fix: Bugs created after the overhaul, performance and layout fixes
@@ -25,7 +25,7 @@ DEBUG=true
|
|||||||
NGINX_PORT=80
|
NGINX_PORT=80
|
||||||
|
|
||||||
# Local file storage (override if you want to store data elsewhere)
|
# Local file storage (override if you want to store data elsewhere)
|
||||||
SQLITE_DB_PATH=./mqtt_data.db
|
SQLITE_DB_PATH=./data/database.db
|
||||||
BUILT_MELODIES_STORAGE_PATH=./storage/built_melodies
|
BUILT_MELODIES_STORAGE_PATH=./storage/built_melodies
|
||||||
FIRMWARE_STORAGE_PATH=./storage/firmware
|
FIRMWARE_STORAGE_PATH=./storage/firmware
|
||||||
|
|
||||||
|
|||||||
5
.gitignore
vendored
@@ -12,6 +12,11 @@ firebase-service-account.json
|
|||||||
!/data/.gitkeep
|
!/data/.gitkeep
|
||||||
!/data/built_melodies/.gitkeep
|
!/data/built_melodies/.gitkeep
|
||||||
|
|
||||||
|
# SQLite databases
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
395
AUTOMATION_ENGINE_STRATEGY.md
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
# BellSystems CP — Automation & Notification Engine Strategy
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document defines the architecture and implementation plan for a three-tier intelligence layer built on top of the existing BellSystems Control Panel. The system consists of:
|
||||||
|
|
||||||
|
1. **Event Logs** — passive, timestamped record of notable system events
|
||||||
|
2. **Notifications** — real-time or near-real-time alerts surfaced in the UI
|
||||||
|
3. **Automation Rules** — trigger → condition → action pipelines, configurable via UI
|
||||||
|
|
||||||
|
The existing tech stack is unchanged: **FastAPI + SQLite (aiosqlite) + Firestore + React**. Everything new slots in as additional tables in `mqtt_data.db`, new backend modules, and new frontend pages/components.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ Scheduler Loop (runs inside existing FastAPI │
|
||||||
|
│ startup, alongside email_sync_loop) │
|
||||||
|
│ │
|
||||||
|
│ Every 60s: evaluate_rules() │
|
||||||
|
│ ↓ │
|
||||||
|
│ Rules Engine │
|
||||||
|
│ → loads enabled rules from DB │
|
||||||
|
│ → evaluates conditions against live data │
|
||||||
|
│ → fires Action Executor on match │
|
||||||
|
│ │
|
||||||
|
│ Action Executor │
|
||||||
|
│ → create_event_log() │
|
||||||
|
│ → create_notification() │
|
||||||
|
│ → send_email() (existing) │
|
||||||
|
│ → mqtt_publish_command() (existing) │
|
||||||
|
│ → update_field() │
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
↕ REST / WebSocket
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ React Frontend │
|
||||||
|
│ - Bell icon in Header (unread count badge) │
|
||||||
|
│ - Notifications dropdown/panel │
|
||||||
|
│ - /automations page (rule CRUD) │
|
||||||
|
│ - Event Log viewer (filterable) │
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema (additions to `mqtt_data.db`)
|
||||||
|
|
||||||
|
### `event_log`
|
||||||
|
Permanent, append-only record of things that happened.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS event_log (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
category TEXT NOT NULL, -- 'device' | 'crm' | 'quotation' | 'user' | 'system'
|
||||||
|
entity_type TEXT, -- 'device' | 'customer' | 'quotation' | 'user'
|
||||||
|
entity_id TEXT, -- the ID of the affected record
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
detail TEXT,
|
||||||
|
severity TEXT NOT NULL DEFAULT 'info', -- 'info' | 'warning' | 'error'
|
||||||
|
rule_id TEXT, -- which automation rule triggered this (nullable)
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_event_log_category ON event_log(category, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_event_log_entity ON event_log(entity_type, entity_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `notifications`
|
||||||
|
Short-lived, user-facing alerts. Cleared once read or after TTL.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT,
|
||||||
|
link TEXT, -- optional frontend route, e.g. "/crm/customers/abc123"
|
||||||
|
severity TEXT NOT NULL DEFAULT 'info', -- 'info' | 'warning' | 'error' | 'success'
|
||||||
|
is_read INTEGER NOT NULL DEFAULT 0,
|
||||||
|
rule_id TEXT,
|
||||||
|
entity_type TEXT,
|
||||||
|
entity_id TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_read ON notifications(is_read, created_at);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `automation_rules`
|
||||||
|
Stores user-defined rules. Evaluated by the scheduler.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS automation_rules (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
trigger_type TEXT NOT NULL, -- 'schedule' | 'mqtt_alert' | 'email_received'
|
||||||
|
trigger_config TEXT NOT NULL DEFAULT '{}', -- JSON
|
||||||
|
conditions TEXT NOT NULL DEFAULT '[]', -- JSON array of condition objects
|
||||||
|
actions TEXT NOT NULL DEFAULT '[]', -- JSON array of action objects
|
||||||
|
cooldown_hours REAL NOT NULL DEFAULT 0, -- min hours between firing on same entity
|
||||||
|
last_run_at TEXT,
|
||||||
|
run_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `automation_run_log`
|
||||||
|
Deduplication and audit trail for rule executions.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS automation_run_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
rule_id TEXT NOT NULL,
|
||||||
|
entity_type TEXT,
|
||||||
|
entity_id TEXT,
|
||||||
|
status TEXT NOT NULL, -- 'fired' | 'skipped_cooldown' | 'error'
|
||||||
|
detail TEXT,
|
||||||
|
fired_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_run_log_rule ON automation_run_log(rule_id, fired_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_run_log_entity ON automation_run_log(entity_type, entity_id, fired_at);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Module: `automation/`
|
||||||
|
|
||||||
|
New module at `backend/automation/`, registered in `main.py`.
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/automation/
|
||||||
|
├── __init__.py
|
||||||
|
├── router.py # CRUD for rules, event_log GET, notifications GET/PATCH
|
||||||
|
├── models.py # Pydantic schemas for rules, conditions, actions
|
||||||
|
├── engine.py # evaluate_rules(), condition evaluators, action executors
|
||||||
|
├── scheduler.py # automation_loop() async task, wired into main.py startup
|
||||||
|
└── database.py # DB helpers for all 4 new tables
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wiring into `main.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from automation.router import router as automation_router
|
||||||
|
from automation.scheduler import automation_loop
|
||||||
|
|
||||||
|
app.include_router(automation_router)
|
||||||
|
|
||||||
|
# In startup():
|
||||||
|
asyncio.create_task(automation_loop())
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rule Object Structure (JSON, stored in DB)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "rule_abc123",
|
||||||
|
"name": "Quotation follow-up after 7 days",
|
||||||
|
"enabled": true,
|
||||||
|
"trigger_type": "schedule",
|
||||||
|
"trigger_config": { "interval_hours": 24 },
|
||||||
|
"conditions": [
|
||||||
|
{ "entity": "quotation", "field": "status", "op": "eq", "value": "sent" },
|
||||||
|
{ "entity": "quotation", "field": "days_since_updated", "op": "gte", "value": 7 },
|
||||||
|
{ "entity": "quotation", "field": "has_reply", "op": "eq", "value": false }
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "send_email",
|
||||||
|
"template_key": "quotation_followup",
|
||||||
|
"to": "{{quotation.client_email}}",
|
||||||
|
"subject": "Following up on Quotation {{quotation.quotation_number}}",
|
||||||
|
"body": "Hi {{customer.name}}, did you have a chance to review our quotation?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "create_notification",
|
||||||
|
"title": "Follow-up sent to {{customer.name}}",
|
||||||
|
"link": "/crm/customers/{{quotation.customer_id}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "create_event_log",
|
||||||
|
"category": "quotation",
|
||||||
|
"severity": "info",
|
||||||
|
"title": "Auto follow-up sent for {{quotation.quotation_number}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cooldown_hours": 168
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supported Trigger Types
|
||||||
|
|
||||||
|
| Trigger | How it works |
|
||||||
|
|---|---|
|
||||||
|
| `schedule` | Evaluated every N hours by the background loop |
|
||||||
|
| `mqtt_alert` | Fires immediately when `_handle_alerts()` in `mqtt/logger.py` upserts an alert — hook into that function |
|
||||||
|
| `email_received` | Fires inside `sync_emails()` in `crm/email_sync.py` after a new inbound email is stored |
|
||||||
|
|
||||||
|
> **Note:** `mqtt_alert` and `email_received` triggers bypass the scheduler loop — they are called directly from the relevant handler functions, giving near-real-time response.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supported Condition Operators
|
||||||
|
|
||||||
|
| op | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `eq` | equals |
|
||||||
|
| `neq` | not equals |
|
||||||
|
| `gt` / `gte` / `lt` / `lte` | numeric comparisons |
|
||||||
|
| `contains` | string contains |
|
||||||
|
| `is_null` / `not_null` | field presence |
|
||||||
|
| `days_since` | computed: (now - field_datetime) in days |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supported Action Types
|
||||||
|
|
||||||
|
| Action | What it does | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `create_event_log` | Writes to `event_log` table | Always safe to fire |
|
||||||
|
| `create_notification` | Writes to `notifications` table | Surfaces in UI bell icon |
|
||||||
|
| `send_email` | Calls existing `crm.email_sync.send_email()` | Uses existing mail accounts |
|
||||||
|
| `update_field` | Updates a field on an entity in DB/Firestore | Use carefully — define allowed fields explicitly |
|
||||||
|
| `mqtt_publish` | Calls `mqtt_manager.publish_command()` | For device auto-actions |
|
||||||
|
| `webhook` | HTTP POST to an external URL | Future / optional |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notification System (Frontend)
|
||||||
|
|
||||||
|
### Bell Icon in Header
|
||||||
|
|
||||||
|
- Polling endpoint: `GET /api/notifications?unread=true&limit=20`
|
||||||
|
- Poll interval: 30 seconds (or switch to WebSocket push — the WS infrastructure already exists via `mqtt_manager`)
|
||||||
|
- Badge shows unread count
|
||||||
|
- Click opens a dropdown panel listing recent notifications with title, time, severity color, and optional link
|
||||||
|
|
||||||
|
### Notification Panel
|
||||||
|
- Mark as read: `PATCH /api/notifications/{id}/read`
|
||||||
|
- Mark all read: `PATCH /api/notifications/read-all`
|
||||||
|
- Link field navigates to the relevant page on click
|
||||||
|
|
||||||
|
### Toast Popups (optional, Phase 3 polish)
|
||||||
|
- Triggered by polling detecting new unread notifications since last check
|
||||||
|
- Use an existing toast component if one exists, otherwise add a lightweight one
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Automation Rules UI (`/automations`)
|
||||||
|
|
||||||
|
A new sidebar entry under Settings (sysadmin/admin only).
|
||||||
|
|
||||||
|
### Rule List Page
|
||||||
|
- Table: name, enabled toggle, trigger type, last run, run count, edit/delete
|
||||||
|
- "New Rule" button
|
||||||
|
|
||||||
|
### Rule Editor (modal or full page)
|
||||||
|
- **Name & description** — free text
|
||||||
|
- **Trigger** — dropdown: Schedule / MQTT Alert / Email Received
|
||||||
|
- Schedule: interval hours input
|
||||||
|
- MQTT Alert: subsystem filter (optional)
|
||||||
|
- Email Received: from address filter (optional)
|
||||||
|
- **Conditions** — dynamic list, each row:
|
||||||
|
- Entity selector (Quotation / Device / Customer / User)
|
||||||
|
- Field selector (populated based on entity)
|
||||||
|
- Operator dropdown
|
||||||
|
- Value input
|
||||||
|
- **Actions** — dynamic list, each row:
|
||||||
|
- Action type dropdown
|
||||||
|
- Type-specific fields (to address, subject, body for email; notification title/body; etc.)
|
||||||
|
- Template variables hint: `{{quotation.quotation_number}}`, `{{customer.name}}`, etc.
|
||||||
|
- **Cooldown** — hours between firings on the same entity
|
||||||
|
- **Enabled** toggle
|
||||||
|
|
||||||
|
### Rule Run History
|
||||||
|
- Per-rule log: when it fired, on which entity, success/error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Event Log UI
|
||||||
|
|
||||||
|
Accessible from `/event-log` route, linked from Dashboard.
|
||||||
|
|
||||||
|
- Filterable by: category, severity, entity type, date range
|
||||||
|
- Columns: time, category, severity badge, title, entity link
|
||||||
|
- Append-only (no deletion from UI)
|
||||||
|
- Retention: purge entries older than configurable days (e.g. 180 days) via the existing `purge_loop` pattern in `mqtt/database.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-Built Rules (Seeded on First Run, All Disabled)
|
||||||
|
|
||||||
|
These are created on first startup — the admin enables and customizes them.
|
||||||
|
|
||||||
|
| Rule | Trigger | Condition | Action |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Quotation follow-up | Schedule 24h | status=sent AND days_since_updated ≥ 7 AND no reply | Send follow-up email + notify |
|
||||||
|
| Device offline warning | Schedule 1h | no heartbeat for > 2h | Create notification + event log |
|
||||||
|
| New unknown email | email_received | customer_id IS NULL | Create notification |
|
||||||
|
| Subscription expiring soon | Schedule 24h | subscription.expiry_date within 7 days | Notify + send email |
|
||||||
|
| Device critical alert | mqtt_alert | state = CRITICAL | Notify + event log + optional MQTT restart |
|
||||||
|
| Quotation expired | Schedule 24h | status=sent AND days_since_updated ≥ 30 | Update status → expired + notify |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1 — Foundation (DB + API)
|
||||||
|
- [ ] Add 4 new tables to `mqtt/database.py` schema + migrations
|
||||||
|
- [ ] Create `automation/database.py` with all DB helpers
|
||||||
|
- [ ] Create `automation/models.py` — Pydantic schemas for rules, conditions, actions, notifications, event_log
|
||||||
|
- [ ] Create `automation/router.py` — CRUD for rules, GET event_log, GET/PATCH notifications
|
||||||
|
- [ ] Wire router into `main.py`
|
||||||
|
|
||||||
|
### Phase 2 — Rules Engine + Scheduler
|
||||||
|
- [ ] Create `automation/engine.py` — condition evaluator, template renderer, action executor
|
||||||
|
- [ ] Create `automation/scheduler.py` — `automation_loop()` async task
|
||||||
|
- [ ] Hook `email_received` trigger into `crm/email_sync.sync_emails()`
|
||||||
|
- [ ] Hook `mqtt_alert` trigger into `mqtt/logger._handle_alerts()`
|
||||||
|
- [ ] Seed pre-built (disabled) rules on first startup
|
||||||
|
- [ ] Wire `automation_loop()` into `main.py` startup
|
||||||
|
|
||||||
|
### Phase 3 — Notification UI
|
||||||
|
- [ ] Bell icon with unread badge in `Header.jsx`
|
||||||
|
- [ ] Notifications dropdown panel component
|
||||||
|
- [ ] 30s polling hook in React
|
||||||
|
- [ ] Mark read / mark all read
|
||||||
|
|
||||||
|
### Phase 4 — Automation Rules UI
|
||||||
|
- [ ] `/automations` route and rule list page
|
||||||
|
- [ ] Rule editor form (conditions + actions dynamic builder)
|
||||||
|
- [ ] Enable/disable toggle
|
||||||
|
- [ ] Run history per rule
|
||||||
|
- [ ] Add "Automations" entry to Sidebar under Settings
|
||||||
|
|
||||||
|
### Phase 5 — Event Log UI
|
||||||
|
- [ ] `/event-log` route with filterable table
|
||||||
|
- [ ] Purge policy wired into existing `purge_loop`
|
||||||
|
- [ ] Dashboard widget showing recent high-severity events
|
||||||
|
|
||||||
|
### Phase 6 — Polish
|
||||||
|
- [ ] Toast notifications on new unread detection
|
||||||
|
- [ ] Template variable previewer in rule editor
|
||||||
|
- [ ] "Run now" button per rule (for testing without waiting for scheduler)
|
||||||
|
- [ ] Named email templates stored in DB (reusable across rules)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
| Decision | Choice | Reason |
|
||||||
|
|---|---|---|
|
||||||
|
| Storage | SQLite (same `mqtt_data.db`) | Consistent with existing pattern; no new infra |
|
||||||
|
| Scheduler | `asyncio` task in FastAPI startup | Same pattern as `email_sync_loop` and `purge_loop` already in `main.py` |
|
||||||
|
| Rule format | JSON columns in DB | Flexible, UI-editable, no schema migrations per new rule type |
|
||||||
|
| Template variables | `{{entity.field}}` string interpolation | Simple to implement, readable in UI |
|
||||||
|
| Cooldown dedup | `automation_run_log` per (rule_id, entity_id) | Prevents repeat firing on same quotation/device within cooldown window |
|
||||||
|
| Notification delivery | DB polling (30s) initially | The WS infra exists (`mqtt_manager._ws_subscribers`) — easy to upgrade later |
|
||||||
|
| Pre-built rules | Seeded as disabled | Non-intrusive — admin must consciously enable each one |
|
||||||
|
| `update_field` safety | Explicit allowlist of permitted fields | Prevents accidental data corruption from misconfigured rules |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Template Variables Reference
|
||||||
|
|
||||||
|
Available inside action `body`, `subject`, `title`, `link` fields:
|
||||||
|
|
||||||
|
| Variable | Source |
|
||||||
|
|---|---|
|
||||||
|
| `{{customer.name}}` | Firestore `crm_customers` |
|
||||||
|
| `{{customer.organization}}` | Firestore `crm_customers` |
|
||||||
|
| `{{quotation.quotation_number}}` | SQLite `crm_quotations` |
|
||||||
|
| `{{quotation.final_total}}` | SQLite `crm_quotations` |
|
||||||
|
| `{{quotation.status}}` | SQLite `crm_quotations` |
|
||||||
|
| `{{quotation.client_email}}` | SQLite `crm_quotations` |
|
||||||
|
| `{{device.serial}}` | Firestore `devices` |
|
||||||
|
| `{{device.label}}` | Firestore `devices` |
|
||||||
|
| `{{alert.subsystem}}` | MQTT alert payload |
|
||||||
|
| `{{alert.state}}` | MQTT alert payload |
|
||||||
|
| `{{user.email}}` | Firestore `users` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `crm/email_sync.send_email()` is reused as-is for the `send_email` action type. The engine constructs the call parameters.
|
||||||
|
- `update_field` actions start with an allowlist of: `quotation.status`, `user.status`. Expand deliberately.
|
||||||
|
- For MQTT auto-restart, `mqtt_manager.publish_command(serial, "restart", {})` already works — the engine just calls it.
|
||||||
|
- Firestore is read-only from the automation engine (for customer/device lookups). All writes go to SQLite, consistent with the existing architecture.
|
||||||
|
- The `has_reply` condition on quotations is computed by checking whether any `crm_comms_log` entry exists with `direction='inbound'` and `customer_id` matching the quotation's customer, dated after the quotation's `updated_at`.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
# WeasyPrint system dependencies (libpango, libcairo, etc.)
|
# System dependencies: WeasyPrint (pango/cairo), ffmpeg (video thumbs), poppler (pdf2image)
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libpango-1.0-0 \
|
libpango-1.0-0 \
|
||||||
libpangocairo-1.0-0 \
|
libpangocairo-1.0-0 \
|
||||||
@@ -8,6 +8,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libffi-dev \
|
libffi-dev \
|
||||||
shared-mime-info \
|
shared-mime-info \
|
||||||
fonts-dejavu-core \
|
fonts-dejavu-core \
|
||||||
|
ffmpeg \
|
||||||
|
poppler-utils \
|
||||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from mqtt.database import get_db
|
from database import get_db
|
||||||
|
|
||||||
logger = logging.getLogger("builder.database")
|
logger = logging.getLogger("builder.database")
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ class Settings(BaseSettings):
|
|||||||
mosquitto_password_file: str = "/etc/mosquitto/passwd"
|
mosquitto_password_file: str = "/etc/mosquitto/passwd"
|
||||||
mqtt_client_id: str = "bellsystems-admin-panel"
|
mqtt_client_id: str = "bellsystems-admin-panel"
|
||||||
|
|
||||||
# SQLite (MQTT data storage)
|
# SQLite (local application database)
|
||||||
sqlite_db_path: str = "./mqtt_data.db"
|
sqlite_db_path: str = "./data/database.db"
|
||||||
mqtt_data_retention_days: int = 90
|
mqtt_data_retention_days: int = 90
|
||||||
|
|
||||||
# Local file storage
|
# Local file storage
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from fastapi import APIRouter, Depends, Query, BackgroundTasks
|
from fastapi import APIRouter, Depends, Query, BackgroundTasks, Body
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from auth.models import TokenPayload
|
from auth.models import TokenPayload
|
||||||
@@ -14,15 +14,25 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=CustomerListResponse)
|
@router.get("", response_model=CustomerListResponse)
|
||||||
def list_customers(
|
async def list_customers(
|
||||||
search: Optional[str] = Query(None),
|
search: Optional[str] = Query(None),
|
||||||
tag: Optional[str] = Query(None),
|
tag: Optional[str] = Query(None),
|
||||||
|
sort: Optional[str] = Query(None),
|
||||||
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
):
|
):
|
||||||
customers = service.list_customers(search=search, tag=tag)
|
customers = service.list_customers(search=search, tag=tag, sort=sort)
|
||||||
|
if sort == "latest_comm":
|
||||||
|
customers = await service.list_customers_sorted_by_latest_comm(customers)
|
||||||
return CustomerListResponse(customers=customers, total=len(customers))
|
return CustomerListResponse(customers=customers, total=len(customers))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tags", response_model=list[str])
|
||||||
|
def list_tags(
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
return service.list_all_tags()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{customer_id}", response_model=CustomerInDB)
|
@router.get("/{customer_id}", response_model=CustomerInDB)
|
||||||
def get_customer(
|
def get_customer(
|
||||||
customer_id: str,
|
customer_id: str,
|
||||||
@@ -64,8 +74,57 @@ def update_customer(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{customer_id}", status_code=204)
|
@router.delete("/{customer_id}", status_code=204)
|
||||||
def delete_customer(
|
async def delete_customer(
|
||||||
|
customer_id: str,
|
||||||
|
wipe_comms: bool = Query(False),
|
||||||
|
wipe_files: bool = Query(False),
|
||||||
|
wipe_nextcloud: bool = Query(False),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
customer = service.delete_customer(customer_id)
|
||||||
|
nc_path = service.get_customer_nc_path(customer)
|
||||||
|
|
||||||
|
if wipe_comms or wipe_nextcloud:
|
||||||
|
await service.delete_customer_comms(customer_id)
|
||||||
|
|
||||||
|
if wipe_files or wipe_nextcloud:
|
||||||
|
await service.delete_customer_media_entries(customer_id)
|
||||||
|
|
||||||
|
if settings.nextcloud_url:
|
||||||
|
folder = f"customers/{nc_path}"
|
||||||
|
if wipe_nextcloud:
|
||||||
|
try:
|
||||||
|
await nextcloud.delete_file(folder)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not delete NC folder for customer %s: %s", customer_id, e)
|
||||||
|
elif wipe_files:
|
||||||
|
stale_folder = f"customers/STALE_{nc_path}"
|
||||||
|
try:
|
||||||
|
await nextcloud.rename_folder(folder, stale_folder)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not rename NC folder for customer %s: %s", customer_id, e)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{customer_id}/toggle-negotiating", response_model=CustomerInDB)
|
||||||
|
async def toggle_negotiating(
|
||||||
customer_id: str,
|
customer_id: str,
|
||||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
):
|
):
|
||||||
service.delete_customer(customer_id)
|
return await service.toggle_negotiating(customer_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{customer_id}/toggle-problem", response_model=CustomerInDB)
|
||||||
|
async def toggle_problem(
|
||||||
|
customer_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return await service.toggle_problem(customer_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{customer_id}/last-comm-direction")
|
||||||
|
async def get_last_comm_direction(
|
||||||
|
customer_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
direction = await service.get_last_comm_direction(customer_id)
|
||||||
|
return {"direction": direction}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from email import encoders
|
|||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
from mqtt import database as mqtt_db
|
import database as mqtt_db
|
||||||
from crm.mail_accounts import get_mail_accounts, account_by_key, account_by_email
|
from crm.mail_accounts import get_mail_accounts, account_by_key, account_by_email
|
||||||
|
|
||||||
logger = logging.getLogger("crm.email_sync")
|
logger = logging.getLogger("crm.email_sync")
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ class ProductCreate(BaseModel):
|
|||||||
sku: Optional[str] = None
|
sku: Optional[str] = None
|
||||||
category: ProductCategory
|
category: ProductCategory
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
name_en: Optional[str] = None
|
||||||
|
name_gr: Optional[str] = None
|
||||||
|
description_en: Optional[str] = None
|
||||||
|
description_gr: Optional[str] = None
|
||||||
price: float
|
price: float
|
||||||
currency: str = "EUR"
|
currency: str = "EUR"
|
||||||
costs: Optional[ProductCosts] = None
|
costs: Optional[ProductCosts] = None
|
||||||
@@ -49,6 +53,10 @@ class ProductUpdate(BaseModel):
|
|||||||
sku: Optional[str] = None
|
sku: Optional[str] = None
|
||||||
category: Optional[ProductCategory] = None
|
category: Optional[ProductCategory] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
name_en: Optional[str] = None
|
||||||
|
name_gr: Optional[str] = None
|
||||||
|
description_en: Optional[str] = None
|
||||||
|
description_gr: Optional[str] = None
|
||||||
price: Optional[float] = None
|
price: Optional[float] = None
|
||||||
currency: Optional[str] = None
|
currency: Optional[str] = None
|
||||||
costs: Optional[ProductCosts] = None
|
costs: Optional[ProductCosts] = None
|
||||||
@@ -114,9 +122,11 @@ class OwnedItem(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CustomerLocation(BaseModel):
|
class CustomerLocation(BaseModel):
|
||||||
|
address: Optional[str] = None
|
||||||
city: Optional[str] = None
|
city: Optional[str] = None
|
||||||
country: Optional[str] = None
|
postal_code: Optional[str] = None
|
||||||
region: Optional[str] = None
|
region: Optional[str] = None
|
||||||
|
country: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class CustomerCreate(BaseModel):
|
class CustomerCreate(BaseModel):
|
||||||
@@ -124,6 +134,7 @@ class CustomerCreate(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
surname: Optional[str] = None
|
surname: Optional[str] = None
|
||||||
organization: Optional[str] = None
|
organization: Optional[str] = None
|
||||||
|
religion: Optional[str] = None
|
||||||
contacts: List[CustomerContact] = []
|
contacts: List[CustomerContact] = []
|
||||||
notes: List[CustomerNote] = []
|
notes: List[CustomerNote] = []
|
||||||
location: Optional[CustomerLocation] = None
|
location: Optional[CustomerLocation] = None
|
||||||
@@ -133,6 +144,8 @@ class CustomerCreate(BaseModel):
|
|||||||
linked_user_ids: List[str] = []
|
linked_user_ids: List[str] = []
|
||||||
nextcloud_folder: Optional[str] = None
|
nextcloud_folder: Optional[str] = None
|
||||||
folder_id: Optional[str] = None # Human-readable Nextcloud folder name, e.g. "saint-john-corfu"
|
folder_id: Optional[str] = None # Human-readable Nextcloud folder name, e.g. "saint-john-corfu"
|
||||||
|
negotiating: bool = False
|
||||||
|
has_problem: bool = False
|
||||||
|
|
||||||
|
|
||||||
class CustomerUpdate(BaseModel):
|
class CustomerUpdate(BaseModel):
|
||||||
@@ -140,6 +153,7 @@ class CustomerUpdate(BaseModel):
|
|||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
surname: Optional[str] = None
|
surname: Optional[str] = None
|
||||||
organization: Optional[str] = None
|
organization: Optional[str] = None
|
||||||
|
religion: Optional[str] = None
|
||||||
contacts: Optional[List[CustomerContact]] = None
|
contacts: Optional[List[CustomerContact]] = None
|
||||||
notes: Optional[List[CustomerNote]] = None
|
notes: Optional[List[CustomerNote]] = None
|
||||||
location: Optional[CustomerLocation] = None
|
location: Optional[CustomerLocation] = None
|
||||||
@@ -148,6 +162,8 @@ class CustomerUpdate(BaseModel):
|
|||||||
owned_items: Optional[List[OwnedItem]] = None
|
owned_items: Optional[List[OwnedItem]] = None
|
||||||
linked_user_ids: Optional[List[str]] = None
|
linked_user_ids: Optional[List[str]] = None
|
||||||
nextcloud_folder: Optional[str] = None
|
nextcloud_folder: Optional[str] = None
|
||||||
|
negotiating: Optional[bool] = None
|
||||||
|
has_problem: Optional[bool] = None
|
||||||
# folder_id intentionally excluded from update — set once at creation
|
# folder_id intentionally excluded from update — set once at creation
|
||||||
|
|
||||||
|
|
||||||
@@ -286,8 +302,11 @@ class CommCreate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CommUpdate(BaseModel):
|
class CommUpdate(BaseModel):
|
||||||
|
type: Optional[CommType] = None
|
||||||
|
direction: Optional[CommDirection] = None
|
||||||
subject: Optional[str] = None
|
subject: Optional[str] = None
|
||||||
body: Optional[str] = None
|
body: Optional[str] = None
|
||||||
|
logged_by: Optional[str] = None
|
||||||
occurred_at: Optional[str] = None
|
occurred_at: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -333,6 +352,7 @@ class MediaCreate(BaseModel):
|
|||||||
direction: Optional[MediaDirection] = None
|
direction: Optional[MediaDirection] = None
|
||||||
tags: List[str] = []
|
tags: List[str] = []
|
||||||
uploaded_by: Optional[str] = None
|
uploaded_by: Optional[str] = None
|
||||||
|
thumbnail_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class MediaInDB(BaseModel):
|
class MediaInDB(BaseModel):
|
||||||
@@ -346,6 +366,7 @@ class MediaInDB(BaseModel):
|
|||||||
tags: List[str] = []
|
tags: List[str] = []
|
||||||
uploaded_by: Optional[str] = None
|
uploaded_by: Optional[str] = None
|
||||||
created_at: str
|
created_at: str
|
||||||
|
thumbnail_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class MediaListResponse(BaseModel):
|
class MediaListResponse(BaseModel):
|
||||||
|
|||||||
@@ -312,3 +312,18 @@ async def delete_file(relative_path: str) -> None:
|
|||||||
resp = await client.request("DELETE", url, auth=_auth())
|
resp = await client.request("DELETE", url, auth=_auth())
|
||||||
if resp.status_code not in (200, 204, 404):
|
if resp.status_code not in (200, 204, 404):
|
||||||
raise HTTPException(status_code=502, detail=f"Nextcloud delete failed: {resp.status_code}")
|
raise HTTPException(status_code=502, detail=f"Nextcloud delete failed: {resp.status_code}")
|
||||||
|
|
||||||
|
|
||||||
|
async def rename_folder(old_relative_path: str, new_relative_path: str) -> None:
|
||||||
|
"""Rename/move a folder in Nextcloud using WebDAV MOVE."""
|
||||||
|
url = _full_url(old_relative_path)
|
||||||
|
destination = _full_url(new_relative_path)
|
||||||
|
client = _get_client()
|
||||||
|
resp = await client.request(
|
||||||
|
"MOVE",
|
||||||
|
url,
|
||||||
|
auth=_auth(),
|
||||||
|
headers={"Destination": destination, "Overwrite": "F"},
|
||||||
|
)
|
||||||
|
if resp.status_code not in (201, 204):
|
||||||
|
raise HTTPException(status_code=502, detail=f"Nextcloud rename failed: {resp.status_code}")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Folder convention (all paths relative to nextcloud_base_path = BellSystems/Conso
|
|||||||
folder_id = customer.folder_id if set, else customer.id (legacy fallback).
|
folder_id = customer.folder_id if set, else customer.id (legacy fallback).
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, Query, UploadFile, File, Form, Response, HTTPException, Request
|
from fastapi import APIRouter, Depends, Query, UploadFile, File, Form, Response, HTTPException, Request
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from jose import JWTError
|
from jose import JWTError
|
||||||
@@ -17,7 +18,9 @@ from auth.models import TokenPayload
|
|||||||
from auth.dependencies import require_permission
|
from auth.dependencies import require_permission
|
||||||
from auth.utils import decode_access_token
|
from auth.utils import decode_access_token
|
||||||
from crm import nextcloud, service
|
from crm import nextcloud, service
|
||||||
|
from config import settings
|
||||||
from crm.models import MediaCreate, MediaDirection
|
from crm.models import MediaCreate, MediaDirection
|
||||||
|
from crm.thumbnails import generate_thumbnail
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/crm/nextcloud", tags=["crm-nextcloud"])
|
router = APIRouter(prefix="/api/crm/nextcloud", tags=["crm-nextcloud"])
|
||||||
|
|
||||||
@@ -30,6 +33,29 @@ DIRECTION_MAP = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/web-url")
|
||||||
|
async def get_web_url(
|
||||||
|
path: str = Query(..., description="Path relative to nextcloud_base_path"),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Return the Nextcloud Files web-UI URL for a given file path.
|
||||||
|
Opens the parent folder with the file highlighted.
|
||||||
|
"""
|
||||||
|
if not settings.nextcloud_url:
|
||||||
|
raise HTTPException(status_code=503, detail="Nextcloud not configured")
|
||||||
|
base = settings.nextcloud_base_path.strip("/")
|
||||||
|
# path is relative to base, e.g. "customers/abc/media/photo.jpg"
|
||||||
|
parts = path.rsplit("/", 1)
|
||||||
|
folder_rel = parts[0] if len(parts) == 2 else ""
|
||||||
|
filename = parts[-1]
|
||||||
|
nc_dir = f"/{base}/{folder_rel}" if folder_rel else f"/{base}"
|
||||||
|
from urllib.parse import urlencode, quote
|
||||||
|
qs = urlencode({"dir": nc_dir, "scrollto": filename})
|
||||||
|
url = f"{settings.nextcloud_url.rstrip('/')}/index.php/apps/files/?{qs}"
|
||||||
|
return {"url": url}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/browse")
|
@router.get("/browse")
|
||||||
async def browse(
|
async def browse(
|
||||||
path: str = Query(..., description="Path relative to nextcloud_base_path"),
|
path: str = Query(..., description="Path relative to nextcloud_base_path"),
|
||||||
@@ -56,6 +82,14 @@ async def browse_all(
|
|||||||
|
|
||||||
all_files = await nextcloud.list_folder_recursive(base)
|
all_files = await nextcloud.list_folder_recursive(base)
|
||||||
|
|
||||||
|
# Exclude _info.txt stubs — human-readable only, should never appear in the UI.
|
||||||
|
# .thumbs/ files are kept: the frontend needs them to build the thumbnail map
|
||||||
|
# (it already filters them out of the visible file list itself).
|
||||||
|
all_files = [
|
||||||
|
f for f in all_files
|
||||||
|
if not f["path"].endswith("/_info.txt")
|
||||||
|
]
|
||||||
|
|
||||||
# Tag each file with the top-level subfolder it lives under
|
# Tag each file with the top-level subfolder it lives under
|
||||||
for item in all_files:
|
for item in all_files:
|
||||||
parts = item["path"].split("/")
|
parts = item["path"].split("/")
|
||||||
@@ -84,33 +118,54 @@ async def proxy_file(
|
|||||||
except (JWTError, KeyError):
|
except (JWTError, KeyError):
|
||||||
raise HTTPException(status_code=403, detail="Invalid token")
|
raise HTTPException(status_code=403, detail="Invalid token")
|
||||||
|
|
||||||
content, mime_type = await nextcloud.download_file(path)
|
# Forward the Range header to Nextcloud so we get a true partial response
|
||||||
total = len(content)
|
# without buffering the whole file into memory.
|
||||||
|
nc_url = nextcloud._full_url(path)
|
||||||
|
nc_auth = nextcloud._auth()
|
||||||
|
forward_headers = {}
|
||||||
range_header = request.headers.get("range")
|
range_header = request.headers.get("range")
|
||||||
if range_header and range_header.startswith("bytes="):
|
if range_header:
|
||||||
# Parse "bytes=start-end"
|
forward_headers["Range"] = range_header
|
||||||
try:
|
|
||||||
range_spec = range_header[6:]
|
|
||||||
start_str, _, end_str = range_spec.partition("-")
|
|
||||||
start = int(start_str) if start_str else 0
|
|
||||||
end = int(end_str) if end_str else total - 1
|
|
||||||
end = min(end, total - 1)
|
|
||||||
chunk = content[start:end + 1]
|
|
||||||
headers = {
|
|
||||||
"Content-Range": f"bytes {start}-{end}/{total}",
|
|
||||||
"Accept-Ranges": "bytes",
|
|
||||||
"Content-Length": str(len(chunk)),
|
|
||||||
"Content-Type": mime_type,
|
|
||||||
}
|
|
||||||
return Response(content=chunk, status_code=206, headers=headers, media_type=mime_type)
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return Response(
|
import httpx as _httpx
|
||||||
content=content,
|
|
||||||
|
# Use a dedicated streaming client — httpx.stream() keeps the connection open
|
||||||
|
# for the lifetime of the generator, so we can't reuse the shared persistent client.
|
||||||
|
# We enter the stream context here to get headers immediately (no body buffering),
|
||||||
|
# then hand the body iterator to StreamingResponse.
|
||||||
|
stream_client = _httpx.AsyncClient(timeout=None, follow_redirects=True)
|
||||||
|
nc_resp_ctx = stream_client.stream("GET", nc_url, auth=nc_auth, headers=forward_headers)
|
||||||
|
nc_resp = await nc_resp_ctx.__aenter__()
|
||||||
|
|
||||||
|
if nc_resp.status_code == 404:
|
||||||
|
await nc_resp_ctx.__aexit__(None, None, None)
|
||||||
|
await stream_client.aclose()
|
||||||
|
raise HTTPException(status_code=404, detail="File not found in Nextcloud")
|
||||||
|
if nc_resp.status_code not in (200, 206):
|
||||||
|
await nc_resp_ctx.__aexit__(None, None, None)
|
||||||
|
await stream_client.aclose()
|
||||||
|
raise HTTPException(status_code=502, detail=f"Nextcloud returned {nc_resp.status_code}")
|
||||||
|
|
||||||
|
mime_type = nc_resp.headers.get("content-type", "application/octet-stream").split(";")[0].strip()
|
||||||
|
|
||||||
|
resp_headers = {"Accept-Ranges": "bytes"}
|
||||||
|
for h in ("content-range", "content-length"):
|
||||||
|
if h in nc_resp.headers:
|
||||||
|
resp_headers[h.title()] = nc_resp.headers[h]
|
||||||
|
|
||||||
|
async def _stream():
|
||||||
|
try:
|
||||||
|
async for chunk in nc_resp.aiter_bytes(chunk_size=64 * 1024):
|
||||||
|
yield chunk
|
||||||
|
finally:
|
||||||
|
await nc_resp_ctx.__aexit__(None, None, None)
|
||||||
|
await stream_client.aclose()
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
_stream(),
|
||||||
|
status_code=nc_resp.status_code,
|
||||||
media_type=mime_type,
|
media_type=mime_type,
|
||||||
headers={"Accept-Ranges": "bytes", "Content-Length": str(total)},
|
headers=resp_headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -164,6 +219,24 @@ async def upload_file(
|
|||||||
mime_type = file.content_type or "application/octet-stream"
|
mime_type = file.content_type or "application/octet-stream"
|
||||||
await nextcloud.upload_file(file_path, content, mime_type)
|
await nextcloud.upload_file(file_path, content, mime_type)
|
||||||
|
|
||||||
|
# Generate and upload thumbnail (best-effort, non-blocking)
|
||||||
|
# Always stored as {stem}.jpg regardless of source extension so the thumb
|
||||||
|
# filename is unambiguous and the existence check can never false-positive.
|
||||||
|
thumb_path = None
|
||||||
|
try:
|
||||||
|
thumb_bytes = generate_thumbnail(content, mime_type, file.filename)
|
||||||
|
if thumb_bytes:
|
||||||
|
thumb_folder = f"{target_folder}/.thumbs"
|
||||||
|
stem = file.filename.rsplit(".", 1)[0] if "." in file.filename else file.filename
|
||||||
|
thumb_filename = f"{stem}.jpg"
|
||||||
|
thumb_nc_path = f"{thumb_folder}/{thumb_filename}"
|
||||||
|
await nextcloud.ensure_folder(thumb_folder)
|
||||||
|
await nextcloud.upload_file(thumb_nc_path, thumb_bytes, "image/jpeg")
|
||||||
|
thumb_path = thumb_nc_path
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).warning("Thumbnail generation failed for %s: %s", file.filename, e)
|
||||||
|
|
||||||
# Resolve direction
|
# Resolve direction
|
||||||
resolved_direction = None
|
resolved_direction = None
|
||||||
if direction:
|
if direction:
|
||||||
@@ -184,6 +257,7 @@ async def upload_file(
|
|||||||
direction=resolved_direction,
|
direction=resolved_direction,
|
||||||
tags=tag_list,
|
tags=tag_list,
|
||||||
uploaded_by=_user.name,
|
uploaded_by=_user.name,
|
||||||
|
thumbnail_path=thumb_path,
|
||||||
))
|
))
|
||||||
|
|
||||||
return media_record
|
return media_record
|
||||||
@@ -244,6 +318,11 @@ async def sync_nextcloud_files(
|
|||||||
|
|
||||||
# Collect all NC files recursively (handles nested folders at any depth)
|
# Collect all NC files recursively (handles nested folders at any depth)
|
||||||
all_nc_files = await nextcloud.list_folder_recursive(base)
|
all_nc_files = await nextcloud.list_folder_recursive(base)
|
||||||
|
# Skip .thumbs/ folder contents and the _info.txt stub — these are internal
|
||||||
|
all_nc_files = [
|
||||||
|
f for f in all_nc_files
|
||||||
|
if "/.thumbs/" not in f["path"] and not f["path"].endswith("/_info.txt")
|
||||||
|
]
|
||||||
for item in all_nc_files:
|
for item in all_nc_files:
|
||||||
parts = item["path"].split("/")
|
parts = item["path"].split("/")
|
||||||
item["_subfolder"] = parts[2] if len(parts) > 2 else "media"
|
item["_subfolder"] = parts[2] if len(parts) > 2 else "media"
|
||||||
@@ -274,6 +353,105 @@ async def sync_nextcloud_files(
|
|||||||
return {"synced": synced, "skipped": skipped}
|
return {"synced": synced, "skipped": skipped}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/generate-thumbs")
|
||||||
|
async def generate_thumbs(
|
||||||
|
customer_id: str = Form(...),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Scan all customer files in Nextcloud and generate thumbnails for any file
|
||||||
|
that doesn't already have one in the corresponding .thumbs/ sub-folder.
|
||||||
|
Skips files inside .thumbs/ itself and file types that can't be thumbnailed.
|
||||||
|
Returns counts of generated, skipped (already exists), and failed files.
|
||||||
|
"""
|
||||||
|
customer = service.get_customer(customer_id)
|
||||||
|
nc_path = service.get_customer_nc_path(customer)
|
||||||
|
base = f"customers/{nc_path}"
|
||||||
|
|
||||||
|
all_nc_files = await nextcloud.list_folder_recursive(base)
|
||||||
|
|
||||||
|
# Build a set of existing thumb paths for O(1) lookup
|
||||||
|
existing_thumbs = {
|
||||||
|
f["path"] for f in all_nc_files if "/.thumbs/" in f["path"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only process real files (not thumbs themselves)
|
||||||
|
candidates = [f for f in all_nc_files if "/.thumbs/" not in f["path"]]
|
||||||
|
|
||||||
|
generated = 0
|
||||||
|
skipped = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for f in candidates:
|
||||||
|
# Derive where the thumb would live
|
||||||
|
path = f["path"] # e.g. customers/{nc_path}/{subfolder}/photo.jpg
|
||||||
|
parts = path.rsplit("/", 1)
|
||||||
|
if len(parts) != 2:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
parent_folder, filename = parts
|
||||||
|
stem = filename.rsplit(".", 1)[0] if "." in filename else filename
|
||||||
|
thumb_filename = f"{stem}.jpg"
|
||||||
|
thumb_nc_path = f"{parent_folder}/.thumbs/{thumb_filename}"
|
||||||
|
|
||||||
|
if thumb_nc_path in existing_thumbs:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Download the file, generate thumb, upload
|
||||||
|
try:
|
||||||
|
content, mime_type = await nextcloud.download_file(path)
|
||||||
|
thumb_bytes = generate_thumbnail(content, mime_type, filename)
|
||||||
|
if not thumb_bytes:
|
||||||
|
skipped += 1 # unsupported file type
|
||||||
|
continue
|
||||||
|
thumb_folder = f"{parent_folder}/.thumbs"
|
||||||
|
await nextcloud.ensure_folder(thumb_folder)
|
||||||
|
await nextcloud.upload_file(thumb_nc_path, thumb_bytes, "image/jpeg")
|
||||||
|
generated += 1
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).warning("Thumb gen failed for %s: %s", path, e)
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
return {"generated": generated, "skipped": skipped, "failed": failed}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clear-thumbs")
|
||||||
|
async def clear_thumbs(
|
||||||
|
customer_id: str = Form(...),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete all .thumbs sub-folders for a customer across all subfolders.
|
||||||
|
This lets you regenerate thumbnails from scratch.
|
||||||
|
Returns count of .thumbs folders deleted.
|
||||||
|
"""
|
||||||
|
customer = service.get_customer(customer_id)
|
||||||
|
nc_path = service.get_customer_nc_path(customer)
|
||||||
|
base = f"customers/{nc_path}"
|
||||||
|
|
||||||
|
all_nc_files = await nextcloud.list_folder_recursive(base)
|
||||||
|
|
||||||
|
# Collect unique .thumbs folder paths
|
||||||
|
thumb_folders = set()
|
||||||
|
for f in all_nc_files:
|
||||||
|
if "/.thumbs/" in f["path"]:
|
||||||
|
folder = f["path"].split("/.thumbs/")[0] + "/.thumbs"
|
||||||
|
thumb_folders.add(folder)
|
||||||
|
|
||||||
|
deleted = 0
|
||||||
|
for folder in thumb_folders:
|
||||||
|
try:
|
||||||
|
await nextcloud.delete_file(folder)
|
||||||
|
deleted += 1
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).warning("Failed to delete .thumbs folder %s: %s", folder, e)
|
||||||
|
|
||||||
|
return {"deleted_folders": deleted}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/untrack-deleted")
|
@router.post("/untrack-deleted")
|
||||||
async def untrack_deleted_files(
|
async def untrack_deleted_files(
|
||||||
customer_id: str = Form(...),
|
customer_id: str = Form(...),
|
||||||
@@ -287,15 +465,22 @@ async def untrack_deleted_files(
|
|||||||
nc_path = service.get_customer_nc_path(customer)
|
nc_path = service.get_customer_nc_path(customer)
|
||||||
base = f"customers/{nc_path}"
|
base = f"customers/{nc_path}"
|
||||||
|
|
||||||
# Collect all NC file paths recursively
|
# Collect all NC file paths recursively (excluding thumbs and info stub)
|
||||||
all_nc_files = await nextcloud.list_folder_recursive(base)
|
all_nc_files = await nextcloud.list_folder_recursive(base)
|
||||||
nc_paths = {item["path"] for item in all_nc_files}
|
nc_paths = {
|
||||||
|
item["path"] for item in all_nc_files
|
||||||
|
if "/.thumbs/" not in item["path"] and not item["path"].endswith("/_info.txt")
|
||||||
|
}
|
||||||
|
|
||||||
# Find DB records whose NC path no longer exists
|
# Find DB records whose NC path no longer exists, OR that are internal files
|
||||||
|
# (_info.txt / .thumbs/) which should never have been tracked in the first place.
|
||||||
existing = await service.list_media(customer_id=customer_id)
|
existing = await service.list_media(customer_id=customer_id)
|
||||||
untracked = 0
|
untracked = 0
|
||||||
for m in existing:
|
for m in existing:
|
||||||
if m.nextcloud_path and m.nextcloud_path not in nc_paths:
|
is_internal = m.nextcloud_path and (
|
||||||
|
"/.thumbs/" in m.nextcloud_path or m.nextcloud_path.endswith("/_info.txt")
|
||||||
|
)
|
||||||
|
if m.nextcloud_path and (is_internal or m.nextcloud_path not in nc_paths):
|
||||||
try:
|
try:
|
||||||
await service.delete_media(m.id)
|
await service.delete_media(m.id)
|
||||||
untracked += 1
|
untracked += 1
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ class QuotationStatus(str, Enum):
|
|||||||
class QuotationItemCreate(BaseModel):
|
class QuotationItemCreate(BaseModel):
|
||||||
product_id: Optional[str] = None
|
product_id: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
description_en: Optional[str] = None
|
||||||
|
description_gr: Optional[str] = None
|
||||||
unit_type: str = "pcs" # pcs / kg / m
|
unit_type: str = "pcs" # pcs / kg / m
|
||||||
unit_cost: float = 0.0
|
unit_cost: float = 0.0
|
||||||
discount_percent: float = 0.0
|
discount_percent: float = 0.0
|
||||||
@@ -52,6 +54,10 @@ class QuotationCreate(BaseModel):
|
|||||||
client_location: Optional[str] = None
|
client_location: Optional[str] = None
|
||||||
client_phone: Optional[str] = None
|
client_phone: Optional[str] = None
|
||||||
client_email: Optional[str] = None
|
client_email: Optional[str] = None
|
||||||
|
# Legacy quotation fields
|
||||||
|
is_legacy: bool = False
|
||||||
|
legacy_date: Optional[str] = None # ISO date string, manually set
|
||||||
|
legacy_pdf_path: Optional[str] = None # Nextcloud path to uploaded PDF
|
||||||
|
|
||||||
|
|
||||||
class QuotationUpdate(BaseModel):
|
class QuotationUpdate(BaseModel):
|
||||||
@@ -79,6 +85,10 @@ class QuotationUpdate(BaseModel):
|
|||||||
client_location: Optional[str] = None
|
client_location: Optional[str] = None
|
||||||
client_phone: Optional[str] = None
|
client_phone: Optional[str] = None
|
||||||
client_email: Optional[str] = None
|
client_email: Optional[str] = None
|
||||||
|
# Legacy quotation fields
|
||||||
|
is_legacy: Optional[bool] = None
|
||||||
|
legacy_date: Optional[str] = None
|
||||||
|
legacy_pdf_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class QuotationInDB(BaseModel):
|
class QuotationInDB(BaseModel):
|
||||||
@@ -118,6 +128,10 @@ class QuotationInDB(BaseModel):
|
|||||||
client_location: Optional[str] = None
|
client_location: Optional[str] = None
|
||||||
client_phone: Optional[str] = None
|
client_phone: Optional[str] = None
|
||||||
client_email: Optional[str] = None
|
client_email: Optional[str] = None
|
||||||
|
# Legacy quotation fields
|
||||||
|
is_legacy: bool = False
|
||||||
|
legacy_date: Optional[str] = None
|
||||||
|
legacy_pdf_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class QuotationListItem(BaseModel):
|
class QuotationListItem(BaseModel):
|
||||||
@@ -130,6 +144,9 @@ class QuotationListItem(BaseModel):
|
|||||||
created_at: str
|
created_at: str
|
||||||
updated_at: str
|
updated_at: str
|
||||||
nextcloud_pdf_url: Optional[str] = None
|
nextcloud_pdf_url: Optional[str] = None
|
||||||
|
is_legacy: bool = False
|
||||||
|
legacy_date: Optional[str] = None
|
||||||
|
legacy_pdf_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class QuotationListResponse(BaseModel):
|
class QuotationListResponse(BaseModel):
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query, UploadFile, File
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import io
|
import io
|
||||||
@@ -28,6 +28,14 @@ async def get_next_number(
|
|||||||
return NextNumberResponse(next_number=next_num)
|
return NextNumberResponse(next_number=next_num)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/all", response_model=list[dict])
|
||||||
|
async def list_all_quotations(
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
"""Returns all quotations across all customers, each including customer_name."""
|
||||||
|
return await svc.list_all_quotations()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/customer/{customer_id}", response_model=QuotationListResponse)
|
@router.get("/customer/{customer_id}", response_model=QuotationListResponse)
|
||||||
async def list_quotations_for_customer(
|
async def list_quotations_for_customer(
|
||||||
customer_id: str,
|
customer_id: str,
|
||||||
@@ -99,3 +107,15 @@ async def regenerate_pdf(
|
|||||||
):
|
):
|
||||||
"""Force PDF regeneration and re-upload to Nextcloud."""
|
"""Force PDF regeneration and re-upload to Nextcloud."""
|
||||||
return await svc.regenerate_pdf(quotation_id)
|
return await svc.regenerate_pdf(quotation_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{quotation_id}/legacy-pdf", response_model=QuotationInDB)
|
||||||
|
async def upload_legacy_pdf(
|
||||||
|
quotation_id: str,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
"""Upload a PDF file for a legacy quotation and store its Nextcloud path."""
|
||||||
|
pdf_bytes = await file.read()
|
||||||
|
filename = file.filename or f"legacy-{quotation_id}.pdf"
|
||||||
|
return await svc.upload_legacy_pdf(quotation_id, pdf_bytes, filename)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from crm.quotation_models import (
|
|||||||
QuotationUpdate,
|
QuotationUpdate,
|
||||||
)
|
)
|
||||||
from crm.service import get_customer
|
from crm.service import get_customer
|
||||||
from mqtt import database as mqtt_db
|
import database as mqtt_db
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -153,10 +153,42 @@ async def get_next_number() -> str:
|
|||||||
return await _generate_quotation_number(db)
|
return await _generate_quotation_number(db)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_all_quotations() -> list[dict]:
|
||||||
|
"""Return all quotations across all customers, with customer_name injected."""
|
||||||
|
from shared.firebase import get_db as get_firestore
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
rows = await db.execute_fetchall(
|
||||||
|
"SELECT id, quotation_number, title, customer_id, status, final_total, created_at, updated_at, "
|
||||||
|
"nextcloud_pdf_url, is_legacy, legacy_date, legacy_pdf_path "
|
||||||
|
"FROM crm_quotations ORDER BY created_at DESC",
|
||||||
|
(),
|
||||||
|
)
|
||||||
|
items = [dict(r) for r in rows]
|
||||||
|
# Fetch unique customer names from Firestore in one pass
|
||||||
|
customer_ids = {i["customer_id"] for i in items if i.get("customer_id")}
|
||||||
|
customer_names: dict[str, str] = {}
|
||||||
|
if customer_ids:
|
||||||
|
fstore = get_firestore()
|
||||||
|
for cid in customer_ids:
|
||||||
|
try:
|
||||||
|
doc = fstore.collection("crm_customers").document(cid).get()
|
||||||
|
if doc.exists:
|
||||||
|
d = doc.to_dict()
|
||||||
|
parts = [d.get("name", ""), d.get("surname", ""), d.get("organization", "")]
|
||||||
|
label = " ".join(p for p in parts if p).strip()
|
||||||
|
customer_names[cid] = label or cid
|
||||||
|
except Exception:
|
||||||
|
customer_names[cid] = cid
|
||||||
|
for item in items:
|
||||||
|
item["customer_name"] = customer_names.get(item["customer_id"], "")
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
async def list_quotations(customer_id: str) -> list[QuotationListItem]:
|
async def list_quotations(customer_id: str) -> list[QuotationListItem]:
|
||||||
db = await mqtt_db.get_db()
|
db = await mqtt_db.get_db()
|
||||||
rows = await db.execute_fetchall(
|
rows = await db.execute_fetchall(
|
||||||
"SELECT id, quotation_number, title, customer_id, status, final_total, created_at, updated_at, nextcloud_pdf_url "
|
"SELECT id, quotation_number, title, customer_id, status, final_total, created_at, updated_at, "
|
||||||
|
"nextcloud_pdf_url, is_legacy, legacy_date, legacy_pdf_path "
|
||||||
"FROM crm_quotations WHERE customer_id = ? ORDER BY created_at DESC",
|
"FROM crm_quotations WHERE customer_id = ? ORDER BY created_at DESC",
|
||||||
(customer_id,),
|
(customer_id,),
|
||||||
)
|
)
|
||||||
@@ -210,6 +242,7 @@ async def create_quotation(data: QuotationCreate, generate_pdf: bool = False) ->
|
|||||||
subtotal_before_discount, global_discount_amount, new_subtotal, vat_amount, final_total,
|
subtotal_before_discount, global_discount_amount, new_subtotal, vat_amount, final_total,
|
||||||
nextcloud_pdf_path, nextcloud_pdf_url,
|
nextcloud_pdf_path, nextcloud_pdf_url,
|
||||||
client_org, client_name, client_location, client_phone, client_email,
|
client_org, client_name, client_location, client_phone, client_email,
|
||||||
|
is_legacy, legacy_date, legacy_pdf_path,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?, ?, ?,
|
?, ?, ?, ?, ?,
|
||||||
@@ -220,6 +253,7 @@ async def create_quotation(data: QuotationCreate, generate_pdf: bool = False) ->
|
|||||||
?, ?, ?, ?, ?,
|
?, ?, ?, ?, ?,
|
||||||
NULL, NULL,
|
NULL, NULL,
|
||||||
?, ?, ?, ?, ?,
|
?, ?, ?, ?, ?,
|
||||||
|
?, ?, ?,
|
||||||
?, ?
|
?, ?
|
||||||
)""",
|
)""",
|
||||||
(
|
(
|
||||||
@@ -231,6 +265,7 @@ async def create_quotation(data: QuotationCreate, generate_pdf: bool = False) ->
|
|||||||
totals["subtotal_before_discount"], totals["global_discount_amount"],
|
totals["subtotal_before_discount"], totals["global_discount_amount"],
|
||||||
totals["new_subtotal"], totals["vat_amount"], totals["final_total"],
|
totals["new_subtotal"], totals["vat_amount"], totals["final_total"],
|
||||||
data.client_org, data.client_name, data.client_location, data.client_phone, data.client_email,
|
data.client_org, data.client_name, data.client_location, data.client_phone, data.client_email,
|
||||||
|
1 if data.is_legacy else 0, data.legacy_date, data.legacy_pdf_path,
|
||||||
now, now,
|
now, now,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -240,11 +275,12 @@ async def create_quotation(data: QuotationCreate, generate_pdf: bool = False) ->
|
|||||||
item_id = str(uuid.uuid4())
|
item_id = str(uuid.uuid4())
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""INSERT INTO crm_quotation_items
|
"""INSERT INTO crm_quotation_items
|
||||||
(id, quotation_id, product_id, description, unit_type, unit_cost,
|
(id, quotation_id, product_id, description, description_en, description_gr,
|
||||||
discount_percent, quantity, vat_percent, line_total, sort_order)
|
unit_type, unit_cost, discount_percent, quantity, vat_percent, line_total, sort_order)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(
|
(
|
||||||
item_id, qid, item.get("product_id"), item.get("description"),
|
item_id, qid, item.get("product_id"), item.get("description"),
|
||||||
|
item.get("description_en"), item.get("description_gr"),
|
||||||
item.get("unit_type", "pcs"), item.get("unit_cost", 0),
|
item.get("unit_type", "pcs"), item.get("unit_cost", 0),
|
||||||
item.get("discount_percent", 0), item.get("quantity", 1),
|
item.get("discount_percent", 0), item.get("quantity", 1),
|
||||||
item.get("vat_percent", 24), item["line_total"], item.get("sort_order", i),
|
item.get("vat_percent", 24), item["line_total"], item.get("sort_order", i),
|
||||||
@@ -255,7 +291,7 @@ async def create_quotation(data: QuotationCreate, generate_pdf: bool = False) ->
|
|||||||
|
|
||||||
quotation = await get_quotation(qid)
|
quotation = await get_quotation(qid)
|
||||||
|
|
||||||
if generate_pdf:
|
if generate_pdf and not data.is_legacy:
|
||||||
quotation = await _do_generate_and_upload_pdf(quotation)
|
quotation = await _do_generate_and_upload_pdf(quotation)
|
||||||
|
|
||||||
return quotation
|
return quotation
|
||||||
@@ -285,6 +321,7 @@ async def update_quotation(quotation_id: str, data: QuotationUpdate, generate_pd
|
|||||||
"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",
|
||||||
|
"legacy_date", "legacy_pdf_path",
|
||||||
]
|
]
|
||||||
|
|
||||||
for field in scalar_fields:
|
for field in scalar_fields:
|
||||||
@@ -343,11 +380,12 @@ async def update_quotation(quotation_id: str, data: QuotationUpdate, generate_pd
|
|||||||
item_id = str(uuid.uuid4())
|
item_id = str(uuid.uuid4())
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""INSERT INTO crm_quotation_items
|
"""INSERT INTO crm_quotation_items
|
||||||
(id, quotation_id, product_id, description, unit_type, unit_cost,
|
(id, quotation_id, product_id, description, description_en, description_gr,
|
||||||
discount_percent, quantity, vat_percent, line_total, sort_order)
|
unit_type, unit_cost, discount_percent, quantity, vat_percent, line_total, sort_order)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(
|
(
|
||||||
item_id, quotation_id, item.get("product_id"), item.get("description"),
|
item_id, quotation_id, item.get("product_id"), item.get("description"),
|
||||||
|
item.get("description_en"), item.get("description_gr"),
|
||||||
item.get("unit_type", "pcs"), item.get("unit_cost", 0),
|
item.get("unit_type", "pcs"), item.get("unit_cost", 0),
|
||||||
item.get("discount_percent", 0), item.get("quantity", 1),
|
item.get("discount_percent", 0), item.get("quantity", 1),
|
||||||
item.get("vat_percent", 24), item["line_total"], item.get("sort_order", i),
|
item.get("vat_percent", 24), item["line_total"], item.get("sort_order", i),
|
||||||
@@ -488,7 +526,33 @@ async def get_quotation_pdf_bytes(quotation_id: str) -> bytes:
|
|||||||
"""Download the PDF for a quotation from Nextcloud and return raw bytes."""
|
"""Download the PDF for a quotation from Nextcloud and return raw bytes."""
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
quotation = await get_quotation(quotation_id)
|
quotation = await get_quotation(quotation_id)
|
||||||
if not quotation.nextcloud_pdf_path:
|
# For legacy quotations, the PDF is at legacy_pdf_path
|
||||||
raise HTTPException(status_code=404, detail="No PDF generated for this quotation")
|
path = quotation.legacy_pdf_path if quotation.is_legacy else quotation.nextcloud_pdf_path
|
||||||
pdf_bytes, _ = await nextcloud.download_file(quotation.nextcloud_pdf_path)
|
if not path:
|
||||||
|
raise HTTPException(status_code=404, detail="No PDF available for this quotation")
|
||||||
|
pdf_bytes, _ = await nextcloud.download_file(path)
|
||||||
return pdf_bytes
|
return pdf_bytes
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_legacy_pdf(quotation_id: str, pdf_bytes: bytes, filename: str) -> QuotationInDB:
|
||||||
|
"""Upload a legacy PDF to Nextcloud and store its path in the quotation record."""
|
||||||
|
quotation = await get_quotation(quotation_id)
|
||||||
|
if not quotation.is_legacy:
|
||||||
|
raise HTTPException(status_code=400, detail="This quotation is not a legacy quotation")
|
||||||
|
|
||||||
|
from crm.service import get_customer, get_customer_nc_path
|
||||||
|
customer = get_customer(quotation.customer_id)
|
||||||
|
nc_folder = get_customer_nc_path(customer)
|
||||||
|
|
||||||
|
await nextcloud.ensure_folder(f"customers/{nc_folder}/quotations")
|
||||||
|
rel_path = f"customers/{nc_folder}/quotations/{filename}"
|
||||||
|
await nextcloud.upload_file(rel_path, pdf_bytes, "application/pdf")
|
||||||
|
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE crm_quotations SET legacy_pdf_path = ?, updated_at = ? WHERE id = ?",
|
||||||
|
(rel_path, now, quotation_id),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return await get_quotation(quotation_id)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -6,7 +7,7 @@ from fastapi import HTTPException
|
|||||||
from shared.firebase import get_db
|
from shared.firebase import get_db
|
||||||
from shared.exceptions import NotFoundError
|
from shared.exceptions import NotFoundError
|
||||||
import re as _re
|
import re as _re
|
||||||
from mqtt import database as mqtt_db
|
import database as mqtt_db
|
||||||
from crm.models import (
|
from crm.models import (
|
||||||
ProductCreate, ProductUpdate, ProductInDB,
|
ProductCreate, ProductUpdate, ProductInDB,
|
||||||
CustomerCreate, CustomerUpdate, CustomerInDB,
|
CustomerCreate, CustomerUpdate, CustomerInDB,
|
||||||
@@ -20,6 +21,11 @@ COLLECTION = "crm_products"
|
|||||||
|
|
||||||
def _doc_to_product(doc) -> ProductInDB:
|
def _doc_to_product(doc) -> ProductInDB:
|
||||||
data = doc.to_dict()
|
data = doc.to_dict()
|
||||||
|
# Backfill bilingual fields for existing products that predate the feature
|
||||||
|
if not data.get("name_en") and data.get("name"):
|
||||||
|
data["name_en"] = data["name"]
|
||||||
|
if not data.get("name_gr") and data.get("name"):
|
||||||
|
data["name_gr"] = data["name"]
|
||||||
return ProductInDB(id=doc.id, **data)
|
return ProductInDB(id=doc.id, **data)
|
||||||
|
|
||||||
|
|
||||||
@@ -128,6 +134,7 @@ def _doc_to_customer(doc) -> CustomerInDB:
|
|||||||
def list_customers(
|
def list_customers(
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
tag: str | None = None,
|
tag: str | None = None,
|
||||||
|
sort: str | None = None,
|
||||||
) -> list[CustomerInDB]:
|
) -> list[CustomerInDB]:
|
||||||
db = get_db()
|
db = get_db()
|
||||||
query = db.collection(CUSTOMERS_COLLECTION)
|
query = db.collection(CUSTOMERS_COLLECTION)
|
||||||
@@ -141,28 +148,64 @@ def list_customers(
|
|||||||
|
|
||||||
if search:
|
if search:
|
||||||
s = search.lower()
|
s = search.lower()
|
||||||
|
s_nospace = s.replace(" ", "")
|
||||||
name_match = s in (customer.name or "").lower()
|
name_match = s in (customer.name or "").lower()
|
||||||
surname_match = s in (customer.surname or "").lower()
|
surname_match = s in (customer.surname or "").lower()
|
||||||
org_match = s in (customer.organization or "").lower()
|
org_match = s in (customer.organization or "").lower()
|
||||||
|
religion_match = s in (customer.religion or "").lower()
|
||||||
|
language_match = s in (customer.language or "").lower()
|
||||||
contact_match = any(
|
contact_match = any(
|
||||||
s in (c.value or "").lower()
|
s_nospace in (c.value or "").lower().replace(" ", "")
|
||||||
|
or s in (c.value or "").lower()
|
||||||
for c in (customer.contacts or [])
|
for c in (customer.contacts or [])
|
||||||
)
|
)
|
||||||
loc = customer.location or {}
|
loc = customer.location
|
||||||
loc_match = (
|
loc_match = bool(loc) and (
|
||||||
s in (loc.get("city", "") or "").lower() or
|
s in (loc.address or "").lower() or
|
||||||
s in (loc.get("country", "") or "").lower() or
|
s in (loc.city or "").lower() or
|
||||||
s in (loc.get("region", "") or "").lower()
|
s in (loc.postal_code or "").lower() or
|
||||||
|
s in (loc.region or "").lower() or
|
||||||
|
s in (loc.country or "").lower()
|
||||||
)
|
)
|
||||||
tag_match = any(s in (t or "").lower() for t in (customer.tags or []))
|
tag_match = any(s in (t or "").lower() for t in (customer.tags or []))
|
||||||
if not (name_match or surname_match or org_match or contact_match or loc_match or tag_match):
|
if not (name_match or surname_match or org_match or religion_match or language_match or contact_match or loc_match or tag_match):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
results.append(customer)
|
results.append(customer)
|
||||||
|
|
||||||
|
# Sorting (non-latest_comm; latest_comm is handled by the async router wrapper)
|
||||||
|
_TITLES = {"fr.", "rev.", "archim.", "bp.", "abp.", "met.", "mr.", "mrs.", "ms.", "dr.", "prof."}
|
||||||
|
|
||||||
|
def _sort_name(c):
|
||||||
|
return (c.name or "").lower()
|
||||||
|
|
||||||
|
def _sort_surname(c):
|
||||||
|
return (c.surname or "").lower()
|
||||||
|
|
||||||
|
def _sort_default(c):
|
||||||
|
return c.created_at or ""
|
||||||
|
|
||||||
|
if sort == "name":
|
||||||
|
results.sort(key=_sort_name)
|
||||||
|
elif sort == "surname":
|
||||||
|
results.sort(key=_sort_surname)
|
||||||
|
elif sort == "default":
|
||||||
|
results.sort(key=_sort_default)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def list_all_tags() -> list[str]:
|
||||||
|
db = get_db()
|
||||||
|
tags: set[str] = set()
|
||||||
|
for doc in db.collection(CUSTOMERS_COLLECTION).select(["tags"]).stream():
|
||||||
|
data = doc.to_dict()
|
||||||
|
for tag in (data.get("tags") or []):
|
||||||
|
if tag:
|
||||||
|
tags.add(tag)
|
||||||
|
return sorted(tags)
|
||||||
|
|
||||||
|
|
||||||
def get_customer(customer_id: str) -> CustomerInDB:
|
def get_customer(customer_id: str) -> CustomerInDB:
|
||||||
db = get_db()
|
db = get_db()
|
||||||
doc = db.collection(CUSTOMERS_COLLECTION).document(customer_id).get()
|
doc = db.collection(CUSTOMERS_COLLECTION).document(customer_id).get()
|
||||||
@@ -206,6 +249,7 @@ def create_customer(data: CustomerCreate) -> CustomerInDB:
|
|||||||
|
|
||||||
|
|
||||||
def update_customer(customer_id: str, data: CustomerUpdate) -> CustomerInDB:
|
def update_customer(customer_id: str, data: CustomerUpdate) -> CustomerInDB:
|
||||||
|
from google.cloud.firestore_v1 import DELETE_FIELD
|
||||||
db = get_db()
|
db = get_db()
|
||||||
doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
||||||
doc = doc_ref.get()
|
doc = doc_ref.get()
|
||||||
@@ -215,18 +259,110 @@ def update_customer(customer_id: str, data: CustomerUpdate) -> CustomerInDB:
|
|||||||
update_data = data.model_dump(exclude_none=True)
|
update_data = data.model_dump(exclude_none=True)
|
||||||
update_data["updated_at"] = datetime.utcnow().isoformat()
|
update_data["updated_at"] = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
# Fields that should be explicitly deleted from Firestore when set to None
|
||||||
|
# (exclude_none=True would just skip them, leaving the old value intact)
|
||||||
|
NULLABLE_FIELDS = {"title", "surname", "organization", "religion"}
|
||||||
|
set_fields = data.model_fields_set
|
||||||
|
for field in NULLABLE_FIELDS:
|
||||||
|
if field in set_fields and getattr(data, field) is None:
|
||||||
|
update_data[field] = DELETE_FIELD
|
||||||
|
|
||||||
doc_ref.update(update_data)
|
doc_ref.update(update_data)
|
||||||
updated_doc = doc_ref.get()
|
updated_doc = doc_ref.get()
|
||||||
return _doc_to_customer(updated_doc)
|
return _doc_to_customer(updated_doc)
|
||||||
|
|
||||||
|
|
||||||
def delete_customer(customer_id: str) -> None:
|
async def toggle_negotiating(customer_id: str) -> CustomerInDB:
|
||||||
|
db_fs = get_db()
|
||||||
|
doc_ref = db_fs.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Customer")
|
||||||
|
current = doc.to_dict().get("negotiating", False)
|
||||||
|
update_data = {"negotiating": not current, "updated_at": datetime.utcnow().isoformat()}
|
||||||
|
doc_ref.update(update_data)
|
||||||
|
return _doc_to_customer(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
|
async def toggle_problem(customer_id: str) -> CustomerInDB:
|
||||||
|
db_fs = get_db()
|
||||||
|
doc_ref = db_fs.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Customer")
|
||||||
|
current = doc.to_dict().get("has_problem", False)
|
||||||
|
update_data = {"has_problem": not current, "updated_at": datetime.utcnow().isoformat()}
|
||||||
|
doc_ref.update(update_data)
|
||||||
|
return _doc_to_customer(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_last_comm_direction(customer_id: str) -> str | None:
|
||||||
|
"""Return 'inbound' or 'outbound' of the most recent comm for this customer, or None."""
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
rows = await db.execute_fetchall(
|
||||||
|
"SELECT direction FROM crm_comms_log WHERE customer_id = ? "
|
||||||
|
"AND direction IN ('inbound', 'outbound') "
|
||||||
|
"ORDER BY COALESCE(occurred_at, created_at) DESC, created_at DESC LIMIT 1",
|
||||||
|
(customer_id,),
|
||||||
|
)
|
||||||
|
if rows:
|
||||||
|
return rows[0][0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_last_comm_timestamp(customer_id: str) -> str | None:
|
||||||
|
"""Return the ISO timestamp of the most recent comm for this customer, or None."""
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
rows = await db.execute_fetchall(
|
||||||
|
"SELECT COALESCE(occurred_at, created_at) as ts FROM crm_comms_log "
|
||||||
|
"WHERE customer_id = ? ORDER BY ts DESC LIMIT 1",
|
||||||
|
(customer_id,),
|
||||||
|
)
|
||||||
|
if rows:
|
||||||
|
return rows[0][0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
timestamps = await asyncio.gather(
|
||||||
|
*[get_last_comm_timestamp(c.id) for c in customers]
|
||||||
|
)
|
||||||
|
paired = list(zip(customers, timestamps))
|
||||||
|
paired.sort(key=lambda x: x[1] or "", reverse=True)
|
||||||
|
return [c for c, _ in paired]
|
||||||
|
|
||||||
|
|
||||||
|
def delete_customer(customer_id: str) -> CustomerInDB:
|
||||||
|
"""Delete customer from Firestore. Returns the customer data (for NC path lookup)."""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
||||||
doc = doc_ref.get()
|
doc = doc_ref.get()
|
||||||
if not doc.exists:
|
if not doc.exists:
|
||||||
raise NotFoundError("Customer")
|
raise NotFoundError("Customer")
|
||||||
|
customer = _doc_to_customer(doc)
|
||||||
doc_ref.delete()
|
doc_ref.delete()
|
||||||
|
return customer
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_customer_comms(customer_id: str) -> int:
|
||||||
|
"""Delete all comm log entries for a customer. Returns count deleted."""
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
cursor = await db.execute(
|
||||||
|
"DELETE FROM crm_comms_log WHERE customer_id = ?", (customer_id,)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return cursor.rowcount
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_customer_media_entries(customer_id: str) -> int:
|
||||||
|
"""Delete all media DB entries for a customer. Returns count deleted."""
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
cursor = await db.execute(
|
||||||
|
"DELETE FROM crm_media WHERE customer_id = ?", (customer_id,)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return cursor.rowcount
|
||||||
|
|
||||||
|
|
||||||
# ── Orders ───────────────────────────────────────────────────────────────────
|
# ── Orders ───────────────────────────────────────────────────────────────────
|
||||||
@@ -594,11 +730,11 @@ async def create_media(data: MediaCreate) -> MediaInDB:
|
|||||||
await db.execute(
|
await db.execute(
|
||||||
"""INSERT INTO crm_media
|
"""INSERT INTO crm_media
|
||||||
(id, customer_id, order_id, filename, nextcloud_path, mime_type,
|
(id, customer_id, order_id, filename, nextcloud_path, mime_type,
|
||||||
direction, tags, uploaded_by, created_at)
|
direction, tags, uploaded_by, thumbnail_path, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(media_id, data.customer_id, data.order_id, data.filename,
|
(media_id, data.customer_id, data.order_id, data.filename,
|
||||||
data.nextcloud_path, data.mime_type, direction,
|
data.nextcloud_path, data.mime_type, direction,
|
||||||
tags_json, data.uploaded_by, now),
|
tags_json, data.uploaded_by, data.thumbnail_path, now),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|||||||
125
backend/crm/thumbnails.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""
|
||||||
|
Thumbnail generation for uploaded media files.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- Images (via Pillow): JPEG thumbnail at 300×300 max
|
||||||
|
- Videos (via ffmpeg subprocess): extract first frame as JPEG
|
||||||
|
- PDFs (via pdf2image + Poppler): render first page as JPEG
|
||||||
|
|
||||||
|
Returns None if the type is unsupported or if generation fails.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
THUMB_SIZE = (220, 220) # small enough for gallery tiles; keeps files ~4-6 KB
|
||||||
|
|
||||||
|
|
||||||
|
def _thumb_from_image(content: bytes) -> bytes | None:
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
img = Image.open(io.BytesIO(content))
|
||||||
|
img = ImageOps.exif_transpose(img) # honour EXIF Orientation tag before resizing
|
||||||
|
img = img.convert("RGB")
|
||||||
|
img.thumbnail(THUMB_SIZE, Image.LANCZOS)
|
||||||
|
out = io.BytesIO()
|
||||||
|
# quality=55 + optimize=True + progressive encoding → ~4-6 KB for typical photos
|
||||||
|
img.save(out, format="JPEG", quality=65, optimize=True, progressive=True)
|
||||||
|
return out.getvalue()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Image thumbnail failed: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _thumb_from_video(content: bytes) -> bytes | None:
|
||||||
|
"""
|
||||||
|
Extract the first frame of a video as a JPEG thumbnail.
|
||||||
|
|
||||||
|
We write the video to a temp file instead of piping it to ffmpeg because
|
||||||
|
most video containers (MP4, MOV, MKV …) store their index (moov atom) at
|
||||||
|
an arbitrary offset and ffmpeg cannot seek on a pipe — causing rc≠0 with
|
||||||
|
"moov atom not found" or similar errors when stdin is used.
|
||||||
|
"""
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
try:
|
||||||
|
# Write to a temp file so ffmpeg can seek freely
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".video", delete=False) as tmp_in:
|
||||||
|
tmp_in.write(content)
|
||||||
|
tmp_in_path = tmp_in.name
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp_out:
|
||||||
|
tmp_out_path = tmp_out.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-i", tmp_in_path,
|
||||||
|
"-vframes", "1",
|
||||||
|
"-vf", f"scale={THUMB_SIZE[0]}:-2",
|
||||||
|
"-q:v", "4", # JPEG quality 1-31 (lower = better); 4 ≈ ~80% quality
|
||||||
|
tmp_out_path,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and os.path.getsize(tmp_out_path) > 0:
|
||||||
|
with open(tmp_out_path, "rb") as f:
|
||||||
|
return f.read()
|
||||||
|
logger.warning(
|
||||||
|
"ffmpeg video thumb failed (rc=%s): %s",
|
||||||
|
result.returncode,
|
||||||
|
result.stderr[-400:].decode(errors="replace") if result.stderr else "",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
os.unlink(tmp_in_path)
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_out_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.warning("ffmpeg not found — video thumbnails unavailable")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Video thumbnail failed: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _thumb_from_pdf(content: bytes) -> bytes | None:
|
||||||
|
try:
|
||||||
|
from pdf2image import convert_from_bytes
|
||||||
|
pages = convert_from_bytes(content, first_page=1, last_page=1, size=THUMB_SIZE)
|
||||||
|
if not pages:
|
||||||
|
return None
|
||||||
|
out = io.BytesIO()
|
||||||
|
pages[0].save(out, format="JPEG", quality=55, optimize=True, progressive=True)
|
||||||
|
return out.getvalue()
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("pdf2image not installed — PDF thumbnails unavailable")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("PDF thumbnail failed: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def generate_thumbnail(content: bytes, mime_type: str, filename: str) -> bytes | None:
|
||||||
|
"""
|
||||||
|
Generate a small JPEG thumbnail for the given file content.
|
||||||
|
Returns JPEG bytes or None if unsupported / generation fails.
|
||||||
|
"""
|
||||||
|
mt = (mime_type or "").lower()
|
||||||
|
fn = (filename or "").lower()
|
||||||
|
|
||||||
|
if mt.startswith("image/"):
|
||||||
|
return _thumb_from_image(content)
|
||||||
|
if mt.startswith("video/"):
|
||||||
|
return _thumb_from_video(content)
|
||||||
|
if mt == "application/pdf" or fn.endswith(".pdf"):
|
||||||
|
return _thumb_from_pdf(content)
|
||||||
|
|
||||||
|
return None
|
||||||
39
backend/database/__init__.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from database.core import (
|
||||||
|
init_db,
|
||||||
|
close_db,
|
||||||
|
get_db,
|
||||||
|
purge_loop,
|
||||||
|
purge_old_data,
|
||||||
|
insert_log,
|
||||||
|
insert_heartbeat,
|
||||||
|
insert_command,
|
||||||
|
update_command_response,
|
||||||
|
get_logs,
|
||||||
|
get_heartbeats,
|
||||||
|
get_commands,
|
||||||
|
get_latest_heartbeats,
|
||||||
|
get_pending_command,
|
||||||
|
upsert_alert,
|
||||||
|
delete_alert,
|
||||||
|
get_alerts,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"init_db",
|
||||||
|
"close_db",
|
||||||
|
"get_db",
|
||||||
|
"purge_loop",
|
||||||
|
"purge_old_data",
|
||||||
|
"insert_log",
|
||||||
|
"insert_heartbeat",
|
||||||
|
"insert_command",
|
||||||
|
"update_command_response",
|
||||||
|
"get_logs",
|
||||||
|
"get_heartbeats",
|
||||||
|
"get_commands",
|
||||||
|
"get_latest_heartbeats",
|
||||||
|
"get_pending_command",
|
||||||
|
"upsert_alert",
|
||||||
|
"delete_alert",
|
||||||
|
"get_alerts",
|
||||||
|
]
|
||||||
@@ -2,10 +2,11 @@ import aiosqlite
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from config import settings
|
from config import settings
|
||||||
|
|
||||||
logger = logging.getLogger("mqtt.database")
|
logger = logging.getLogger("database")
|
||||||
|
|
||||||
_db: aiosqlite.Connection | None = None
|
_db: aiosqlite.Connection | None = None
|
||||||
|
|
||||||
@@ -162,6 +163,8 @@ SCHEMA_STATEMENTS = [
|
|||||||
quotation_id TEXT NOT NULL,
|
quotation_id TEXT NOT NULL,
|
||||||
product_id TEXT,
|
product_id TEXT,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
|
description_en TEXT,
|
||||||
|
description_gr TEXT,
|
||||||
unit_type TEXT NOT NULL DEFAULT 'pcs',
|
unit_type TEXT NOT NULL DEFAULT 'pcs',
|
||||||
unit_cost REAL NOT NULL DEFAULT 0,
|
unit_cost REAL NOT NULL DEFAULT 0,
|
||||||
discount_percent REAL NOT NULL DEFAULT 0,
|
discount_percent REAL NOT NULL DEFAULT 0,
|
||||||
@@ -177,6 +180,7 @@ SCHEMA_STATEMENTS = [
|
|||||||
|
|
||||||
async def init_db():
|
async def init_db():
|
||||||
global _db
|
global _db
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(settings.sqlite_db_path)), exist_ok=True)
|
||||||
_db = await aiosqlite.connect(settings.sqlite_db_path)
|
_db = await aiosqlite.connect(settings.sqlite_db_path)
|
||||||
_db.row_factory = aiosqlite.Row
|
_db.row_factory = aiosqlite.Row
|
||||||
for stmt in SCHEMA_STATEMENTS:
|
for stmt in SCHEMA_STATEMENTS:
|
||||||
@@ -197,6 +201,12 @@ async def init_db():
|
|||||||
"ALTER TABLE crm_quotations ADD COLUMN client_location TEXT",
|
"ALTER TABLE crm_quotations ADD COLUMN client_location TEXT",
|
||||||
"ALTER TABLE crm_quotations ADD COLUMN client_phone TEXT",
|
"ALTER TABLE crm_quotations ADD COLUMN client_phone TEXT",
|
||||||
"ALTER TABLE crm_quotations ADD COLUMN client_email TEXT",
|
"ALTER TABLE crm_quotations ADD COLUMN client_email TEXT",
|
||||||
|
"ALTER TABLE crm_quotations ADD COLUMN is_legacy INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE crm_quotations ADD COLUMN legacy_date TEXT",
|
||||||
|
"ALTER TABLE crm_quotations ADD COLUMN legacy_pdf_path TEXT",
|
||||||
|
"ALTER TABLE crm_media ADD COLUMN thumbnail_path TEXT",
|
||||||
|
"ALTER TABLE crm_quotation_items ADD COLUMN description_en TEXT",
|
||||||
|
"ALTER TABLE crm_quotation_items ADD COLUMN description_gr TEXT",
|
||||||
]
|
]
|
||||||
for m in _migrations:
|
for m in _migrations:
|
||||||
try:
|
try:
|
||||||
@@ -7,7 +7,7 @@ from devices.models import (
|
|||||||
DeviceUsersResponse, DeviceUserInfo,
|
DeviceUsersResponse, DeviceUserInfo,
|
||||||
)
|
)
|
||||||
from devices import service
|
from devices import service
|
||||||
from mqtt import database as mqtt_db
|
import database as mqtt_db
|
||||||
from mqtt.models import DeviceAlertEntry, DeviceAlertsResponse
|
from mqtt.models import DeviceAlertEntry, DeviceAlertsResponse
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/devices", tags=["devices"])
|
router = APIRouter(prefix="/api/devices", tags=["devices"])
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from crm.quotations_router import router as crm_quotations_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
|
||||||
from mqtt import database as mqtt_db
|
import database as db
|
||||||
from melodies import service as melody_service
|
from melodies import service as melody_service
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -88,10 +88,10 @@ async def email_sync_loop():
|
|||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup():
|
async def startup():
|
||||||
init_firebase()
|
init_firebase()
|
||||||
await mqtt_db.init_db()
|
await db.init_db()
|
||||||
await melody_service.migrate_from_firestore()
|
await melody_service.migrate_from_firestore()
|
||||||
mqtt_manager.start(asyncio.get_event_loop())
|
mqtt_manager.start(asyncio.get_event_loop())
|
||||||
asyncio.create_task(mqtt_db.purge_loop())
|
asyncio.create_task(db.purge_loop())
|
||||||
asyncio.create_task(nextcloud_keepalive_loop())
|
asyncio.create_task(nextcloud_keepalive_loop())
|
||||||
sync_accounts = [a for a in get_mail_accounts() if a.get("sync_inbound") and a.get("imap_host")]
|
sync_accounts = [a for a in get_mail_accounts() if a.get("sync_inbound") and a.get("imap_host")]
|
||||||
if sync_accounts:
|
if sync_accounts:
|
||||||
@@ -104,7 +104,7 @@ async def startup():
|
|||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
async def shutdown():
|
async def shutdown():
|
||||||
mqtt_manager.stop()
|
mqtt_manager.stop()
|
||||||
await mqtt_db.close_db()
|
await db.close_db()
|
||||||
await close_nextcloud_client()
|
await close_nextcloud_client()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from mqtt.database import get_db
|
from database import get_db
|
||||||
|
|
||||||
logger = logging.getLogger("manufacturing.audit")
|
logger = logging.getLogger("manufacturing.audit")
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from mqtt.database import get_db
|
from database import get_db
|
||||||
|
|
||||||
logger = logging.getLogger("melodies.database")
|
logger = logging.getLogger("melodies.database")
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
from mqtt import database as db
|
import database as db
|
||||||
|
|
||||||
logger = logging.getLogger("mqtt.logger")
|
logger = logging.getLogger("mqtt.logger")
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from mqtt.models import (
|
|||||||
CommandListResponse, HeartbeatEntry,
|
CommandListResponse, HeartbeatEntry,
|
||||||
)
|
)
|
||||||
from mqtt.client import mqtt_manager
|
from mqtt.client import mqtt_manager
|
||||||
from mqtt import database as db
|
import database as db
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/mqtt", tags=["mqtt"])
|
router = APIRouter(prefix="/api/mqtt", tags=["mqtt"])
|
||||||
|
|||||||
@@ -13,3 +13,5 @@ resend==2.10.0
|
|||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
weasyprint>=62.0
|
weasyprint>=62.0
|
||||||
jinja2>=3.1.0
|
jinja2>=3.1.0
|
||||||
|
Pillow>=10.0.0
|
||||||
|
pdf2image>=1.17.0
|
||||||
@@ -464,7 +464,7 @@
|
|||||||
|
|
||||||
<div class="client-block">
|
<div class="client-block">
|
||||||
<div class="block-title">{{ L_CLIENT }}</div>
|
<div class="block-title">{{ L_CLIENT }}</div>
|
||||||
<table class="fields"><tbody>{% if customer.organization %}<tr><td class="lbl">{{ L_ORG }}</td><td class="val">{{ customer.organization }}</td></tr>{% endif %}{% set name_parts = [customer.title, customer.name, customer.surname] | select | list %}{% if name_parts %}<tr><td class="lbl">{{ L_CONTACT }}</td><td class="val">{{ name_parts | join(' ') }}</td></tr>{% endif %}{% if customer.location %}{% set loc_parts = [customer.location.city, customer.location.region, customer.location.country] | select | list %}{% if loc_parts %}<tr><td class="lbl">{{ L_ADDRESS }}</td><td class="val">{{ loc_parts | join(', ') }}</td></tr>{% endif %}{% endif %}{% if customer_email %}<tr><td class="lbl">Email</td><td class="val">{{ customer_email }}</td></tr>{% endif %}{% if customer_phone %}<tr><td class="lbl">{{ L_PHONE }}</td><td class="val">{{ customer_phone }}</td></tr>{% endif %}</tbody></table>
|
<table class="fields"><tbody>{% if customer.organization %}<tr><td class="lbl">{{ L_ORG }}</td><td class="val">{{ customer.organization }}</td></tr>{% endif %}{% set name_parts = [customer.title, customer.name, customer.surname] | select | list %}{% if name_parts %}<tr><td class="lbl">{{ L_CONTACT }}</td><td class="val">{{ name_parts | join(' ') }}</td></tr>{% endif %}{% if quotation.client_location %}<tr><td class="lbl">{{ L_ADDRESS }}</td><td class="val">{{ quotation.client_location }}</td></tr>{% elif customer.location %}{% set loc_parts = [customer.location.address, customer.location.city, customer.location.postal_code, customer.location.region, customer.location.country] | select | list %}{% if loc_parts %}<tr><td class="lbl">{{ L_ADDRESS }}</td><td class="val">{{ loc_parts | join(', ') }}</td></tr>{% endif %}{% endif %}{% if customer_email %}<tr><td class="lbl">Email</td><td class="val">{{ customer_email }}</td></tr>{% endif %}{% if customer_phone %}<tr><td class="lbl">{{ L_PHONE }}</td><td class="val">{{ customer_phone }}</td></tr>{% endif %}</tbody></table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="order-block">
|
<div class="order-block">
|
||||||
@@ -490,7 +490,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for item in quotation.items %}
|
{% for item in quotation.items %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ item.description or '' }}</td>
|
<td>{% if lang == 'gr' %}{{ item.description_gr or item.description or '' }}{% else %}{{ item.description_en or item.description or '' }}{% endif %}</td>
|
||||||
<td class="right">{{ item.unit_cost | format_money }}</td>
|
<td class="right">{{ item.unit_cost | format_money }}</td>
|
||||||
<td class="center">
|
<td class="center">
|
||||||
{% if item.discount_percent and item.discount_percent > 0 %}
|
{% if item.discount_percent and item.discount_percent > 0 %}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
# Persistent data - lives outside the container
|
# Persistent data - lives outside the container
|
||||||
- ./data/mqtt_data.db:/app/mqtt_data.db
|
- ./data/database.db:/app/data/database.db
|
||||||
- ./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/firebase-service-account.json:/app/firebase-service-account.json:ro
|
- ./data/firebase-service-account.json:/app/firebase-service-account.json:ro
|
||||||
|
|||||||
436
frontend/package-lock.json
generated
@@ -26,7 +26,8 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1",
|
||||||
|
"vite-plugin-svgr": "^4.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
@@ -1030,6 +1031,29 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@rollup/pluginutils": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "^1.0.0",
|
||||||
|
"estree-walker": "^2.0.2",
|
||||||
|
"picomatch": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"rollup": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.57.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
||||||
@@ -1380,6 +1404,231 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@svgr/babel-plugin-add-jsx-attribute": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgr/babel-plugin-remove-jsx-attribute": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgr/babel-plugin-svg-dynamic-title": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgr/babel-plugin-svg-em-dimensions": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgr/babel-plugin-transform-react-native-svg": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgr/babel-plugin-transform-svg-component": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgr/babel-preset": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@svgr/babel-plugin-add-jsx-attribute": "8.0.0",
|
||||||
|
"@svgr/babel-plugin-remove-jsx-attribute": "8.0.0",
|
||||||
|
"@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0",
|
||||||
|
"@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0",
|
||||||
|
"@svgr/babel-plugin-svg-dynamic-title": "8.0.0",
|
||||||
|
"@svgr/babel-plugin-svg-em-dimensions": "8.0.0",
|
||||||
|
"@svgr/babel-plugin-transform-react-native-svg": "8.1.0",
|
||||||
|
"@svgr/babel-plugin-transform-svg-component": "8.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgr/core": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/core": "^7.21.3",
|
||||||
|
"@svgr/babel-preset": "8.1.0",
|
||||||
|
"camelcase": "^6.2.0",
|
||||||
|
"cosmiconfig": "^8.1.3",
|
||||||
|
"snake-case": "^3.0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgr/hast-util-to-babel-ast": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/types": "^7.21.3",
|
||||||
|
"entities": "^4.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgr/plugin-jsx": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/core": "^7.21.3",
|
||||||
|
"@svgr/babel-preset": "8.1.0",
|
||||||
|
"@svgr/hast-util-to-babel-ast": "8.0.0",
|
||||||
|
"svg-parser": "^2.0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@svgr/core": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.1.18",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
|
||||||
@@ -1893,6 +2142,19 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelcase": {
|
||||||
|
"version": "6.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
|
||||||
|
"integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001770",
|
"version": "1.0.30001770",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz",
|
||||||
@@ -1978,6 +2240,33 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cosmiconfig": {
|
||||||
|
"version": "8.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
|
||||||
|
"integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"import-fresh": "^3.3.0",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
|
"parse-json": "^5.2.0",
|
||||||
|
"path-type": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/d-fischer"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.9.5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -2035,6 +2324,17 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dot-case": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"no-case": "^3.0.4",
|
||||||
|
"tslib": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.286",
|
"version": "1.5.286",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
|
||||||
@@ -2056,6 +2356,29 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/error-ex": {
|
||||||
|
"version": "1.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
||||||
|
"integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-arrayish": "^0.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||||
@@ -2306,6 +2629,13 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/estree-walker": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/esutils": {
|
"node_modules/esutils": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||||
@@ -2528,6 +2858,13 @@
|
|||||||
"node": ">=0.8.19"
|
"node": ">=0.8.19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-arrayish": {
|
||||||
|
"version": "0.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||||
|
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-extglob": {
|
"node_modules/is-extglob": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
@@ -2608,6 +2945,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-parse-even-better-errors": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||||
@@ -2926,6 +3270,13 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lines-and-columns": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
@@ -2949,6 +3300,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lower-case": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -3015,6 +3376,17 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/no-case": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lower-case": "^2.0.2",
|
||||||
|
"tslib": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.27",
|
"version": "2.0.27",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||||
@@ -3091,6 +3463,25 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse-json": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/code-frame": "^7.0.0",
|
||||||
|
"error-ex": "^1.3.1",
|
||||||
|
"json-parse-even-better-errors": "^2.3.0",
|
||||||
|
"lines-and-columns": "^1.1.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-exists": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -3111,6 +3502,16 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-type": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -3363,6 +3764,17 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/snake-case": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dot-case": "^3.0.4",
|
||||||
|
"tslib": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -3399,6 +3811,13 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/svg-parser": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.1.18",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||||
@@ -3572,6 +3991,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite-plugin-svgr": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-W+uoSpmVkSmNOGPSsDCWVW/DDAyv+9fap9AZXBvWiQqrboJ08j2vh0tFxTD/LjwqwAd3yYSVJgm54S/1GhbdnA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@rollup/pluginutils": "^5.2.0",
|
||||||
|
"@svgr/core": "^8.1.0",
|
||||||
|
"@svgr/plugin-jsx": "^8.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vite": ">=2.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1",
|
||||||
|
"vite-plugin-svgr": "^4.5.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
frontend/public/horizontal-logo-dark-console.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
frontend/public/horizontal-logo-dark.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/public/horizontal-logo-light.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
@@ -34,7 +34,7 @@ import ApiReferencePage from "./developer/ApiReferencePage";
|
|||||||
import { ProductList, ProductForm } from "./crm/products";
|
import { ProductList, ProductForm } from "./crm/products";
|
||||||
import { CustomerList, CustomerForm, CustomerDetail } from "./crm/customers";
|
import { CustomerList, CustomerForm, CustomerDetail } from "./crm/customers";
|
||||||
import { OrderList, OrderForm, OrderDetail } from "./crm/orders";
|
import { OrderList, OrderForm, OrderDetail } from "./crm/orders";
|
||||||
import { QuotationForm } from "./crm/quotations";
|
import { QuotationForm, AllQuotationsList } from "./crm/quotations";
|
||||||
import CommsPage from "./crm/inbox/CommsPage";
|
import CommsPage from "./crm/inbox/CommsPage";
|
||||||
import MailPage from "./crm/mail/MailPage";
|
import MailPage from "./crm/mail/MailPage";
|
||||||
|
|
||||||
@@ -174,6 +174,7 @@ export default function App() {
|
|||||||
<Route path="crm/orders/new" element={<PermissionGate section="crm" action="edit"><OrderForm /></PermissionGate>} />
|
<Route path="crm/orders/new" element={<PermissionGate section="crm" action="edit"><OrderForm /></PermissionGate>} />
|
||||||
<Route path="crm/orders/:id" element={<PermissionGate section="crm"><OrderDetail /></PermissionGate>} />
|
<Route path="crm/orders/:id" element={<PermissionGate section="crm"><OrderDetail /></PermissionGate>} />
|
||||||
<Route path="crm/orders/:id/edit" element={<PermissionGate section="crm" action="edit"><OrderForm /></PermissionGate>} />
|
<Route path="crm/orders/:id/edit" element={<PermissionGate section="crm" action="edit"><OrderForm /></PermissionGate>} />
|
||||||
|
<Route path="crm/quotations" element={<PermissionGate section="crm"><AllQuotationsList /></PermissionGate>} />
|
||||||
<Route path="crm/quotations/new" element={<PermissionGate section="crm" action="edit"><QuotationForm /></PermissionGate>} />
|
<Route path="crm/quotations/new" element={<PermissionGate section="crm" action="edit"><QuotationForm /></PermissionGate>} />
|
||||||
<Route path="crm/quotations/:id" element={<PermissionGate section="crm" action="edit"><QuotationForm /></PermissionGate>} />
|
<Route path="crm/quotations/:id" element={<PermissionGate section="crm" action="edit"><QuotationForm /></PermissionGate>} />
|
||||||
|
|
||||||
|
|||||||
2
frontend/src/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/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/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/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/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/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/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/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/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 |
17
frontend/src/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/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 |
2
frontend/src/assets/other-icons/negotiations.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 -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 |
6
frontend/src/assets/side-menu-icons/activity-log.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 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/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/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/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 |
5
frontend/src/assets/side-menu-icons/blackbox.svg
Normal file
@@ -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 |
3
frontend/src/assets/side-menu-icons/communications.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg viewBox="0 0 15 15" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||||
|
<path d="M11.8545,6.4336l-.4131-.2813a4.7623,4.7623,0,0,0,.2813-4.8779l-.0835-.1533L12.0747.875l.0908.167a5.2619,5.2619,0,0,1-.311,5.3916Zm1.1521,7.1316V14h-11v-.4348H4.4952L6.0439,6.4a.5.5,0,0,1,.4888-.3945h.7255V4.6014A1.14,1.14,0,0,1,6.3756,3.5a1.1568,1.1568,0,1,1,2.3136,0,1.14,1.14,0,0,1-.931,1.1112V6.0059h.7223A.5.5,0,0,1,8.9692,6.4l1.5478,7.1648ZM8.4543,8.751H6.5588L6.236,10.2441H8.777ZM6.1279,10.7441l-.3233,1.4952H9.2082l-.3231-1.4952ZM6.936,7.0059,6.6669,8.251H8.3463L8.0771,7.0059ZM5.5179,13.5652H9.4948l-.1786-.8259h-3.62ZM5.21,5.0137a2.7523,2.7523,0,0,1,.0161-3.0518L4.812,1.6826a3.25,3.25,0,0,0-.019,3.6065ZM10.7568,3.5a3.2433,3.2433,0,0,0-.5341-1.7861l-.418.2754a2.7517,2.7517,0,0,1-.0176,3.0488l.4141.2793A3.2341,3.2341,0,0,0,10.7568,3.5ZM3.5342,6.1182A4.7637,4.7637,0,0,1,3.3813,1.13L2.9478.88a5.2643,5.2643,0,0,0,.1694,5.5137Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 940 B |
4
frontend/src/assets/side-menu-icons/composer.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="M2 5C2 3.89543 2.89543 3 4 3H7H9H13H15H19H20C21.1046 3 22 3.89543 22 5V13V19C22 20.1046 21.1046 21 20 21H15H9H4C2.89543 21 2 20.1046 2 19V5ZM20 14H19C18.4477 14 18 13.5523 18 13V5H16V13V19H20V14ZM14 19V14H13C12.4477 14 12 13.5523 12 13V5H10V13V19H14ZM8 19V14H7C6.44772 14 6 13.5523 6 13V5H4V19H8Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 596 B |
36
frontend/src/assets/side-menu-icons/crm.svg
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg fill="currentColor" height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
viewBox="0 0 512 512" xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path d="M133.12,149.049H5.689c-3.141,0-5.689,2.547-5.689,5.689V471.04c0,9.425,7.641,17.067,17.067,17.067H133.12
|
||||||
|
c3.141,0,5.689-2.547,5.689-5.689v-327.68C138.809,151.596,136.261,149.049,133.12,149.049z M89.889,418.702h-40.96
|
||||||
|
c-9.425,0-17.067-7.641-17.067-17.067s7.641-17.067,17.067-17.067h40.96c9.425,0,17.067,7.641,17.067,17.067
|
||||||
|
S99.315,418.702,89.889,418.702z M89.889,335.644h-40.96c-9.425,0-17.067-7.641-17.067-17.067c0-9.425,7.641-17.067,17.067-17.067
|
||||||
|
h40.96c9.425,0,17.067,7.641,17.067,17.067C106.956,328.003,99.315,335.644,89.889,335.644z M89.889,252.587h-40.96
|
||||||
|
c-9.425,0-17.067-7.641-17.067-17.067s7.641-17.067,17.067-17.067h40.96c9.425,0,17.067,7.641,17.067,17.067
|
||||||
|
S99.315,252.587,89.889,252.587z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path d="M494.933,23.893H17.067C7.641,23.893,0,31.535,0,40.96v68.267c0,3.141,2.547,5.689,5.689,5.689h500.622
|
||||||
|
c3.141,0,5.689-2.547,5.689-5.689V40.96C512,31.535,504.359,23.893,494.933,23.893z M64.853,80.782
|
||||||
|
c-6.284,0-11.378-5.094-11.378-11.378c0-6.284,5.094-11.378,11.378-11.378s11.378,5.094,11.378,11.378
|
||||||
|
C76.231,75.688,71.137,80.782,64.853,80.782z M110.364,80.782c-6.284,0-11.378-5.094-11.378-11.378
|
||||||
|
c0-6.284,5.094-11.378,11.378-11.378s11.378,5.094,11.378,11.378C121.742,75.688,116.648,80.782,110.364,80.782z M155.876,80.782
|
||||||
|
c-6.284,0-11.378-5.094-11.378-11.378c0-6.284,5.094-11.378,11.378-11.378s11.378,5.094,11.378,11.378
|
||||||
|
C167.253,75.688,162.159,80.782,155.876,80.782z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path d="M506.311,149.049h-327.68c-3.141,0-5.689,2.547-5.689,5.689v327.68c0,3.141,2.547,5.689,5.689,5.689h316.302
|
||||||
|
c9.425,0,17.067-7.641,17.067-17.067V154.738C512,151.596,509.453,149.049,506.311,149.049z M405.049,364.089V409.6
|
||||||
|
c0,9.425-7.641,17.067-17.067,17.067H296.96c-9.425,0-17.067-7.641-17.067-17.067v-45.511c0-17.92,7.583-34.092,19.695-45.511
|
||||||
|
c-41.186-38.831-13.463-108.089,42.883-108.089c56.395,0,84.023,69.303,42.883,108.089
|
||||||
|
C397.466,329.996,405.049,346.169,405.049,364.089z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill="none" stroke="currentColor" stroke-width="2" d="M18.5,21 C20.9852814,21 23,18.9852814 23,16.5 C23,14.0147186 20.9852814,12 18.5,12 C16.0147186,12 14,14.0147186 14,16.5 C14,18.9852814 16.0147186,21 18.5,21 L18.5,21 Z M10,7 L14,7 M1.5,14.5 C1.5,14.5 5.5,5 6,4 C6.5,3 7.5,3 8,3 C8.5,3 10,3 10,5 L10,16 M5.5,21 C3.01471863,21 1,18.9852814 1,16.5 C1,14.0147186 3.01471863,12 5.5,12 C7.98528137,12 10,14.0147186 10,16.5 C10,18.9852814 7.98528137,21 5.5,21 L5.5,21 L5.5,21 Z M22.5,14.5 C22.5,14.5 18.5,5 18,4 C17.5,3 16.5,3 16,3 C15.5,3 14,3 14,5 L14,16 M10,16 L14,16"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 658 B |
3
frontend/src/assets/side-menu-icons/customers.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<?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 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M670.5 471.7c-7.1-3.1-14.2-5.9-21.4-8.5 49.8-40.3 81.6-101.8 81.6-170.6 0-121-98.4-219.4-219.4-219.4s-219.4 98.4-219.4 219.4c0 68.9 31.9 130.5 81.7 170.7C219.4 519.6 109 667.8 109 841.3h73.1c0-181.5 147.7-329.1 329.1-329.1 45.3 0 89.1 9 130.2 26.7l29.1-67.2zM511.3 146.3c80.7 0 146.3 65.6 146.3 146.3S592 438.9 511.3 438.9 365 373.2 365 292.6s65.6-146.3 146.3-146.3zM612.5 636.5c0 10.2 5.6 19.5 14.6 24.2l128 67.6c4 2.1 8.4 3.2 12.8 3.2s8.8-1.1 12.8-3.2l128-67.6c9-4.8 14.6-14.1 14.6-24.2s-5.6-19.5-14.6-24.2l-128-67.7c-8-4.2-17.6-4.2-25.6 0l-128 67.7c-9 4.7-14.6 14-14.6 24.2z m155.4-36.6l69.3 36.6-69.3 36.6-69.3-36.6 69.3-36.6z" fill="currentColor" /><path d="M767.9 763.4l-147-77.7-25.6 48.5 172.6 91.2 171.9-90.8-25.6-48.5z" fill="currentColor" /><path d="M767.9 851.4l-147-77.6-25.6 48.4 172.6 91.3 171.3-90.6-25.6-48.5z" fill="currentColor" /></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
9
frontend/src/assets/side-menu-icons/dashboard.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg fill="currentColor" id="Layer_1" data-name="Layer 1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||||
|
<path class="cls-1" d="M2,2V14H14V2ZM13,13H3V3H13Z"/>
|
||||||
|
<rect class="cls-1" x="9" y="9" width="3" height="1"/>
|
||||||
|
<path class="cls-1" d="M12,4H4V8h8ZM11,7H5V5h6Z"/>
|
||||||
|
<path class="cls-1" d="M8,9H4v3H8ZM7,11H5V10H7Z"/>
|
||||||
|
<rect class="cls-1" x="9" y="11" width="3" height="1"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 530 B |
22
frontend/src/assets/side-menu-icons/device-inventory.svg
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?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="currentColor" 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 287.639 287.639" xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<path d="M102.543,43.003C112.344,35.226,128.788,30,143.462,30c14.81,0,31.376,5.321,41.225,13.241
|
||||||
|
c2.771,2.229,6.092,3.312,9.391,3.312c4.387,0,8.736-1.916,11.699-5.601c5.191-6.456,4.166-15.898-2.289-21.09
|
||||||
|
C188.252,7.611,165.252,0,143.462,0c-21.583,0-44.407,7.473-59.567,19.503c-6.489,5.149-7.575,14.585-2.426,21.074
|
||||||
|
C86.617,47.065,96.053,48.152,102.543,43.003z"/>
|
||||||
|
<path d="M143.535,56.573c-11.488,0-29.271,3.466-41.581,13.232c-6.49,5.149-7.576,14.585-2.428,21.074
|
||||||
|
c5.15,6.49,14.584,7.576,21.074,2.428c4.759-3.776,14.833-6.734,22.935-6.734c8.169,0,18.311,3.012,23.09,6.856
|
||||||
|
c2.771,2.229,6.093,3.313,9.393,3.313c4.386,0,8.734-1.915,11.697-5.599c5.192-6.455,4.17-15.896-2.285-21.09
|
||||||
|
C175.25,61.865,158.806,56.573,143.535,56.573z"/>
|
||||||
|
<path d="M269.319,121.639c0-5.523-4.477-10-10-10h-231c-5.523,0-10,4.477-10,10v52c0,5.523,4.477,10,10,10h231
|
||||||
|
c5.523,0,10-4.477,10-10V121.639z M225.434,164.877c-9.081,0-16.442-7.362-16.442-16.442c0-9.081,7.362-16.443,16.442-16.443
|
||||||
|
s16.443,7.362,16.443,16.443C241.877,157.515,234.515,164.877,225.434,164.877z"/>
|
||||||
|
<path d="M269.319,225.639c0-5.523-4.477-10-10-10h-231c-5.523,0-10,4.477-10,10v52c0,5.523,4.477,10,10,10h231
|
||||||
|
c5.523,0,10-4.477,10-10V225.639z M225.434,269.22c-9.081,0-16.442-7.362-16.442-16.443c0-9.081,7.362-16.442,16.442-16.442
|
||||||
|
s16.443,7.362,16.443,16.442C241.877,261.859,234.515,269.22,225.434,269.22z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
3
frontend/src/assets/side-menu-icons/device-overview.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||||
|
<path d="M2 1H20V2H20.94V20H20V21H2V20H1.06V2H2V1M3 3V19H19V3H3M4 4H18V12H4V4M5 14H8V17H5V14M12 15H14V17H12V15M15 14H17V16H15V14Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 223 B |
4
frontend/src/assets/side-menu-icons/devices.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="M21 8H17.8174M21 12H18M21 16H17.8174M6.18257 8H3M8 6.18257V3M8 21L8 17.8174M12 6V3M12 21V18M16 6.18257V3M16 21V17.8174M6 12H3M6.18257 16H3M10.8 18H13.2C14.8802 18 15.7202 18 16.362 17.673C16.9265 17.3854 17.3854 16.9265 17.673 16.362C18 15.7202 18 14.8802 18 13.2V10.8C18 9.11984 18 8.27976 17.673 7.63803C17.3854 7.07354 16.9265 6.6146 16.362 6.32698C15.7202 6 14.8802 6 13.2 6H10.8C9.11984 6 8.27976 6 7.63803 6.32698C7.07354 6.6146 6.6146 7.07354 6.32698 7.63803C6 8.27976 6 9.11984 6 10.8V13.2C6 14.8802 6 15.7202 6.32698 16.362C6.6146 16.9265 7.07354 17.3854 7.63803 17.673C8.27976 18 9.11984 18 10.8 18ZM10 10H14V14H10V10Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 954 B |
4
frontend/src/assets/side-menu-icons/firmware.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.46447ZM13.4881 6.44591C13.8882 6.55311 14.1256 6.96437 14.0184 7.36447L11.4302 17.0237C11.323 17.4238 10.9117 17.6613 10.5116 17.5541C10.1115 17.4468 9.8741 17.0356 9.98131 16.6355L12.5695 6.97624C12.6767 6.57614 13.088 6.3387 13.4881 6.44591ZM14.9697 8.46967C15.2626 8.17678 15.7374 8.17678 16.0303 8.46967L16.2387 8.67801C16.874 9.3133 17.4038 9.84308 17.7678 10.3202C18.1521 10.8238 18.4216 11.3559 18.4216 12C18.4216 12.6441 18.1521 13.1762 17.7678 13.6798C17.4038 14.1569 16.874 14.6867 16.2387 15.322L16.0303 15.5303C15.7374 15.8232 15.2626 15.8232 14.9697 15.5303C14.6768 15.2374 14.6768 14.7626 14.9697 14.4697L15.1412 14.2981C15.8229 13.6164 16.2797 13.1574 16.5753 12.7699C16.8577 12.3998 16.9216 12.1843 16.9216 12C16.9216 11.8157 16.8577 11.6002 16.5753 11.2301C16.2797 10.8426 15.8229 10.3836 15.1412 9.70191L14.9697 9.53033C14.6768 9.23744 14.6768 8.76257 14.9697 8.46967ZM7.96986 8.46967C8.26275 8.17678 8.73762 8.17678 9.03052 8.46967C9.32341 8.76257 9.32341 9.23744 9.03052 9.53033L8.85894 9.70191C8.17729 10.3836 7.72052 10.8426 7.42488 11.2301C7.14245 11.6002 7.07861 11.8157 7.07861 12C7.07861 12.1843 7.14245 12.3998 7.42488 12.7699C7.72052 13.1574 8.17729 13.6164 8.85894 14.2981L9.03052 14.4697C9.32341 14.7626 9.32341 15.2374 9.03052 15.5303C8.73762 15.8232 8.26275 15.8232 7.96986 15.5303L7.76151 15.322C7.12617 14.6867 6.59638 14.1569 6.23235 13.6798C5.84811 13.1762 5.57861 12.6441 5.57861 12C5.57861 11.3559 5.84811 10.8238 6.23235 10.3202C6.59638 9.84308 7.12617 9.31331 7.76151 8.67801L7.96986 8.46967Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
3
frontend/src/assets/side-menu-icons/fleet.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M29.2695 22.999C30.3745 20.91 30.9995 18.527 30.9995 16C30.9995 7.716 24.2845 1 15.9995 1L16 16M13 1.3008C6.153 2.6898 1 8.7428 1 15.9998C1 24.2838 7.716 30.9998 16 30.9998C19.189 30.9998 22.11 30.0628 24.541 28.3658M8.5166 21.002C10.1316 23.412 12.8806 25 15.9996 25C20.9706 25 24.9996 20.971 24.9996 16C24.9996 11.029 20.9706 7 15.9996 7M13 7.5117C10.09 8.5397 7.887 10.9367 7.223 13.9997M9 16C9 14.896 8.104 14 7 14C5.896 14 5 14.896 5 16C5 17.104 5.896 18 7 18C8.104 18 9 17.104 9 16ZM28 27C28 25.896 27.104 25 26 25C24.896 25 24 25.896 24 27C24 28.104 24.896 29 26 29C27.104 29 28 28.104 28 27Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 778 B |
4
frontend/src/assets/side-menu-icons/helpdesk.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||||
|
<rect width="24" height="24" fill="none"/>
|
||||||
|
<path d="M12,2a8,8,0,0,0-8,8v1.9A2.92,2.92,0,0,0,3,14a2.88,2.88,0,0,0,1.94,2.61C6.24,19.72,8.85,22,12,22h3V20H12c-2.26,0-4.31-1.7-5.34-4.39l-.21-.55L5.86,15A1,1,0,0,1,5,14a1,1,0,0,1,.5-.86l.5-.29V11a1,1,0,0,1,1-1H17a1,1,0,0,1,1,1v5H13.91a1.5,1.5,0,1,0-1.52,2H20a2,2,0,0,0,2-2V14a2,2,0,0,0-2-2V10A8,8,0,0,0,12,2Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 450 B |
4
frontend/src/assets/side-menu-icons/issues.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" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 0a4 4 0 00-4 4v8a4 4 0 004 4h8a4 4 0 004-4V4a4 4 0 00-4-4H4zm0 4.75A.75.75 0 014.75 4h6.5a.75.75 0 010 1.5h-6.5A.75.75 0 014 4.75zm9.78 5.72a.75.75 0 010 1.06l-2.25 2.25a.75.75 0 01-1.06 0l-1.25-1.25a.75.75 0 111.06-1.06l.72.72 1.72-1.72a.75.75 0 011.06 0zM4 8a.75.75 0 01.75-.75h6.5a.75.75 0 010 1.5h-6.5A.75.75 0 014 8zm0 3.25a.75.75 0 01.75-.75h2.5a.75.75 0 010 1.5h-2.5a.75.75 0 01-.75-.75z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 687 B |
4
frontend/src/assets/side-menu-icons/mail.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="M4 18L9 12M20 18L15 12M3 8L10.225 12.8166C10.8665 13.2443 11.1872 13.4582 11.5339 13.5412C11.8403 13.6147 12.1597 13.6147 12.4661 13.5412C12.8128 13.4582 13.1335 13.2443 13.775 12.8166L21 8M6.2 19H17.8C18.9201 19 19.4802 19 19.908 18.782C20.2843 18.5903 20.5903 18.2843 20.782 17.908C21 17.4802 21 16.9201 21 15.8V8.2C21 7.0799 21 6.51984 20.782 6.09202C20.5903 5.71569 20.2843 5.40973 19.908 5.21799C19.4802 5 18.9201 5 17.8 5H6.2C5.0799 5 4.51984 5 4.09202 5.21799C3.71569 5.40973 3.40973 5.71569 3.21799 6.09202C3 6.51984 3 7.07989 3 8.2V15.8C3 16.9201 3 17.4802 3.21799 17.908C3.40973 18.2843 3.71569 18.5903 4.09202 18.782C4.51984 19 5.07989 19 6.2 19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 983 B |
2
frontend/src/assets/side-menu-icons/manufacturing.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="currentColor" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M22,1H18a1,1,0,0,0-1,1V9H15V7a1,1,0,0,0-1.447-.895L9,8.382V7a1,1,0,0,0-1.447-.895l-6,3A1,1,0,0,0,1,10V22a1,1,0,0,0,1,1H22a1,1,0,0,0,1-1V2A1,1,0,0,0,22,1ZM21,3V5H19V3ZM3,21V10.618l4-2V10a1,1,0,0,0,1.447.895L13,8.618V10a1,1,0,0,0,1,1h4a1,1,0,0,0,1-1V7h2V21Zm2-8H8v2H5Zm5,0h4v2H10Zm6,0h3v2H16ZM5,17H8v2H5Zm5,0h4v2H10Zm6,0h3v2H16Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 572 B |
2
frontend/src/assets/side-menu-icons/melodies-editor.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" fill="none" xmlns="http://www.w3.org/2000/svg" aria-labelledby="soundsIconTitle" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <title id="soundsIconTitle">Sounds</title> <path d="M12 7L12 17"/> <path d="M15 10L15 14"/> <path d="M18 8L18 16"/> <path d="M21 13L21 11"/> <path d="M9 4L9 20"/> <path d="M6 9L6 15"/> <path d="M3 13L3 11"/> </svg>
|
||||||
|
After Width: | Height: | Size: 555 B |
5
frontend/src/assets/side-menu-icons/melodies.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="M3 5C3 2.23858 5.23858 0 8 0C10.7614 0 13 2.23858 13 5V8L15 10V12H1V10L3 8V5Z" fill="currentColor"/>
|
||||||
|
<path d="M7.99999 16C6.69378 16 5.58254 15.1652 5.1707 14H10.8293C10.4175 15.1652 9.30621 16 7.99999 16Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 465 B |
12
frontend/src/assets/side-menu-icons/melody-settings.svg
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<title>ic_fluent_speaker_settings_24_filled</title>
|
||||||
|
<desc>Created with Sketch.</desc>
|
||||||
|
<g id="🔍-Product-Icons" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<g id="ic_fluent_speaker_settings_24_filled" fill="currentColor" fill-rule="nonzero">
|
||||||
|
<path d="M16.5,11 C16.7930736,11 17.0806075,11.024542 17.3607867,11.0717481 L17.5341163,11.7877987 C17.7930873,12.8615807 18.8734971,13.5221156 19.9472791,13.2631446 L20.0450624,13.2369258 L20.0450624,13.2369258 L20.6461665,13.0580331 C21.0087308,13.5250263 21.2991223,14.0545692 21.4998264,14.6285394 L21.0525916,15.0588054 C20.2566418,15.8246629 20.2322472,17.0907579 20.9981047,17.8867077 L21.0525916,17.9411946 L21.4998264,18.3714606 C21.2992653,18.9450217 21.0091447,19.4742188 20.6469416,19.9409683 L20.0450624,19.7630742 C18.9857606,19.4500989 17.8733103,20.0551161 17.5603351,21.1144179 L17.5341163,21.2122013 L17.5341163,21.2122013 L17.3607867,21.9282519 C17.0806075,21.975458 16.7930736,22 16.5,22 C16.2066096,22 15.918771,21.9754049 15.6383049,21.9280988 L15.4658837,21.2122013 C15.2069127,20.1384193 14.1265029,19.4778844 13.0527209,19.7368554 L12.9549376,19.7630742 L12.9549376,19.7630742 L12.3530584,19.9409683 C11.9908553,19.4742188 11.7007347,18.9450217 11.5001736,18.3714606 L11.9474084,17.9411946 C12.7433582,17.1753371 12.7677528,15.9092421 12.0018953,15.1132923 L11.9474084,15.0588054 L11.5001736,14.6285394 C11.7007347,14.0549783 11.9908553,13.5257812 12.3530584,13.0590317 L12.9549376,13.2369258 C14.0142394,13.5499011 15.1266897,12.9448839 15.4396649,11.8855821 L15.4658837,11.7877987 L15.4658837,11.7877987 L15.6383049,11.0719012 C15.918771,11.0245951 16.2066096,11 16.5,11 Z M16.5,15 C15.6993682,15 15.0503279,15.6715729 15.0503279,16.5 C15.0503279,17.3284271 15.6993682,18 16.5,18 C17.3006318,18 17.9496721,17.3284271 17.9496721,16.5 C17.9496721,15.6715729 17.3006318,15 16.5,15 Z M12.6270133,2.50171522 C12.8656325,2.73666759 13,3.05754013 13,3.39241584 L12.9997937,11.02195 C11.1957525,12.1770413 10,14.1988792 10,16.5 L10.002,16.6144156 L6.93583334,13.5 L4.25,13.5 C3.00735931,13.5 2,12.4926407 2,11.25 L2,8.75 C2,7.50735931 3.00735931,6.5 4.25,6.5 L6.93593264,6.5 L10.8592994,2.51540249 C11.3436605,2.02348212 12.135093,2.01735412 12.6270133,2.50171522 Z" id="🎨-Color">
|
||||||
|
|
||||||
|
</path>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
4
frontend/src/assets/side-menu-icons/mqtt-commands.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="M10 10V7C10 5.34315 8.65685 4 7 4C5.34315 4 4 5.34315 4 7C4 8.65685 5.34315 10 7 10H10ZM10 10V14M10 10H14M10 14V17C10 18.6569 8.65685 20 7 20C5.34315 20 4 18.6569 4 17C4 15.3431 5.34315 14 7 14H10ZM10 14H14M14 10H17C18.6569 10 20 8.65685 20 7C20 5.34315 18.6569 4 17 4C15.3431 4 14 5.34315 14 7V10ZM14 10V14M14 14H17C18.6569 14 20 15.3431 20 17C20 18.6569 18.6569 20 17 20C15.3431 20 14 18.6569 14 17V14Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 731 B |
7
frontend/src/assets/side-menu-icons/mqtt-logs.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?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" xmlns="http://www.w3.org/2000/svg" fill="none">
|
||||||
|
<g fill="currentColor">
|
||||||
|
<path d="M5.314 1.256a.75.75 0 01-.07 1.058L3.889 3.5l1.355 1.186a.75.75 0 11-.988 1.128l-2-1.75a.75.75 0 010-1.128l2-1.75a.75.75 0 011.058.07zM7.186 1.256a.75.75 0 00.07 1.058L8.611 3.5 7.256 4.686a.75.75 0 10.988 1.128l2-1.75a.75.75 0 000-1.128l-2-1.75a.75.75 0 00-1.058.07zM2.75 7.5a.75.75 0 000 1.5h10.5a.75.75 0 000-1.5H2.75zM2 11.25a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H2.75a.75.75 0 01-.75-.75zM2.75 13.5a.75.75 0 000 1.5h6.5a.75.75 0 000-1.5h-6.5z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 721 B |
2
frontend/src/assets/side-menu-icons/mqtt.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="currentColor" width="800px" height="800px" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg"><title>Eclipse Mosquitto icon</title><path d="M1.353 11.424c0 2.637.964 5.105 2.636 7.013l-1.007.903A11.968 11.968 0 010 11.424C0 8.065 1.38 5.029 3.604 2.85l.05.045L6.637 5.57a7.942 7.942 0 00-1.433 9.963l1.03-.923a6.59 6.59 0 011.416-8.132l1.02.915.909.814.941.844a2.778 2.778 0 00-1.311 2.367c0 1.23.795 2.273 1.899 2.646l.095 1.297a4.024 4.024 0 01-2.483-6.27l-.9-.809-.004-.003a5.233 5.233 0 00.205 6.546l-3.023 2.71a9.291 9.291 0 01-.21-11.97L3.777 4.66a10.599 10.599 0 00-2.407 6.14l-.006.008.005.004c-.011.203-.017.406-.017.612zm11.54 2.639a2.793 2.793 0 00.588-5.013l.941-.844.908-.814 1.021-.915a6.59 6.59 0 011.417 8.132l1.029.923a7.942 7.942 0 00-1.433-9.963l2.981-2.673.05-.045A11.964 11.964 0 0124 11.424c0 2.98-1.095 5.769-2.982 7.916l-1.007-.903a10.61 10.61 0 002.619-7.625l.005-.004-.006-.007a10.598 10.598 0 00-2.407-6.141l-1.008.904a9.291 9.291 0 01-.211 11.97l-3.023-2.71a5.233 5.233 0 00.205-6.546l-.004.003-.9.808a4.024 4.024 0 01-2.482 6.27zM12 21.149l.335-4.571.271-3.712a1.56 1.56 0 10-1.212 0l.271 3.712Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
4
frontend/src/assets/side-menu-icons/orders.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="M6.29977 5H21L19 12H7.37671M20 16H8L6 3H3M9 20C9 20.5523 8.55228 21 8 21C7.44772 21 7 20.5523 7 20C7 19.4477 7.44772 19 8 19C8.55228 19 9 19.4477 9 20ZM20 20C20 20.5523 19.5523 21 19 21C18.4477 21 18 20.5523 18 20C18 19.4477 18.4477 19 19 19C19.5523 19 20 19.4477 20 20Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 596 B |
5
frontend/src/assets/side-menu-icons/product-catalog.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g>
|
||||||
|
<path d="M12,23.3L0,20V3l4,1V1l8,2.3L20,1v3l4-1v17L12,23.3z M2,18.5l10,2.8l10-2.7v-13l-2,0.5v11l-8,2.2L4,17V6L2,5.5V18.5z M13,5.1V17l5-1.4v-12L13,5.1z M6,15.5l5,1.4V5.1L6,3.7V15.5z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 283 B |
12
frontend/src/assets/side-menu-icons/products.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 width="800px" height="800px" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>product-management</title>
|
||||||
|
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<g id="icon" fill="currentColor" transform="translate(42.666667, 34.346667)">
|
||||||
|
<path d="M426.247658,366.986259 C426.477599,368.072636 426.613335,369.17172 426.653805,370.281095 L426.666667,370.986667 L426.666667,392.32 C426.666667,415.884149 383.686003,434.986667 330.666667,434.986667 C278.177524,434.986667 235.527284,416.264289 234.679528,393.025571 L234.666667,392.32 L234.666667,370.986667 L234.679528,370.281095 C234.719905,369.174279 234.855108,368.077708 235.081684,366.992917 C240.961696,371.41162 248.119437,375.487081 256.413327,378.976167 C275.772109,387.120048 301.875889,392.32 330.666667,392.32 C360.599038,392.32 387.623237,386.691188 407.213205,377.984536 C414.535528,374.73017 420.909655,371.002541 426.247658,366.986259 Z M192,7.10542736e-15 L384,106.666667 L384.001134,185.388691 C368.274441,181.351277 350.081492,178.986667 330.666667,178.986667 C301.427978,178.986667 274.9627,184.361969 255.43909,193.039129 C228.705759,204.92061 215.096345,223.091357 213.375754,241.480019 L213.327253,242.037312 L213.449,414.75 L192,426.666667 L-2.13162821e-14,320 L-2.13162821e-14,106.666667 L192,7.10542736e-15 Z M426.247658,302.986259 C426.477599,304.072636 426.613335,305.17172 426.653805,306.281095 L426.666667,306.986667 L426.666667,328.32 C426.666667,351.884149 383.686003,370.986667 330.666667,370.986667 C278.177524,370.986667 235.527284,352.264289 234.679528,329.025571 L234.666667,328.32 L234.666667,306.986667 L234.679528,306.281095 C234.719905,305.174279 234.855108,304.077708 235.081684,302.992917 C240.961696,307.41162 248.119437,311.487081 256.413327,314.976167 C275.772109,323.120048 301.875889,328.32 330.666667,328.32 C360.599038,328.32 387.623237,322.691188 407.213205,313.984536 C414.535528,310.73017 420.909655,307.002541 426.247658,302.986259 Z M127.999,199.108 L128,343.706 L170.666667,367.410315 L170.666667,222.811016 L127.999,199.108 Z M42.6666667,151.701991 L42.6666667,296.296296 L85.333,320.001 L85.333,175.405 L42.6666667,151.701991 Z M330.666667,200.32 C383.155809,200.32 425.80605,219.042377 426.653805,242.281095 L426.666667,242.986667 L426.666667,264.32 C426.666667,287.884149 383.686003,306.986667 330.666667,306.986667 C278.177524,306.986667 235.527284,288.264289 234.679528,265.025571 L234.666667,264.32 L234.666667,242.986667 L234.808715,240.645666 C237.543198,218.170241 279.414642,200.32 330.666667,200.32 Z M275.991,94.069 L150.412,164.155 L192,187.259259 L317.866667,117.333333 L275.991,94.069 Z M192,47.4074074 L66.1333333,117.333333 L107.795,140.479 L233.373,70.393 L192,47.4074074 Z" id="Combined-Shape">
|
||||||
|
|
||||||
|
</path>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
5
frontend/src/assets/side-menu-icons/provision.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 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.25467 9.53972L6.02181 8.77259C8.19159 6.60281 9.27648 5.51791 10.6246 5.51791C10.8261 5.51791 11.0217 5.54214 11.2159 5.59059C12.321 5.86637 13.3815 6.92686 15.2266 8.77189L15.2274 8.77271L15.2281 8.77346C16.8083 10.3536 17.813 11.3583 18.2421 12.3084C18.4021 12.6626 18.4821 13.0093 18.4821 13.3754C18.4821 14.7235 17.3972 15.8084 15.2274 17.9782L14.4603 18.7453C12.2905 20.9151 11.2056 22 9.85748 22C8.50935 22 7.42446 20.9151 5.25467 18.7453C3.08489 16.5755 2 15.4907 2 14.1425C2 12.7944 3.08489 11.7095 5.25467 9.53972ZM4.72452 13.6122C5.01741 13.3193 5.49229 13.3193 5.78518 13.6122L10.388 18.215C10.6809 18.5079 10.6809 18.9828 10.388 19.2757C10.0951 19.5686 9.62022 19.5686 9.32732 19.2757L4.72452 14.6729C4.43163 14.38 4.43163 13.9051 4.72452 13.6122Z" fill="currentColor"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.8302 4.16978C21.2767 5.6163 22 6.33957 22 7.23832C22 8.13707 21.2767 8.86033 19.8302 10.3069L19.1945 10.9426C19.0901 10.7827 18.9783 10.6262 18.8609 10.4724C18.257 9.6808 17.3833 8.80722 16.359 7.78302L16.2171 7.64107C15.1929 6.61678 14.3193 5.74311 13.5277 5.13917C13.3739 5.02178 13.2174 4.90995 13.0574 4.80555L13.6931 4.16978C15.1397 2.72327 15.8629 2 16.7617 2C17.6604 2 18.3837 2.72326 19.8302 4.16978ZM19.6213 6.5C19.9141 6.79289 19.9141 7.26777 19.6213 7.56066L18.9141 8.26777C18.6213 8.56066 18.1464 8.56066 17.8535 8.26777C17.5606 7.97487 17.5606 7.5 17.8535 7.20711L18.5606 6.5C18.8535 6.20711 19.3284 6.20711 19.6213 6.5ZM17.4999 4.37868C17.7928 4.67157 17.7928 5.14645 17.4999 5.43934L16.7928 6.14645C16.4999 6.43934 16.0251 6.43934 15.7322 6.14645C15.4393 5.85355 15.4393 5.37868 15.7322 5.08579L16.4393 4.37868C16.7322 4.08579 17.207 4.08579 17.4999 4.37868Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
33
frontend/src/assets/side-menu-icons/quotations.svg
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<svg fill="currentColor" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g>
|
||||||
|
<path d="M173.345,250.017c-3.253-3.253-7.529-4.885-11.805-4.885c-4.276,0-8.552,1.632-11.815,4.885
|
||||||
|
c-3.144,3.155-4.885,7.355-4.885,11.826c0,4.461,1.73,8.65,4.885,11.794c3.155,3.166,7.344,4.897,11.805,4.897
|
||||||
|
c0.011,0,0.011,0,0.011,0c4.472,0,8.66-1.741,11.815-4.885C179.863,267.131,179.863,256.534,173.345,250.017z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M119.305,103.548c-2.144-2.144-4.874-3.274-7.888-3.274c-3.025,0-5.744,1.121-7.867,3.253
|
||||||
|
c-2.155,2.144-3.274,4.863-3.274,7.888c0,3.014,1.132,5.744,3.264,7.877c4.253,4.286,11.489,4.275,15.754,0
|
||||||
|
c2.132-2.132,3.264-4.863,3.264-7.877C122.558,108.39,121.437,105.671,119.305,103.548z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M497.311,213.708L298.294,14.69c-9.466-9.477-22.054-14.689-35.447-14.689H50.138C22.492,0.001,0,22.491,0,50.137v212.708
|
||||||
|
c0,13.393,5.213,25.982,14.69,35.447L213.709,497.31c9.466,9.477,22.065,14.689,35.447,14.689
|
||||||
|
c13.393,0,25.993-5.223,35.447-14.689l212.708-212.697c9.466-9.466,14.689-22.065,14.689-35.458
|
||||||
|
C512,235.772,506.788,223.174,497.311,213.708z M79.895,142.912c-8.411-8.411-13.045-19.595-13.045-31.498
|
||||||
|
c0-11.913,4.646-23.109,13.068-31.52c8.388-8.399,19.584-13.045,31.498-13.045c11.903,0,23.088,4.635,31.509,13.056
|
||||||
|
c8.411,8.399,13.057,19.595,13.057,31.509c0,11.903-4.635,23.088-13.057,31.509c-8.422,8.421-19.607,13.056-31.509,13.056
|
||||||
|
C99.502,155.979,88.306,151.333,79.895,142.912z M196.988,297.281c-9.466,9.466-22.054,14.678-35.447,14.678
|
||||||
|
c-0.011,0-0.011,0-0.011,0c-13.404,0-25.982-5.212-35.447-14.678c-9.455-9.455-14.666-22.032-14.666-35.437
|
||||||
|
c0-13.405,5.212-25.993,14.678-35.458c19.54-19.541,51.354-19.541,70.894,0C216.529,245.938,216.529,277.741,196.988,297.281z
|
||||||
|
M278.533,362.094c0,9.226-7.486,16.712-16.712,16.712c-9.226,0-16.712-7.486-16.712-16.712V161.55
|
||||||
|
c0-9.226,7.486-16.712,16.712-16.712c9.226,0,16.712,7.486,16.712,16.712V362.094z M397.532,297.281
|
||||||
|
c-9.466,9.466-22.054,14.678-35.447,14.678c-0.011,0-0.011,0-0.011,0c-13.404,0-25.982-5.212-35.447-14.678
|
||||||
|
c-9.455-9.455-14.666-22.032-14.666-35.437c0-13.405,5.211-25.993,14.677-35.458c19.541-19.541,51.355-19.541,70.895,0
|
||||||
|
C417.073,245.938,417.073,277.741,397.532,297.281z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M373.889,250.017c-3.253-3.253-7.529-4.885-11.805-4.885c-4.276,0-8.552,1.632-11.815,4.885
|
||||||
|
c-3.144,3.155-4.885,7.355-4.885,11.826c0,4.461,1.73,8.65,4.885,11.794c3.155,3.166,7.344,4.897,11.805,4.897
|
||||||
|
c0.011,0,0.011,0,0.011,0c4.472,0,8.66-1.741,11.815-4.885C380.407,267.131,380.407,256.534,373.889,250.017z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
4
frontend/src/assets/side-menu-icons/settings.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="M12.7848 0.449982C13.8239 0.449982 14.7167 1.16546 14.9122 2.15495L14.9991 2.59495C15.3408 4.32442 17.1859 5.35722 18.9016 4.7794L19.3383 4.63233C20.3199 4.30175 21.4054 4.69358 21.9249 5.56605L22.7097 6.88386C23.2293 7.75636 23.0365 8.86366 22.2504 9.52253L21.9008 9.81555C20.5267 10.9672 20.5267 13.0328 21.9008 14.1844L22.2504 14.4774C23.0365 15.1363 23.2293 16.2436 22.7097 17.1161L21.925 18.4339C21.4054 19.3064 20.3199 19.6982 19.3382 19.3676L18.9017 19.2205C17.1859 18.6426 15.3408 19.6754 14.9991 21.405L14.9122 21.845C14.7167 22.8345 13.8239 23.55 12.7848 23.55H11.2152C10.1761 23.55 9.28331 22.8345 9.08781 21.8451L9.00082 21.4048C8.65909 19.6754 6.81395 18.6426 5.09822 19.2205L4.66179 19.3675C3.68016 19.6982 2.59465 19.3063 2.07505 18.4338L1.2903 17.1161C0.770719 16.2436 0.963446 15.1363 1.74956 14.4774L2.09922 14.1844C3.47324 13.0327 3.47324 10.9672 2.09922 9.8156L1.74956 9.52254C0.963446 8.86366 0.77072 7.75638 1.2903 6.8839L2.07508 5.56608C2.59466 4.69359 3.68014 4.30176 4.66176 4.63236L5.09831 4.77939C6.81401 5.35722 8.65909 4.32449 9.00082 2.59506L9.0878 2.15487C9.28331 1.16542 10.176 0.449982 11.2152 0.449982H12.7848ZM12 15.3C13.8225 15.3 15.3 13.8225 15.3 12C15.3 10.1774 13.8225 8.69998 12 8.69998C10.1774 8.69998 8.69997 10.1774 8.69997 12C8.69997 13.8225 10.1774 15.3 12 15.3Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
23
frontend/src/assets/side-menu-icons/sms.svg
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<svg viewBox="0 0 52 52" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||||
|
<path d="M26,4C12.7,4,2.1,13.8,2.1,25.9c0,3.8,1.1,7.4,2.9,10.6c0.3,0.5,0.4,1.1,0.2,1.7l-3.1,8.5
|
||||||
|
c-0.3,0.8,0.5,1.5,1.3,1.3l8.6-3.3c0.5-0.2,1.1-0.1,1.7,0.2c3.6,2,7.9,3.2,12.5,3.2C39.3,48,50,38.2,50,26.1C49.9,13.8,39.2,4,26,4z
|
||||||
|
M16.4,29.6c-0.3,0.5-0.6,1-1,1.3s-0.9,0.6-1.5,0.8c-0.5,0.2-1.1,0.2-1.7,0.2c-0.8,0-1.5-0.1-2.2-0.4c-0.7-0.3-1.4-0.7-1.9-1.3
|
||||||
|
L7.9,30c-0.1-0.1,0-0.3,0.2-0.5L9.7,28c0.2-0.2,0.4-0.2,0.5-0.1s0.2,0.3,0.2,0.3c0.2,0.3,0.5,0.5,0.8,0.7c0.5,0.3,1,0.3,1.6,0.2
|
||||||
|
c0.2,0,0.3-0.1,0.5-0.2c0.1-0.1,0.2-0.2,0.3-0.3c0.1-0.1,0.1-0.3,0.1-0.4c0-0.4-0.1-0.5-0.2-0.6c-0.2-0.2-0.5-0.4-0.9-0.5
|
||||||
|
s-0.8-0.3-1.3-0.4c-0.5-0.2-1-0.4-1.4-0.6c-0.5-0.3-0.8-0.7-1.1-1.1c-0.3-0.5-0.5-1.1-0.5-1.9c0-0.7,0.1-1.3,0.4-1.8
|
||||||
|
c0.3-0.5,0.6-0.9,1.1-1.2c0.4-0.3,0.9-0.6,1.5-0.7c1.2-0.3,2.4-0.3,3.6,0.1c0.6,0.2,1.2,0.6,1.5,0.8l0.3,0.2
|
||||||
|
c0.2,0.1,0.1,0.4-0.1,0.6l-1.5,1.5c-0.2,0.2-0.5,0.2-0.7,0c-0.2-0.1-0.3-0.3-0.4-0.3c-0.5-0.3-1.2-0.4-1.8-0.3
|
||||||
|
c-0.2,0-0.3,0.1-0.4,0.2c-0.1,0.1-0.2,0.2-0.3,0.3c-0.1,0.1-0.1,0.2-0.1,0.4c0,0.3,0.1,0.4,0.2,0.5c0.2,0.2,0.5,0.3,0.9,0.5
|
||||||
|
c0.4,0.1,0.8,0.3,1.3,0.4c0.5,0.2,1,0.4,1.4,0.6c0.5,0.3,0.8,0.7,1.1,1.1c0.3,0.5,0.5,1.1,0.5,1.9C16.8,28.5,16.7,29.1,16.4,29.6z
|
||||||
|
M33,31c0,0.6-0.5,1-1.1,1h-1c-0.6,0-0.9-0.4-0.9-1v-5.9c0-0.6-0.8-0.7-1-0.2l-1.7,4.5c-0.1,0.4-0.5,0.6-0.9,0.6h-0.7
|
||||||
|
c-0.4,0-0.8-0.3-0.9-0.6L23,24.9c-0.2-0.5-1-0.4-1,0.2V31c0,0.6-0.5,1-1.1,1h-1c-0.6,0-0.9-0.4-0.9-1V20c0-0.6,0.4-1,0.9-1h2.6
|
||||||
|
c0.4,0,0.8,0.3,0.9,0.6l2,5.2c0.2,0.4,0.8,0.4,0.9,0l2-5.2c0.1-0.4,0.5-0.6,0.9-0.6h2.7c0.6,0,1.1,0.4,1.1,1V31z M43.5,29.7
|
||||||
|
c-0.3,0.5-0.6,1-1.1,1.3c-0.4,0.3-0.9,0.6-1.5,0.8S39.8,32,39.2,32c-0.8,0-1.5-0.1-2.2-0.4c-0.7-0.3-1.4-0.7-1.9-1.3l-0.2-0.2
|
||||||
|
c-0.1-0.1,0-0.3,0.2-0.5l1.6-1.5c0.2-0.2,0.4-0.2,0.5-0.1s0.2,0.3,0.2,0.3c0.2,0.3,0.5,0.5,0.8,0.7c0.5,0.3,1.1,0.3,1.6,0.2
|
||||||
|
c0.2-0.1,0.4-0.1,0.5-0.2c0.1-0.1,0.2-0.2,0.3-0.3s0.1-0.3,0.1-0.4c0-0.4-0.1-0.5-0.2-0.6c-0.2-0.2-0.5-0.4-0.9-0.5
|
||||||
|
s-0.8-0.3-1.3-0.4c-0.5-0.2-1-0.4-1.4-0.6c-0.5-0.3-0.9-0.7-1.2-1.1c-0.3-0.5-0.5-1.1-0.5-1.9c0-0.7,0.1-1.3,0.4-1.8
|
||||||
|
c0.3-0.5,0.6-0.9,1.1-1.2c0.4-0.3,1-0.6,1.5-0.7c1.2-0.3,2.4-0.3,3.6,0.1c0.6,0.2,1.2,0.6,1.5,0.9l0.3,0.3c0.2,0.1,0.1,0.4-0.1,0.6
|
||||||
|
L42,22.9c-0.2,0.2-0.5,0.2-0.7,0c-0.2-0.1-0.3-0.3-0.4-0.3c-0.5-0.3-1.2-0.4-1.8-0.3c-0.2,0-0.3,0.1-0.4,0.2
|
||||||
|
c-0.1,0.1-0.2,0.2-0.3,0.3c-0.1,0.1-0.1,0.3-0.1,0.4c0,0.3,0.1,0.4,0.2,0.5c0.2,0.2,0.5,0.3,0.9,0.5c0.4,0.1,0.8,0.3,1.3,0.4
|
||||||
|
c0.5,0.2,1,0.4,1.4,0.6c0.5,0.3,0.8,0.7,1.2,1.1c0.3,0.5,0.5,1.1,0.5,1.9C43.9,28.5,43.8,29.2,43.5,29.7z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
6
frontend/src/assets/side-menu-icons/sn-manager.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg fill="currentColor" viewBox="0 0 122.88 122.88" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;}</style>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M0.18,0h44.63v44.45H0.18V0L0.18,0z M111.5,111.5h11.38v11.2H111.5V111.5L111.5,111.5z M89.63,111.48h11.38 v10.67H89.63h-0.01H78.25v-21.82h11.02V89.27h11.21V67.22h11.38v10.84h10.84v11.2h-10.84v11.2h-11.21h-0.17H89.63V111.48 L89.63,111.48z M55.84,89.09h11.02v-11.2H56.2v-11.2h10.66v-11.2H56.02v11.2H44.63v-11.2h11.2V22.23h11.38v33.25h11.02v11.2h10.84 v-11.2h11.38v11.2H89.63v11.2H78.25v22.05H67.22v22.23H55.84V89.09L55.84,89.09z M111.31,55.48h11.38v11.2h-11.38V55.48 L111.31,55.48z M22.41,55.48h11.38v11.2H22.41V55.48L22.41,55.48z M0.18,55.48h11.38v11.2H0.18V55.48L0.18,55.48z M55.84,0h11.38 v11.2H55.84V0L55.84,0z M0,78.06h44.63v44.45H0V78.06L0,78.06z M10.84,88.86h22.95v22.86H10.84V88.86L10.84,88.86z M78.06,0h44.63 v44.45H78.06V0L78.06,0z M88.91,10.8h22.95v22.86H88.91V10.8L88.91,10.8z M11.02,10.8h22.95v22.86H11.02V10.8L11.02,10.8z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
3
frontend/src/assets/side-menu-icons/staff-notes.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9 3V5M12 3V5M15 3V5M13 9H9M15 13H9M8.2 21H15.8C16.9201 21 17.4802 21 17.908 20.782C18.2843 20.5903 18.5903 20.2843 18.782 19.908C19 19.4802 19 18.9201 19 17.8V7.2C19 6.0799 19 5.51984 18.782 5.09202C18.5903 4.71569 18.2843 4.40973 17.908 4.21799C17.4802 4 16.9201 4 15.8 4H8.2C7.0799 4 6.51984 4 6.09202 4.21799C5.71569 4.40973 5.40973 4.71569 5.21799 5.09202C5 5.51984 5 6.07989 5 7.2V17.8C5 18.9201 5 19.4802 5.21799 19.908C5.40973 20.2843 5.71569 20.5903 6.09202 20.782C6.51984 21 7.07989 21 8.2 21Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 682 B |
34
frontend/src/assets/side-menu-icons/staff.svg
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?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="currentColor" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
width="800px" height="800px" viewBox="0 0 39.66 39.66" xml:space="preserve"
|
||||||
|
>
|
||||||
|
<g>
|
||||||
|
<g id="_x31__143_">
|
||||||
|
<g>
|
||||||
|
<path d="M4.619,15.479c0.888,3.39,3.752,6.513,7.382,6.513c3.684,0,6.594-3.109,7.504-6.49c0.346-0.039,0.632-0.303,0.663-0.663
|
||||||
|
l0.115-1.336c0.029-0.348-0.189-0.646-0.506-0.756c-0.006-0.08-0.008-0.161-0.017-0.24c-0.068-3.062-0.6-5.534-3.01-6.556
|
||||||
|
c-2.544-1.078-4.786-1.093-6.432-0.453C10.21,5.541,9.931,5.912,9.822,5.979C9.713,6.046,9.136,5.856,8.917,5.907
|
||||||
|
c-3.61,0.516-4.801,3.917-4.538,6.569C4.371,12.55,4.366,12.625,4.36,12.7c-0.349,0.087-0.599,0.404-0.567,0.774l0.114,1.336
|
||||||
|
C3.94,15.188,4.25,15.462,4.619,15.479z M5.388,12.833c1.581-0.579,4.622-1.79,4.952-2.426c1.383,1.437,6.267,2.244,8.411,2.513
|
||||||
|
c0.009,0.139,0.021,0.274,0.021,0.414c0,3.525-2.958,7.623-6.771,7.623c-3.799,0-6.638-4.024-6.638-7.623
|
||||||
|
C5.362,13.165,5.375,13,5.388,12.833z"/>
|
||||||
|
<path d="M17.818,20.777c-0.19-0.029-0.376,0.014-0.498,0.063l-3.041,4.113l-2.307-1.84l-0.014,0.012v0.013l-0.003-0.003
|
||||||
|
l-2.307,1.84l-3.041-4.113c-0.121-0.05-0.308-0.093-0.498-0.064C0.364,21.608,0,34.584,0,34.584l11.969,0.008v-0.021
|
||||||
|
l11.958-0.008C23.928,34.563,23.562,21.587,17.818,20.777z"/>
|
||||||
|
<path d="M23.997,15.302c0.72,2.75,3.044,5.281,5.987,5.281c2.988,0,5.349-2.521,6.087-5.264c0.28-0.032,0.513-0.245,0.538-0.537
|
||||||
|
l0.093-1.083c0.024-0.283-0.154-0.525-0.411-0.614c-0.004-0.063-0.007-0.13-0.014-0.193c-0.055-2.483-0.486-4.49-2.44-5.318
|
||||||
|
c-2.063-0.874-3.882-0.887-5.217-0.368c-0.087,0.035-0.313,0.336-0.401,0.392c-0.09,0.055-0.557-0.101-0.734-0.059
|
||||||
|
c-2.928,0.418-3.895,3.177-3.682,5.328c-0.007,0.061-0.01,0.121-0.015,0.182c-0.283,0.071-0.485,0.328-0.459,0.627l0.092,1.083
|
||||||
|
C23.446,15.065,23.698,15.288,23.997,15.302z M24.62,13.155c1.282-0.47,3.75-1.452,4.017-1.968
|
||||||
|
c1.123,1.164,5.084,1.818,6.822,2.039c0.008,0.11,0.018,0.222,0.018,0.335c0,2.858-2.398,6.183-5.492,6.183
|
||||||
|
c-3.082,0-5.385-3.264-5.385-6.183C24.6,13.425,24.609,13.29,24.62,13.155z"/>
|
||||||
|
<path d="M34.703,19.6c-0.154-0.024-0.305,0.011-0.402,0.05l-2.468,3.337l-1.871-1.492l-0.011,0.009v0.011l-0.003-0.002
|
||||||
|
l-1.871,1.492l-2.468-3.337c-0.098-0.04-0.25-0.073-0.402-0.05c-1.521,0.214-2.574,1.482-3.307,3.089
|
||||||
|
c0.433,0.669,1.043,1.736,1.56,3.14c0.515,1.395,0.856,3.295,1.078,4.958l5.422,0.003v-0.018l9.7-0.006
|
||||||
|
C39.659,30.781,39.363,20.254,34.703,19.6z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
3
frontend/src/assets/side-menu-icons/whatsapp.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.50002 12C3.50002 7.30558 7.3056 3.5 12 3.5C16.6944 3.5 20.5 7.30558 20.5 12C20.5 16.6944 16.6944 20.5 12 20.5C10.3278 20.5 8.77127 20.0182 7.45798 19.1861C7.21357 19.0313 6.91408 18.9899 6.63684 19.0726L3.75769 19.9319L4.84173 17.3953C4.96986 17.0955 4.94379 16.7521 4.77187 16.4751C3.9657 15.176 3.50002 13.6439 3.50002 12ZM12 1.5C6.20103 1.5 1.50002 6.20101 1.50002 12C1.50002 13.8381 1.97316 15.5683 2.80465 17.0727L1.08047 21.107C0.928048 21.4637 0.99561 21.8763 1.25382 22.1657C1.51203 22.4552 1.91432 22.5692 2.28599 22.4582L6.78541 21.1155C8.32245 21.9965 10.1037 22.5 12 22.5C17.799 22.5 22.5 17.799 22.5 12C22.5 6.20101 17.799 1.5 12 1.5ZM14.2925 14.1824L12.9783 15.1081C12.3628 14.7575 11.6823 14.2681 10.9997 13.5855C10.2901 12.8759 9.76402 12.1433 9.37612 11.4713L10.2113 10.7624C10.5697 10.4582 10.6678 9.94533 10.447 9.53028L9.38284 7.53028C9.23954 7.26097 8.98116 7.0718 8.68115 7.01654C8.38113 6.96129 8.07231 7.046 7.84247 7.24659L7.52696 7.52195C6.76823 8.18414 6.3195 9.2723 6.69141 10.3741C7.07698 11.5163 7.89983 13.314 9.58552 14.9997C11.3991 16.8133 13.2413 17.5275 14.3186 17.8049C15.1866 18.0283 16.008 17.7288 16.5868 17.2572L17.1783 16.7752C17.4313 16.5691 17.5678 16.2524 17.544 15.9269C17.5201 15.6014 17.3389 15.308 17.0585 15.1409L15.3802 14.1409C15.0412 13.939 14.6152 13.9552 14.2925 14.1824Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -63,6 +63,75 @@ function AttachmentPill({ name, size, onRemove }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Email chip input ──────────────────────────────────────────────────────────
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
function EmailChipInput({ list, setList, inputVal, setInputVal, placeholder }) {
|
||||||
|
const commit = (raw) => {
|
||||||
|
const parts = raw.split(",").map(s => s.trim()).filter(Boolean);
|
||||||
|
const valid = parts.filter(p => EMAIL_RE.test(p));
|
||||||
|
if (valid.length) setList(prev => [...prev, ...valid.filter(e => !prev.includes(e))]);
|
||||||
|
setInputVal("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e) => {
|
||||||
|
if (e.key === "Enter" || e.key === ",") {
|
||||||
|
e.preventDefault();
|
||||||
|
commit(inputVal);
|
||||||
|
} else if (e.key === "Backspace" && !inputVal && list.length > 0) {
|
||||||
|
setList(prev => prev.slice(0, -1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const chipStyle = {
|
||||||
|
display: "inline-flex", alignItems: "center", gap: 4,
|
||||||
|
padding: "2px 8px", borderRadius: 999, fontSize: 12,
|
||||||
|
backgroundColor: "var(--bg-card-hover)",
|
||||||
|
border: "1px solid var(--border-secondary)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
flexShrink: 0,
|
||||||
|
maxWidth: 220,
|
||||||
|
};
|
||||||
|
const xStyle = {
|
||||||
|
background: "none", border: "none", padding: 0, lineHeight: 1,
|
||||||
|
cursor: "pointer", color: "var(--text-muted)", fontSize: 14,
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex", flexWrap: "wrap", alignItems: "center", gap: 4,
|
||||||
|
minHeight: 34,
|
||||||
|
backgroundColor: "var(--bg-input)",
|
||||||
|
border: "1px solid var(--border-primary)",
|
||||||
|
borderRadius: 6, padding: "4px 8px",
|
||||||
|
cursor: "text",
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.currentTarget.querySelector("input")?.focus()}
|
||||||
|
>
|
||||||
|
{list.map((email) => (
|
||||||
|
<span key={email} style={chipStyle} title={email}>
|
||||||
|
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{email}</span>
|
||||||
|
<button type="button" style={xStyle} onClick={() => setList(prev => prev.filter(e => e !== email))}>×</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputVal}
|
||||||
|
onChange={(e) => setInputVal(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onBlur={() => inputVal.trim() && commit(inputVal)}
|
||||||
|
placeholder={list.length === 0 ? placeholder : ""}
|
||||||
|
style={{
|
||||||
|
flex: "1 1 120px", minWidth: 80, border: "none", outline: "none",
|
||||||
|
background: "transparent", color: "var(--text-primary)", fontSize: 13, padding: "1px 2px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main component ────────────────────────────────────────────────────────────
|
// ── Main component ────────────────────────────────────────────────────────────
|
||||||
export default function ComposeEmailModal({
|
export default function ComposeEmailModal({
|
||||||
open,
|
open,
|
||||||
@@ -75,8 +144,10 @@ export default function ComposeEmailModal({
|
|||||||
customerId = null,
|
customerId = null,
|
||||||
onSent,
|
onSent,
|
||||||
}) {
|
}) {
|
||||||
const [to, setTo] = useState(defaultTo);
|
const [toList, setToList] = useState(defaultTo ? [defaultTo] : []);
|
||||||
const [cc, setCc] = useState("");
|
const [toInput, setToInput] = useState("");
|
||||||
|
const [ccList, setCcList] = useState([]);
|
||||||
|
const [ccInput, setCcInput] = useState("");
|
||||||
const [subject, setSubject] = useState(defaultSubject);
|
const [subject, setSubject] = useState(defaultSubject);
|
||||||
const [fromAccount, setFromAccount] = useState(defaultFromAccount || "");
|
const [fromAccount, setFromAccount] = useState(defaultFromAccount || "");
|
||||||
const [mailAccounts, setMailAccounts] = useState([]);
|
const [mailAccounts, setMailAccounts] = useState([]);
|
||||||
@@ -99,8 +170,10 @@ export default function ComposeEmailModal({
|
|||||||
// Reset fields when opened
|
// Reset fields when opened
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setTo(defaultTo);
|
setToList(defaultTo ? [defaultTo] : []);
|
||||||
setCc("");
|
setToInput("");
|
||||||
|
setCcList([]);
|
||||||
|
setCcInput("");
|
||||||
setSubject(defaultSubject);
|
setSubject(defaultSubject);
|
||||||
setFromAccount(defaultFromAccount || "");
|
setFromAccount(defaultFromAccount || "");
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
@@ -361,27 +434,35 @@ export default function ComposeEmailModal({
|
|||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
const { html, text } = getContent();
|
const { html, text } = getContent();
|
||||||
const toClean = to.trim();
|
// Commit any pending input in the TO/CC fields before validating
|
||||||
const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(toClean);
|
const finalToList = [...toList];
|
||||||
|
if (toInput.trim()) {
|
||||||
|
toInput.split(",").map(s => s.trim()).filter(s => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s)).forEach(e => { if (!finalToList.includes(e)) finalToList.push(e); });
|
||||||
|
}
|
||||||
|
const finalCcList = [...ccList];
|
||||||
|
if (ccInput.trim()) {
|
||||||
|
ccInput.split(",").map(s => s.trim()).filter(s => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s)).forEach(e => { if (!finalCcList.includes(e)) finalCcList.push(e); });
|
||||||
|
}
|
||||||
|
const totalAttachMB = attachments.reduce((s, a) => s + (a.file?.size ?? 0), 0) / (1024 * 1024);
|
||||||
|
const ATTACH_LIMIT_MB = 25;
|
||||||
if (requireFromAccount && !fromAccount) { setError("Please select a sender account."); return; }
|
if (requireFromAccount && !fromAccount) { setError("Please select a sender account."); return; }
|
||||||
if (!to.trim()) { setError("Please enter a recipient email address."); return; }
|
if (finalToList.length === 0) { setError("Please enter a recipient email address."); return; }
|
||||||
if (!emailOk) { setError("Please enter a valid recipient email address."); return; }
|
|
||||||
if (!subject.trim()) { setError("Please enter a subject."); return; }
|
if (!subject.trim()) { setError("Please enter a subject."); return; }
|
||||||
if (!text && !html.replace(/<[^>]*>/g, "").trim()) { setError("Please write a message."); return; }
|
if (!text && !html.replace(/<[^>]*>/g, "").trim()) { setError("Please write a message."); return; }
|
||||||
|
if (totalAttachMB > ATTACH_LIMIT_MB) { setError(`Attachments exceed the ${ATTACH_LIMIT_MB} MB limit (${totalAttachMB.toFixed(1)} MB total).`); return; }
|
||||||
setError("");
|
setError("");
|
||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
const ccList = cc.split(",").map((s) => s.trim()).filter(Boolean);
|
|
||||||
const token = localStorage.getItem("access_token");
|
const token = localStorage.getItem("access_token");
|
||||||
|
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
if (customerId) fd.append("customer_id", customerId);
|
if (customerId) fd.append("customer_id", customerId);
|
||||||
if (fromAccount) fd.append("from_account", fromAccount);
|
if (fromAccount) fd.append("from_account", fromAccount);
|
||||||
fd.append("to", to.trim());
|
fd.append("to", finalToList[0]);
|
||||||
fd.append("subject", subject.trim());
|
fd.append("subject", subject.trim());
|
||||||
fd.append("body", text);
|
fd.append("body", text);
|
||||||
fd.append("body_html", html);
|
fd.append("body_html", html);
|
||||||
fd.append("cc", JSON.stringify(ccList));
|
fd.append("cc", JSON.stringify(finalCcList));
|
||||||
for (const { file } of attachments) {
|
for (const { file } of attachments) {
|
||||||
fd.append("files", file, file.name);
|
fd.append("files", file, file.name);
|
||||||
}
|
}
|
||||||
@@ -478,23 +559,18 @@ export default function ComposeEmailModal({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>To</label>
|
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>To</label>
|
||||||
<input
|
<EmailChipInput
|
||||||
className="compose-email-input"
|
list={toList} setList={setToList}
|
||||||
style={inputStyle}
|
inputVal={toInput} setInputVal={setToInput}
|
||||||
value={to}
|
|
||||||
onChange={(e) => setTo(e.target.value)}
|
|
||||||
placeholder="recipient@example.com"
|
placeholder="recipient@example.com"
|
||||||
type="email"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>CC</label>
|
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>CC</label>
|
||||||
<input
|
<EmailChipInput
|
||||||
className="compose-email-input"
|
list={ccList} setList={setCcList}
|
||||||
style={inputStyle}
|
inputVal={ccInput} setInputVal={setCcInput}
|
||||||
value={cc}
|
placeholder="cc@example.com"
|
||||||
onChange={(e) => setCc(e.target.value)}
|
|
||||||
placeholder="cc1@example.com, cc2@..."
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -598,6 +674,19 @@ export default function ComposeEmailModal({
|
|||||||
>
|
>
|
||||||
✍ Add Signature
|
✍ Add Signature
|
||||||
</button>
|
</button>
|
||||||
|
{(() => {
|
||||||
|
const ATTACH_LIMIT_MB = 25;
|
||||||
|
const totalMB = attachments.reduce((s, a) => s + (a.file?.size ?? 0), 0) / (1024 * 1024);
|
||||||
|
if (attachments.length === 0) return null;
|
||||||
|
const color = totalMB > 25 ? "var(--danger)" : totalMB > 20 ? "var(--danger)" : totalMB > 15 ? "#f59e0b" : "var(--text-muted)";
|
||||||
|
const fontWeight = totalMB > ATTACH_LIMIT_MB ? 700 : 400;
|
||||||
|
return (
|
||||||
|
<span style={{ fontSize: 12, color, fontWeight }}>
|
||||||
|
{totalMB.toFixed(1)}/{ATTACH_LIMIT_MB} MB attached
|
||||||
|
{totalMB > ATTACH_LIMIT_MB && " — too large"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
Tip: Paste images directly with Ctrl+V
|
Tip: Paste images directly with Ctrl+V
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -11,7 +11,20 @@ const LANGUAGES = [
|
|||||||
{ value: "fr", label: "French" },
|
{ value: "fr", label: "French" },
|
||||||
{ value: "it", label: "Italian" },
|
{ value: "it", label: "Italian" },
|
||||||
];
|
];
|
||||||
const TITLES = ["", "Fr.", "Rev.", "Archim.", "Bp.", "Abp.", "Met.", "Mr.", "Mrs.", "Ms.", "Dr.", "Prof."];
|
const TITLES = [
|
||||||
|
{ value: "", label: "—" },
|
||||||
|
{ value: "Fr.", label: "Father" },
|
||||||
|
{ value: "Rev.", label: "Reverend" },
|
||||||
|
{ value: "Archim.", label: "Archimandrite" },
|
||||||
|
{ value: "Bp.", label: "Bishop" },
|
||||||
|
{ value: "Abp.", label: "Archbishop" },
|
||||||
|
{ value: "Met.", label: "Metropolitan" },
|
||||||
|
{ value: "Mr.", label: "Mister" },
|
||||||
|
{ value: "Mrs.", label: "Missus" },
|
||||||
|
{ value: "Ms.", label: "Ms." },
|
||||||
|
{ value: "Dr.", label: "Doctor" },
|
||||||
|
{ value: "Prof.", label: "Professor" },
|
||||||
|
];
|
||||||
const PRESET_TAGS = ["church", "monastery", "municipality", "school", "repeat-customer", "vip", "pending", "inactive"];
|
const PRESET_TAGS = ["church", "monastery", "municipality", "school", "repeat-customer", "vip", "pending", "inactive"];
|
||||||
|
|
||||||
const CONTACT_TYPE_ICONS = {
|
const CONTACT_TYPE_ICONS = {
|
||||||
@@ -70,10 +83,11 @@ export default function CustomerForm() {
|
|||||||
name: "",
|
name: "",
|
||||||
surname: "",
|
surname: "",
|
||||||
organization: "",
|
organization: "",
|
||||||
|
religion: "",
|
||||||
language: "el",
|
language: "el",
|
||||||
tags: [],
|
tags: [],
|
||||||
folder_id: "",
|
folder_id: "",
|
||||||
location: { city: "", country: "", region: "" },
|
location: { address: "", city: "", postal_code: "", region: "", country: "" },
|
||||||
contacts: [],
|
contacts: [],
|
||||||
notes: [],
|
notes: [],
|
||||||
});
|
});
|
||||||
@@ -81,11 +95,17 @@ export default function CustomerForm() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [deleteOpts, setDeleteOpts] = useState({ wipe_comms: false, wipe_files: false, wipe_everything: false });
|
||||||
const [tagInput, setTagInput] = useState("");
|
const [tagInput, setTagInput] = useState("");
|
||||||
|
const [allTags, setAllTags] = useState([]);
|
||||||
const [newNoteText, setNewNoteText] = useState("");
|
const [newNoteText, setNewNoteText] = useState("");
|
||||||
const [editingNoteIdx, setEditingNoteIdx] = useState(null);
|
const [editingNoteIdx, setEditingNoteIdx] = useState(null);
|
||||||
const [editingNoteText, setEditingNoteText] = useState("");
|
const [editingNoteText, setEditingNoteText] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get("/crm/customers/tags").then(setAllTags).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEdit) return;
|
if (!isEdit) return;
|
||||||
api.get(`/crm/customers/${id}`)
|
api.get(`/crm/customers/${id}`)
|
||||||
@@ -95,13 +115,16 @@ export default function CustomerForm() {
|
|||||||
name: data.name || "",
|
name: data.name || "",
|
||||||
surname: data.surname || "",
|
surname: data.surname || "",
|
||||||
organization: data.organization || "",
|
organization: data.organization || "",
|
||||||
|
religion: data.religion || "",
|
||||||
language: data.language || "el",
|
language: data.language || "el",
|
||||||
tags: data.tags || [],
|
tags: data.tags || [],
|
||||||
folder_id: data.folder_id || "",
|
folder_id: data.folder_id || "",
|
||||||
location: {
|
location: {
|
||||||
|
address: data.location?.address || "",
|
||||||
city: data.location?.city || "",
|
city: data.location?.city || "",
|
||||||
country: data.location?.country || "",
|
postal_code: data.location?.postal_code || "",
|
||||||
region: data.location?.region || "",
|
region: data.location?.region || "",
|
||||||
|
country: data.location?.country || "",
|
||||||
},
|
},
|
||||||
contacts: data.contacts || [],
|
contacts: data.contacts || [],
|
||||||
notes: data.notes || [],
|
notes: data.notes || [],
|
||||||
@@ -176,13 +199,16 @@ export default function CustomerForm() {
|
|||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
surname: form.surname.trim() || null,
|
surname: form.surname.trim() || null,
|
||||||
organization: form.organization.trim() || null,
|
organization: form.organization.trim() || null,
|
||||||
|
religion: form.religion.trim() || null,
|
||||||
language: form.language,
|
language: form.language,
|
||||||
tags: form.tags,
|
tags: form.tags,
|
||||||
...(!isEdit && { folder_id: form.folder_id.trim().toLowerCase() }),
|
...(!isEdit && { folder_id: form.folder_id.trim().toLowerCase() }),
|
||||||
location: {
|
location: {
|
||||||
city: form.location.city.trim(),
|
address: form.location.address.trim() || null,
|
||||||
country: form.location.country.trim(),
|
city: form.location.city.trim() || null,
|
||||||
region: form.location.region.trim(),
|
postal_code: form.location.postal_code.trim() || null,
|
||||||
|
region: form.location.region.trim() || null,
|
||||||
|
country: form.location.country.trim() || null,
|
||||||
},
|
},
|
||||||
contacts: form.contacts.filter((c) => c.value.trim()),
|
contacts: form.contacts.filter((c) => c.value.trim()),
|
||||||
notes: form.notes,
|
notes: form.notes,
|
||||||
@@ -215,7 +241,13 @@ export default function CustomerForm() {
|
|||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await api.delete(`/crm/customers/${id}`);
|
const wipeEverything = deleteOpts.wipe_everything;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (wipeEverything || deleteOpts.wipe_comms) params.set("wipe_comms", "true");
|
||||||
|
if (wipeEverything || deleteOpts.wipe_files) params.set("wipe_files", "true");
|
||||||
|
if (wipeEverything) params.set("wipe_nextcloud", "true");
|
||||||
|
const qs = params.toString();
|
||||||
|
await api.delete(`/crm/customers/${id}${qs ? `?${qs}` : ""}`);
|
||||||
navigate("/crm/customers");
|
navigate("/crm/customers");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -223,6 +255,19 @@ export default function CustomerForm() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleDeleteOpt = (key) => {
|
||||||
|
setDeleteOpts((prev) => {
|
||||||
|
if (key === "wipe_everything") {
|
||||||
|
const next = !prev.wipe_everything;
|
||||||
|
// Checking "wipe everything" also checks the other two for clarity,
|
||||||
|
// but unchecking it does NOT uncheck them.
|
||||||
|
if (next) return { wipe_comms: true, wipe_files: true, wipe_everything: true };
|
||||||
|
return { ...prev, wipe_everything: false };
|
||||||
|
}
|
||||||
|
return { ...prev, [key]: !prev[key] };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
|
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
|
||||||
}
|
}
|
||||||
@@ -270,7 +315,7 @@ export default function CustomerForm() {
|
|||||||
<Field label="Title">
|
<Field label="Title">
|
||||||
<select className={inputClass} style={inputStyle} value={form.title}
|
<select className={inputClass} style={inputStyle} value={form.title}
|
||||||
onChange={(e) => set("title", e.target.value)}>
|
onChange={(e) => set("title", e.target.value)}>
|
||||||
{TITLES.map((t) => <option key={t} value={t}>{t || "—"}</option>)}
|
{TITLES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Name *">
|
<Field label="Name *">
|
||||||
@@ -283,12 +328,16 @@ export default function CustomerForm() {
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: Organization, Language, Folder ID */}
|
{/* Row 2: Organization, Religion, Language, Folder ID */}
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16, marginBottom: 16 }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr", gap: 16, marginBottom: 16 }}>
|
||||||
<Field label="Organization">
|
<Field label="Organization">
|
||||||
<input className={inputClass} style={inputStyle} value={form.organization}
|
<input className={inputClass} style={inputStyle} value={form.organization}
|
||||||
onChange={(e) => set("organization", e.target.value)} placeholder="Church, organization, etc." />
|
onChange={(e) => set("organization", e.target.value)} placeholder="Church, organization, etc." />
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="Religion">
|
||||||
|
<input className={inputClass} style={inputStyle} value={form.religion}
|
||||||
|
onChange={(e) => set("religion", e.target.value)} placeholder="e.g. Christian Orthodox" />
|
||||||
|
</Field>
|
||||||
<Field label="Language">
|
<Field label="Language">
|
||||||
<select className={inputClass} style={inputStyle} value={form.language}
|
<select className={inputClass} style={inputStyle} value={form.language}
|
||||||
onChange={(e) => set("language", e.target.value)}>
|
onChange={(e) => set("language", e.target.value)}>
|
||||||
@@ -348,38 +397,107 @@ export default function CustomerForm() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
<input
|
<input
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
value={tagInput}
|
value={tagInput}
|
||||||
onChange={(e) => setTagInput(e.target.value)}
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === ",") {
|
const suggestions = allTags.filter(
|
||||||
|
(t) => t.toLowerCase().startsWith(tagInput.toLowerCase()) && !form.tags.includes(t)
|
||||||
|
);
|
||||||
|
if (e.key === "Tab" && suggestions.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
addTag(suggestions[0]);
|
||||||
|
} else if (e.key === "Enter" || e.key === ",") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
addTag(tagInput);
|
addTag(tagInput);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setTagInput("");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onBlur={() => tagInput.trim() && addTag(tagInput)}
|
onBlur={(e) => {
|
||||||
placeholder="Type a custom tag and press Enter or comma..."
|
// Delay so suggestion clicks register first
|
||||||
|
setTimeout(() => {
|
||||||
|
if (tagInput.trim()) addTag(tagInput);
|
||||||
|
}, 150);
|
||||||
|
}}
|
||||||
|
placeholder="Type a custom tag and press Enter, comma, or Tab to autocomplete..."
|
||||||
/>
|
/>
|
||||||
|
{tagInput.trim().length > 0 && (() => {
|
||||||
|
const suggestions = allTags.filter(
|
||||||
|
(t) => t.toLowerCase().startsWith(tagInput.toLowerCase()) && !form.tags.includes(t)
|
||||||
|
);
|
||||||
|
if (suggestions.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "100%",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 50,
|
||||||
|
border: "1px solid var(--border-primary)",
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: "var(--bg-card)",
|
||||||
|
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||||
|
marginTop: 2,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{suggestions.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s}
|
||||||
|
onMouseDown={(e) => { e.preventDefault(); addTag(s); }}
|
||||||
|
style={{
|
||||||
|
padding: "7px 12px",
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
borderBottom: "1px solid var(--border-primary)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = ""}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
{s === suggestions[0] && (
|
||||||
|
<span style={{ fontSize: 11, color: "var(--text-muted)", marginLeft: 8 }}>Tab to fill</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{/* Location */}
|
{/* Location */}
|
||||||
<SectionCard title="Location">
|
<SectionCard title="Location">
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16 }}>
|
<div style={{ display: "grid", gridTemplateColumns: "2fr 1fr 1fr", gap: 16, marginBottom: 16 }}>
|
||||||
|
<Field label="Address">
|
||||||
|
<input className={inputClass} style={inputStyle} value={form.location.address}
|
||||||
|
onChange={(e) => setLoc("address", e.target.value)} placeholder="Street address" />
|
||||||
|
</Field>
|
||||||
<Field label="City">
|
<Field label="City">
|
||||||
<input className={inputClass} style={inputStyle} value={form.location.city}
|
<input className={inputClass} style={inputStyle} value={form.location.city}
|
||||||
onChange={(e) => setLoc("city", e.target.value)} placeholder="City" />
|
onChange={(e) => setLoc("city", e.target.value)} placeholder="City" />
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="Postal Code">
|
||||||
|
<input className={inputClass} style={inputStyle} value={form.location.postal_code}
|
||||||
|
onChange={(e) => setLoc("postal_code", e.target.value)} placeholder="Postal code" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
|
||||||
|
<Field label="Region">
|
||||||
|
<input className={inputClass} style={inputStyle} value={form.location.region}
|
||||||
|
onChange={(e) => setLoc("region", e.target.value)} placeholder="Region / State" />
|
||||||
|
</Field>
|
||||||
<Field label="Country">
|
<Field label="Country">
|
||||||
<input className={inputClass} style={inputStyle} value={form.location.country}
|
<input className={inputClass} style={inputStyle} value={form.location.country}
|
||||||
onChange={(e) => setLoc("country", e.target.value)} placeholder="Country" />
|
onChange={(e) => setLoc("country", e.target.value)} placeholder="Country" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Region">
|
|
||||||
<input className={inputClass} style={inputStyle} value={form.location.region}
|
|
||||||
onChange={(e) => setLoc("region", e.target.value)} placeholder="Region" />
|
|
||||||
</Field>
|
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
@@ -551,6 +669,30 @@ export default function CustomerForm() {
|
|||||||
<p className="text-sm mb-3" style={{ color: "var(--text-secondary)" }}>
|
<p className="text-sm mb-3" style={{ color: "var(--text-secondary)" }}>
|
||||||
Are you sure? This cannot be undone.
|
Are you sure? This cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
|
<div
|
||||||
|
className="rounded-md border p-3 mb-3"
|
||||||
|
style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ key: "wipe_comms", label: "Wipe all communication logs for this customer" },
|
||||||
|
{ key: "wipe_files", label: "Wipe all file entries from the database and rename Nextcloud folder to STALE_…" },
|
||||||
|
{ key: "wipe_everything", label: "Wipe everything — delete Nextcloud folder entirely (implies both above)" },
|
||||||
|
].map(({ key, label }) => (
|
||||||
|
<label
|
||||||
|
key={key}
|
||||||
|
className="flex items-start gap-2 text-sm cursor-pointer mb-2 last:mb-0"
|
||||||
|
style={{ color: key === "wipe_everything" ? "var(--danger)" : "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={deleteOpts[key]}
|
||||||
|
onChange={() => toggleDeleteOpt(key)}
|
||||||
|
className="mt-0.5 cursor-pointer flex-shrink-0"
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -563,7 +705,7 @@ export default function CustomerForm() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowDeleteConfirm(false)}
|
onClick={() => { setShowDeleteConfirm(false); setDeleteOpts({ wipe_comms: false, wipe_files: false, wipe_everything: false }); }}
|
||||||
className="px-4 py-2 text-sm rounded-md border cursor-pointer"
|
className="px-4 py-2 text-sm rounded-md border cursor-pointer"
|
||||||
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
|
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import api from "../../api/client";
|
import api from "../../api/client";
|
||||||
import { useAuth } from "../../auth/AuthContext";
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
@@ -9,19 +9,509 @@ const inputStyle = {
|
|||||||
color: "var(--text-primary)",
|
color: "var(--text-primary)",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TITLE_SHORT = {
|
||||||
|
"Fr.": "Fr.", "Rev.": "Rev.", "Archim.": "Archim.", "Bp.": "Bp.",
|
||||||
|
"Abp.": "Abp.", "Met.": "Met.", "Mr.": "Mr.", "Mrs.": "Mrs.",
|
||||||
|
"Ms.": "Ms.", "Dr.": "Dr.", "Prof.": "Prof.",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ISO 639-1 → human-readable. Extend as needed.
|
||||||
|
const LANGUAGE_NAMES = {
|
||||||
|
af: "Afrikaans", sq: "Albanian", am: "Amharic", ar: "Arabic",
|
||||||
|
hy: "Armenian", az: "Azerbaijani", eu: "Basque", be: "Belarusian",
|
||||||
|
bn: "Bengali", bs: "Bosnian", bg: "Bulgarian", ca: "Catalan",
|
||||||
|
zh: "Chinese", hr: "Croatian", cs: "Czech", da: "Danish",
|
||||||
|
nl: "Dutch", en: "English", et: "Estonian", fi: "Finnish",
|
||||||
|
fr: "French", ka: "Georgian", de: "German", el: "Greek",
|
||||||
|
gu: "Gujarati", he: "Hebrew", hi: "Hindi", hu: "Hungarian",
|
||||||
|
id: "Indonesian", it: "Italian", ja: "Japanese", kn: "Kannada",
|
||||||
|
kk: "Kazakh", ko: "Korean", lv: "Latvian", lt: "Lithuanian",
|
||||||
|
mk: "Macedonian", ms: "Malay", ml: "Malayalam", mt: "Maltese",
|
||||||
|
mr: "Marathi", mn: "Mongolian", ne: "Nepali", no: "Norwegian",
|
||||||
|
fa: "Persian", pl: "Polish", pt: "Portuguese", pa: "Punjabi",
|
||||||
|
ro: "Romanian", ru: "Russian", sr: "Serbian", si: "Sinhala",
|
||||||
|
sk: "Slovak", sl: "Slovenian", es: "Spanish", sw: "Swahili",
|
||||||
|
sv: "Swedish", tl: "Tagalog", ta: "Tamil", te: "Telugu",
|
||||||
|
th: "Thai", tr: "Turkish", uk: "Ukrainian", ur: "Urdu",
|
||||||
|
uz: "Uzbek", vi: "Vietnamese", cy: "Welsh", yi: "Yiddish",
|
||||||
|
zu: "Zulu",
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveLanguage(val) {
|
||||||
|
if (!val) return "—";
|
||||||
|
const key = val.trim().toLowerCase();
|
||||||
|
return LANGUAGE_NAMES[key] || val;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_COLUMNS = [
|
||||||
|
{ id: "name", label: "Name", default: true },
|
||||||
|
{ id: "organization", label: "Organization", default: true },
|
||||||
|
{ id: "address", label: "Full Address", default: true },
|
||||||
|
{ id: "location", label: "Location", default: true },
|
||||||
|
{ id: "email", label: "Email", default: true },
|
||||||
|
{ id: "phone", label: "Phone", default: true },
|
||||||
|
{ id: "tags", label: "Tags", default: true },
|
||||||
|
{ id: "religion", label: "Religion", default: false },
|
||||||
|
{ id: "language", label: "Language", default: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SORT_OPTIONS = [
|
||||||
|
{ value: "default", label: "Date Added" },
|
||||||
|
{ value: "name", label: "First Name" },
|
||||||
|
{ value: "surname", label: "Surname" },
|
||||||
|
{ value: "latest_comm", label: "Latest Communication" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const COL_STORAGE_KEY = "crm_customers_columns";
|
||||||
|
const COL_ORDER_KEY = "crm_customers_col_order";
|
||||||
|
|
||||||
|
function loadColumnPrefs() {
|
||||||
|
try {
|
||||||
|
const vis = JSON.parse(localStorage.getItem(COL_STORAGE_KEY) || "null");
|
||||||
|
const order = JSON.parse(localStorage.getItem(COL_ORDER_KEY) || "null");
|
||||||
|
const visible = vis || Object.fromEntries(ALL_COLUMNS.map((c) => [c.id, c.default]));
|
||||||
|
const orderedIds = order || ALL_COLUMNS.map((c) => c.id);
|
||||||
|
for (const c of ALL_COLUMNS) {
|
||||||
|
if (!orderedIds.includes(c.id)) orderedIds.push(c.id);
|
||||||
|
}
|
||||||
|
const filtered = orderedIds.filter(id => ALL_COLUMNS.find(c => c.id === id));
|
||||||
|
return { visible, orderedIds: filtered };
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
visible: Object.fromEntries(ALL_COLUMNS.map((c) => [c.id, c.default])),
|
||||||
|
orderedIds: ALL_COLUMNS.map((c) => c.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveColumnPrefs(visible, orderedIds) {
|
||||||
|
localStorage.setItem(COL_STORAGE_KEY, JSON.stringify(visible));
|
||||||
|
localStorage.setItem(COL_ORDER_KEY, JSON.stringify(orderedIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inline SVG icon components (currentColor, no hardcoded fills) ──────────
|
||||||
|
|
||||||
|
function IconNegotiations({ style }) {
|
||||||
|
return (
|
||||||
|
<svg style={style} viewBox="0 -8 72 72" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||||
|
<path 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconIssues({ style }) {
|
||||||
|
return (
|
||||||
|
<svg style={style} viewBox="0 0 283.722 283.722" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||||
|
<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.932c-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.373c2.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.375c-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.933c2.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.013c-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.972c1.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.3C5.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.032c-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.285c-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,0c-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.285c7.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,0c7.81-7.81,7.81-20.475,0-28.285L240.037,208.125z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconImportant({ style, className }) {
|
||||||
|
return (
|
||||||
|
<svg style={style} className={className} viewBox="0 0 299.467 299.467" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||||
|
<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.182c-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.899C301.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.522c-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.876c3.737-7.112,11.052-11.531,19.087-11.531s15.35,4.418,19.087,11.531l98.211,186.876C270.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.251c8.979,0,16.251-7.28,16.251-16.251C165.984,219.294,158.713,212.021,149.733,212.021z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status icons next to customer name ──────────────────────────────────────
|
||||||
|
// direction: "inbound" = client sent last, "outbound" = we sent last, null = unknown
|
||||||
|
|
||||||
|
function CustomerStatusIcons({ customer, direction }) {
|
||||||
|
const hasNeg = customer.negotiating;
|
||||||
|
const hasIssue = customer.has_problem;
|
||||||
|
// "important" = we have an open issue or negotiation AND we sent the last message
|
||||||
|
// (pending reply from client) — shown as breathing exclamation
|
||||||
|
const pendingOurReply = direction === "inbound";
|
||||||
|
|
||||||
|
if (!hasNeg && !hasIssue) return null;
|
||||||
|
|
||||||
|
// Color logic:
|
||||||
|
// negotiations: yellow (#f08c00) if outbound (we sent last), orange (#f76707) if inbound (client sent)
|
||||||
|
// issues: yellow (#f08c00) if outbound, red (#f34b4b) if inbound
|
||||||
|
const negColor = pendingOurReply ? "var(--crm-status-alert)" : "var(--crm-status-warn)";
|
||||||
|
const issColor = pendingOurReply ? "var(--crm-status-danger)" : "var(--crm-status-warn)";
|
||||||
|
const iconSize = { width: 13, height: 13, display: "inline-block", flexShrink: 0 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, marginLeft: 6, verticalAlign: "middle" }}>
|
||||||
|
{hasNeg && (
|
||||||
|
<span title="Negotiating" style={{ color: negColor, display: "inline-flex" }}>
|
||||||
|
<IconNegotiations style={iconSize} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasIssue && (
|
||||||
|
<span title="Has issue" style={{ color: issColor, display: "inline-flex" }}>
|
||||||
|
<IconIssues style={iconSize} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(hasNeg || hasIssue) && pendingOurReply && (
|
||||||
|
<span title="Awaiting our reply" style={{ color: "var(--crm-status-warn)", display: "inline-flex" }}>
|
||||||
|
<IconImportant style={iconSize} className="crm-icon-breathe" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Column toggle ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ColumnToggle({ visible, orderedIds, onChange, onReorder }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [dragging, setDragging] = useState(null);
|
||||||
|
const ref = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = (e, id) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (dragging && dragging !== id) {
|
||||||
|
const next = [...orderedIds];
|
||||||
|
const from = next.indexOf(dragging);
|
||||||
|
const to = next.indexOf(id);
|
||||||
|
next.splice(from, 1);
|
||||||
|
next.splice(to, 0, dragging);
|
||||||
|
onReorder(next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibleCount = Object.values(visible).filter(Boolean).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: "relative" }} ref={ref}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-md border hover:opacity-80 cursor-pointer transition-opacity"
|
||||||
|
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||||
|
</svg>
|
||||||
|
Columns ({visibleCount})
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div style={{ position: "absolute", right: 0, top: "calc(100% + 4px)", zIndex: 20, backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)", borderRadius: 8, width: 200, boxShadow: "0 8px 24px rgba(0,0,0,0.15)", padding: 8 }}>
|
||||||
|
<p className="text-xs font-medium px-2 py-1 mb-1" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Drag to reorder · Click to toggle
|
||||||
|
</p>
|
||||||
|
{orderedIds.map((id) => {
|
||||||
|
const col = ALL_COLUMNS.find((c) => c.id === id);
|
||||||
|
if (!col) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => setDragging(id)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, id)}
|
||||||
|
onDragEnd={() => setDragging(null)}
|
||||||
|
onClick={() => onChange(id, !visible[id])}
|
||||||
|
style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 8, padding: "6px 8px", borderRadius: 6,
|
||||||
|
cursor: "pointer", userSelect: "none",
|
||||||
|
backgroundColor: dragging === id ? "var(--bg-card-hover)" : "transparent",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = dragging === id ? "var(--bg-card-hover)" : "transparent"; }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--text-muted)", cursor: "grab" }}>⠿</span>
|
||||||
|
<div style={{
|
||||||
|
width: 14, height: 14, borderRadius: 3, border: `2px solid ${visible[id] ? "var(--accent)" : "var(--border-primary)"}`,
|
||||||
|
backgroundColor: visible[id] ? "var(--accent)" : "transparent",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{visible[id] && <span style={{ color: "#fff", fontSize: 9, lineHeight: 1 }}>✓</span>}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-primary)" }}>{col.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filter dropdown ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const FILTER_OPTIONS = [
|
||||||
|
{ value: "negotiating", label: "Negotiating" },
|
||||||
|
{ value: "has_problem", label: "Has Open Issue" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function FilterDropdown({ active, onChange }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = (value) => {
|
||||||
|
const next = new Set(active);
|
||||||
|
if (next.has(value)) next.delete(value); else next.add(value);
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const count = active.size;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: "relative" }} ref={ref}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-md border hover:opacity-80 cursor-pointer transition-opacity"
|
||||||
|
style={{
|
||||||
|
backgroundColor: count > 0 ? "var(--accent)" : "var(--bg-input)",
|
||||||
|
borderColor: count > 0 ? "var(--accent)" : "var(--border-primary)",
|
||||||
|
color: count > 0 ? "#fff" : "var(--text-secondary)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2a1 1 0 01-.293.707L13 13.414V19a1 1 0 01-.553.894l-4 2A1 1 0 017 21v-7.586L3.293 6.707A1 1 0 013 6V4z" />
|
||||||
|
</svg>
|
||||||
|
Filter{count > 0 ? ` (${count})` : ""}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div style={{
|
||||||
|
position: "absolute", right: 0, top: "calc(100% + 4px)", zIndex: 20,
|
||||||
|
backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)",
|
||||||
|
borderRadius: 8, minWidth: 190, boxShadow: "0 8px 24px rgba(0,0,0,0.15)", overflow: "hidden",
|
||||||
|
}}>
|
||||||
|
{FILTER_OPTIONS.map(opt => {
|
||||||
|
const checked = active.has(opt.value);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => toggle(opt.value)}
|
||||||
|
style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 10, width: "100%", textAlign: "left",
|
||||||
|
padding: "9px 14px", fontSize: 12, fontWeight: checked ? 600 : 400,
|
||||||
|
cursor: "pointer", background: "none", border: "none",
|
||||||
|
color: checked ? "var(--accent)" : "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.backgroundColor = ""}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: 14, height: 14, borderRadius: 3, flexShrink: 0,
|
||||||
|
border: `2px solid ${checked ? "var(--accent)" : "var(--border-primary)"}`,
|
||||||
|
backgroundColor: checked ? "var(--accent)" : "transparent",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
}}>
|
||||||
|
{checked && <span style={{ color: "#fff", fontSize: 9, lineHeight: 1 }}>✓</span>}
|
||||||
|
</div>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{count > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => { onChange(new Set()); setOpen(false); }}
|
||||||
|
style={{
|
||||||
|
display: "block", width: "100%", textAlign: "center", padding: "7px 14px",
|
||||||
|
fontSize: 11, cursor: "pointer", background: "none", border: "none",
|
||||||
|
borderTop: "1px solid var(--border-secondary)", color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.backgroundColor = ""}
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sort dropdown ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SortDropdown({ value, onChange }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const current = SORT_OPTIONS.find(o => o.value === value) || SORT_OPTIONS[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: "relative" }} ref={ref}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-md border hover:opacity-80 cursor-pointer transition-opacity"
|
||||||
|
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-secondary)", whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" />
|
||||||
|
</svg>
|
||||||
|
{current.label}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div style={{
|
||||||
|
position: "absolute", right: 0, top: "calc(100% + 4px)", zIndex: 20,
|
||||||
|
backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)",
|
||||||
|
borderRadius: 8, minWidth: 180, boxShadow: "0 8px 24px rgba(0,0,0,0.15)", overflow: "hidden",
|
||||||
|
}}>
|
||||||
|
{SORT_OPTIONS.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => { onChange(opt.value); setOpen(false); }}
|
||||||
|
style={{
|
||||||
|
display: "block", width: "100%", textAlign: "left",
|
||||||
|
padding: "9px 14px", fontSize: 12, fontWeight: opt.value === value ? 600 : 400,
|
||||||
|
cursor: "pointer", background: "none", border: "none",
|
||||||
|
color: opt.value === value ? "var(--accent)" : "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.backgroundColor = ""}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function primaryContact(customer, type) {
|
function primaryContact(customer, type) {
|
||||||
const contacts = customer.contacts || [];
|
const contacts = customer.contacts || [];
|
||||||
const primary = contacts.find((c) => c.type === type && c.primary);
|
const primary = contacts.find((c) => c.type === type && c.primary);
|
||||||
return primary?.value || contacts.find((c) => c.type === type)?.value || null;
|
return primary?.value || contacts.find((c) => c.type === type)?.value || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ActionsDropdown({ customer, onUpdate }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [menuPos, setMenuPos] = useState({ top: 0, right: 0 });
|
||||||
|
const [loading, setLoading] = useState(null);
|
||||||
|
const btnRef = useRef(null);
|
||||||
|
const menuRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e) => {
|
||||||
|
if (
|
||||||
|
btnRef.current && !btnRef.current.contains(e.target) &&
|
||||||
|
menuRef.current && !menuRef.current.contains(e.target)
|
||||||
|
) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOpen = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (open) { setOpen(false); return; }
|
||||||
|
const rect = btnRef.current.getBoundingClientRect();
|
||||||
|
setMenuPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = async (type, e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setLoading(type);
|
||||||
|
try {
|
||||||
|
const endpoint = type === "negotiating"
|
||||||
|
? `/crm/customers/${customer.id}/toggle-negotiating`
|
||||||
|
: `/crm/customers/${customer.id}/toggle-problem`;
|
||||||
|
const updated = await api.post(endpoint);
|
||||||
|
onUpdate(updated);
|
||||||
|
} catch {
|
||||||
|
alert("Failed to update status");
|
||||||
|
} finally {
|
||||||
|
setLoading(null);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div onClick={e => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
ref={btnRef}
|
||||||
|
onClick={handleOpen}
|
||||||
|
style={{
|
||||||
|
padding: "4px 10px", fontSize: 11, fontWeight: 600, borderRadius: 5,
|
||||||
|
border: "1px solid var(--border-primary)", cursor: "pointer",
|
||||||
|
backgroundColor: "transparent", color: "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Actions ▾
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
position: "fixed", top: menuPos.top, right: menuPos.right, zIndex: 9999,
|
||||||
|
backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)",
|
||||||
|
borderRadius: 8, minWidth: 180, boxShadow: "0 8px 24px rgba(0,0,0,0.18)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={(e) => toggle("negotiating", e)}
|
||||||
|
disabled={loading === "negotiating"}
|
||||||
|
style={{
|
||||||
|
display: "block", width: "100%", textAlign: "left",
|
||||||
|
padding: "9px 14px", fontSize: 12, fontWeight: 500, cursor: "pointer",
|
||||||
|
background: "none", border: "none",
|
||||||
|
color: customer.negotiating ? "#a16207" : "var(--text-primary)",
|
||||||
|
borderBottom: "1px solid var(--border-secondary)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.backgroundColor = ""}
|
||||||
|
>
|
||||||
|
{loading === "negotiating" ? "..." : customer.negotiating ? "End Negotiations" : "Start Negotiating"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => toggle("problem", e)}
|
||||||
|
disabled={loading === "problem"}
|
||||||
|
style={{
|
||||||
|
display: "block", width: "100%", textAlign: "left",
|
||||||
|
padding: "9px 14px", fontSize: 12, fontWeight: 500, cursor: "pointer",
|
||||||
|
background: "none", border: "none",
|
||||||
|
color: customer.has_problem ? "#b91c1c" : "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.backgroundColor = ""}
|
||||||
|
>
|
||||||
|
{loading === "problem" ? "..." : customer.has_problem ? "Resolve Issue" : "Has Problem"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function CustomerList() {
|
export default function CustomerList() {
|
||||||
const [customers, setCustomers] = useState([]);
|
const [customers, setCustomers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [tagFilter, setTagFilter] = useState("");
|
const [sort, setSort] = useState("default");
|
||||||
|
const [activeFilters, setActiveFilters] = useState(new Set());
|
||||||
const [hoveredRow, setHoveredRow] = useState(null);
|
const [hoveredRow, setHoveredRow] = useState(null);
|
||||||
|
const [colPrefs, setColPrefs] = useState(loadColumnPrefs);
|
||||||
|
// Map of customer_id → "inbound" | "outbound" | null
|
||||||
|
const [commDirections, setCommDirections] = useState({});
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const canEdit = hasPermission("crm", "edit");
|
const canEdit = hasPermission("crm", "edit");
|
||||||
@@ -32,10 +522,12 @@ export default function CustomerList() {
|
|||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (search) params.set("search", search);
|
if (search) params.set("search", search);
|
||||||
if (tagFilter) params.set("tag", tagFilter);
|
if (sort) params.set("sort", sort);
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
const data = await api.get(`/crm/customers${qs ? `?${qs}` : ""}`);
|
const data = await api.get(`/crm/customers${qs ? `?${qs}` : ""}`);
|
||||||
setCustomers(data.customers);
|
setCustomers(data.customers);
|
||||||
|
// Fetch last-comm directions only for customers with a status flag
|
||||||
|
fetchDirections(data.customers);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -43,9 +535,108 @@ export default function CustomerList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchDirections = async (list) => {
|
||||||
fetchCustomers();
|
const flagged = list.filter(c => c.negotiating || c.has_problem);
|
||||||
}, [search, tagFilter]);
|
if (!flagged.length) return;
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
flagged.map(c =>
|
||||||
|
api.get(`/crm/customers/${c.id}/last-comm-direction`)
|
||||||
|
.then(r => [c.id, r.direction])
|
||||||
|
.catch(() => [c.id, null])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const map = {};
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.status === "fulfilled") {
|
||||||
|
const [id, dir] = r.value;
|
||||||
|
map[id] = dir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCommDirections(prev => ({ ...prev, ...map }));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { fetchCustomers(); }, [search, sort]);
|
||||||
|
|
||||||
|
const updateColVisible = (id, vis) => {
|
||||||
|
const next = { ...colPrefs.visible, [id]: vis };
|
||||||
|
setColPrefs((p) => ({ ...p, visible: next }));
|
||||||
|
saveColumnPrefs(next, colPrefs.orderedIds);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateColOrder = (orderedIds) => {
|
||||||
|
setColPrefs((p) => ({ ...p, orderedIds }));
|
||||||
|
saveColumnPrefs(colPrefs.visible, orderedIds);
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibleCols = colPrefs.orderedIds
|
||||||
|
.map((id) => ALL_COLUMNS.find((c) => c.id === id))
|
||||||
|
.filter((c) => c && colPrefs.visible[c.id]);
|
||||||
|
|
||||||
|
const filteredCustomers = activeFilters.size === 0 ? customers : customers.filter(c =>
|
||||||
|
(!activeFilters.has("negotiating") || c.negotiating) &&
|
||||||
|
(!activeFilters.has("has_problem") || c.has_problem)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCustomerUpdate = (updated) => {
|
||||||
|
setCustomers(prev => prev.map(c => c.id === updated.id ? updated : c));
|
||||||
|
// Refresh direction for this customer if it now has/lost a flag
|
||||||
|
if (updated.negotiating || updated.has_problem) {
|
||||||
|
api.get(`/crm/customers/${updated.id}/last-comm-direction`)
|
||||||
|
.then(r => setCommDirections(prev => ({ ...prev, [updated.id]: r.direction })))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCell = (col, c) => {
|
||||||
|
const loc = c.location || {};
|
||||||
|
switch (col.id) {
|
||||||
|
case "name":
|
||||||
|
return (
|
||||||
|
<td key={col.id} className="px-4 py-3 font-medium" style={{ color: "var(--text-heading)" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: 2 }}>
|
||||||
|
<span>{[TITLE_SHORT[c.title], c.name, c.surname].filter(Boolean).join(" ")}</span>
|
||||||
|
<CustomerStatusIcons customer={c} direction={commDirections[c.id] ?? null} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
case "organization":
|
||||||
|
return <td key={col.id} className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{c.organization || "—"}</td>;
|
||||||
|
case "address": {
|
||||||
|
const parts = [loc.address, loc.postal_code, loc.city, loc.region, loc.country].filter(Boolean);
|
||||||
|
return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{parts.join(", ") || "—"}</td>;
|
||||||
|
}
|
||||||
|
case "location": {
|
||||||
|
const cityCountry = [loc.city, loc.country].filter(Boolean).join(", ");
|
||||||
|
return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)", whiteSpace: "nowrap" }}>{cityCountry || "—"}</td>;
|
||||||
|
}
|
||||||
|
case "email":
|
||||||
|
return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{primaryContact(c, "email") || "—"}</td>;
|
||||||
|
case "phone":
|
||||||
|
return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)", whiteSpace: "nowrap" }}>{primaryContact(c, "phone") || "—"}</td>;
|
||||||
|
case "religion":
|
||||||
|
return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{c.religion || "—"}</td>;
|
||||||
|
case "language":
|
||||||
|
return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{resolveLanguage(c.language)}</td>;
|
||||||
|
case "tags":
|
||||||
|
return (
|
||||||
|
<td key={col.id} className="px-4 py-3">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(c.tags || []).slice(0, 3).map((tag) => (
|
||||||
|
<span key={tag} className="px-2 py-0.5 text-xs rounded-full"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{(c.tags || []).length > 3 && (
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>+{c.tags.length - 3}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <td key={col.id} className="px-4 py-3" style={{ color: "var(--text-muted)" }}>—</td>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -65,105 +656,72 @@ export default function CustomerList() {
|
|||||||
<div className="flex gap-3 mb-4">
|
<div className="flex gap-3 mb-4">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by name, location, email, phone, tags..."
|
placeholder="Search by name, organization, address, email, phone, religion, language, tags..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="flex-1 px-3 py-2 text-sm rounded-md border"
|
className="flex-1 px-3 py-2 text-sm rounded-md border"
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
<input
|
<SortDropdown value={sort} onChange={setSort} />
|
||||||
type="text"
|
<FilterDropdown active={activeFilters} onChange={setActiveFilters} />
|
||||||
placeholder="Filter by tag..."
|
<ColumnToggle
|
||||||
value={tagFilter}
|
visible={colPrefs.visible}
|
||||||
onChange={(e) => setTagFilter(e.target.value)}
|
orderedIds={colPrefs.orderedIds}
|
||||||
className="w-40 px-3 py-2 text-sm rounded-md border"
|
onChange={updateColVisible}
|
||||||
style={inputStyle}
|
onReorder={updateColOrder}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div
|
<div className="text-sm rounded-md p-3 mb-4 border"
|
||||||
className="text-sm rounded-md p-3 mb-4 border"
|
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||||
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
|
|
||||||
>
|
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
|
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
|
||||||
) : customers.length === 0 ? (
|
) : filteredCustomers.length === 0 ? (
|
||||||
<div
|
<div className="rounded-lg p-8 text-center text-sm border"
|
||||||
className="rounded-lg p-8 text-center text-sm border"
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}>
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
|
{activeFilters.size > 0 ? "No customers match the current filters." : "No customers found."}
|
||||||
>
|
|
||||||
No customers found.
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div className="rounded-lg overflow-hidden border"
|
||||||
className="rounded-lg overflow-hidden border"
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
|
||||||
>
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
|
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
|
||||||
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Name</th>
|
{visibleCols.map((col) => (
|
||||||
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Organization</th>
|
<th key={col.id} className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>
|
||||||
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Location</th>
|
{col.label}
|
||||||
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Email</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Phone</th>
|
))}
|
||||||
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Tags</th>
|
{canEdit && <th className="px-4 py-3 text-right font-medium" style={{ color: "var(--text-secondary)" }}></th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{customers.map((c, index) => {
|
{filteredCustomers.map((c, index) => (
|
||||||
const loc = c.location || {};
|
|
||||||
const locationStr = [loc.city, loc.country].filter(Boolean).join(", ");
|
|
||||||
return (
|
|
||||||
<tr
|
<tr
|
||||||
key={c.id}
|
key={c.id}
|
||||||
onClick={() => navigate(`/crm/customers/${c.id}`)}
|
onClick={() => navigate(`/crm/customers/${c.id}`)}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
borderBottom: index < customers.length - 1 ? "1px solid var(--border-secondary)" : "none",
|
borderBottom: index < filteredCustomers.length - 1 ? "1px solid var(--border-secondary)" : "none",
|
||||||
backgroundColor: hoveredRow === c.id ? "var(--bg-card-hover)" : "transparent",
|
backgroundColor: hoveredRow === c.id ? "var(--bg-card-hover)" : "transparent",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => setHoveredRow(c.id)}
|
onMouseEnter={() => setHoveredRow(c.id)}
|
||||||
onMouseLeave={() => setHoveredRow(null)}
|
onMouseLeave={() => setHoveredRow(null)}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 font-medium" style={{ color: "var(--text-heading)" }}>
|
{visibleCols.map((col) => renderCell(col, c))}
|
||||||
{[c.title, c.name, c.surname].filter(Boolean).join(" ")}
|
{canEdit && (
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<ActionsDropdown customer={c} onUpdate={handleCustomerUpdate} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{c.organization || "—"}</td>
|
|
||||||
<td className="px-4 py-3" style={{ color: "var(--text-muted)" }}>{locationStr || "—"}</td>
|
|
||||||
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{primaryContact(c, "email") || "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{primaryContact(c, "phone") || "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{(c.tags || []).slice(0, 3).map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="px-2 py-0.5 text-xs rounded-full"
|
|
||||||
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{(c.tags || []).length > 3 && (
|
|
||||||
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
|
||||||
+{c.tags.length - 3}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import api from "../../api/client";
|
import api from "../../api/client";
|
||||||
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
import MailViewModal from "../components/MailViewModal";
|
import MailViewModal from "../components/MailViewModal";
|
||||||
import ComposeEmailModal from "../components/ComposeEmailModal";
|
import ComposeEmailModal from "../components/ComposeEmailModal";
|
||||||
import { CommTypeIconBadge, CommDirectionIcon } from "../components/CommIcons";
|
import { CommTypeIconBadge, CommDirectionIcon } from "../components/CommIcons";
|
||||||
|
|
||||||
|
// Inline SVG icons — all use currentColor so tinting via CSS color works
|
||||||
|
const IconExpand = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 8L21 3M21 3H16M21 3V8M8 8L3 3M3 3L3 8M3 3L8 3M8 16L3 21M3 21H8M3 21L3 16M16 16L21 21M21 21V16M21 21H16"/></svg>;
|
||||||
|
const IconReply = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>;
|
||||||
|
const IconEdit = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M20,16v4a2,2,0,0,1-2,2H4a2,2,0,0,1-2-2V6A2,2,0,0,1,4,4H8"/><polygon points="12.5 15.8 22 6.2 17.8 2 8.3 11.5 8 16 12.5 15.8"/></svg>;
|
||||||
|
const IconDelete = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><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>;
|
||||||
|
|
||||||
// Display labels for transport types - always lowercase
|
// Display labels for transport types - always lowercase
|
||||||
const TYPE_LABELS = {
|
const TYPE_LABELS = {
|
||||||
email: "e-mail",
|
email: "e-mail",
|
||||||
@@ -17,14 +24,35 @@ const TYPE_LABELS = {
|
|||||||
|
|
||||||
const COMMS_TYPES = ["email", "whatsapp", "call", "sms", "note", "in_person"];
|
const COMMS_TYPES = ["email", "whatsapp", "call", "sms", "note", "in_person"];
|
||||||
const DIRECTIONS = ["inbound", "outbound", "internal"];
|
const DIRECTIONS = ["inbound", "outbound", "internal"];
|
||||||
const COMM_DATE_FMT = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", year: "numeric" });
|
|
||||||
const COMM_TIME_FMT = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
|
const COMM_TIME_FMT = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
|
||||||
|
const COMM_FULL_DATE_FMT = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "long", year: "numeric" });
|
||||||
|
|
||||||
function formatCommDateTime(value) {
|
function formatRelativeTime(value) {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
const d = new Date(value);
|
const d = new Date(value);
|
||||||
if (Number.isNaN(d.getTime())) return "";
|
if (Number.isNaN(d.getTime())) return "";
|
||||||
return `${COMM_DATE_FMT.format(d)} · ${COMM_TIME_FMT.format(d).toLowerCase()}`;
|
const diffMs = Date.now() - d.getTime();
|
||||||
|
const diffSec = Math.floor(diffMs / 1000);
|
||||||
|
if (diffSec < 60) return "just now";
|
||||||
|
const diffMin = Math.floor(diffSec / 60);
|
||||||
|
if (diffMin < 60) return `${diffMin}m ago`;
|
||||||
|
const diffHr = Math.floor(diffMin / 60);
|
||||||
|
if (diffHr < 24) return `${diffHr}h ago`;
|
||||||
|
const diffDay = Math.floor(diffHr / 24);
|
||||||
|
if (diffDay < 7) return diffDay === 1 ? "yesterday" : `${diffDay} days ago`;
|
||||||
|
const diffWk = Math.floor(diffDay / 7);
|
||||||
|
if (diffWk < 5) return diffWk === 1 ? "1 week ago" : `${diffWk} weeks ago`;
|
||||||
|
const diffMo = Math.floor(diffDay / 30);
|
||||||
|
if (diffMo < 12) return diffMo === 1 ? "1 month ago" : `${diffMo} months ago`;
|
||||||
|
const diffYr = Math.floor(diffDay / 365);
|
||||||
|
return diffYr === 1 ? "1 year ago" : `${diffYr} years ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFullDateTime(value) {
|
||||||
|
if (!value) return "";
|
||||||
|
const d = new Date(value);
|
||||||
|
if (Number.isNaN(d.getTime())) return "";
|
||||||
|
return `${COMM_FULL_DATE_FMT.format(d)}, ${COMM_TIME_FMT.format(d).toLowerCase()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectStyle = {
|
const selectStyle = {
|
||||||
@@ -116,6 +144,9 @@ function CustomerPickerModal({ open, onClose, customers, value, onChange }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CommsPage() {
|
export default function CommsPage() {
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
const canEdit = hasPermission("crm", "edit");
|
||||||
|
|
||||||
const [entries, setEntries] = useState([]);
|
const [entries, setEntries] = useState([]);
|
||||||
const [customers, setCustomers] = useState({});
|
const [customers, setCustomers] = useState({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -128,6 +159,14 @@ export default function CommsPage() {
|
|||||||
const [syncResult, setSyncResult] = useState(null);
|
const [syncResult, setSyncResult] = useState(null);
|
||||||
const [custPickerOpen, setCustPickerOpen] = useState(false);
|
const [custPickerOpen, setCustPickerOpen] = useState(false);
|
||||||
|
|
||||||
|
// Hover/edit/delete state for entries
|
||||||
|
const [hoveredId, setHoveredId] = useState(null);
|
||||||
|
const [deleteId, setDeleteId] = useState(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [editId, setEditId] = useState(null);
|
||||||
|
const [editForm, setEditForm] = useState({});
|
||||||
|
const [editSaving, setEditSaving] = useState(false);
|
||||||
|
|
||||||
// Modals
|
// Modals
|
||||||
const [viewEntry, setViewEntry] = useState(null);
|
const [viewEntry, setViewEntry] = useState(null);
|
||||||
const [composeOpen, setComposeOpen] = useState(false);
|
const [composeOpen, setComposeOpen] = useState(false);
|
||||||
@@ -185,6 +224,51 @@ export default function CommsPage() {
|
|||||||
setComposeOpen(true);
|
setComposeOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startEdit = (entry) => {
|
||||||
|
setEditId(entry.id);
|
||||||
|
setEditForm({
|
||||||
|
type: entry.type || "",
|
||||||
|
direction: entry.direction || "",
|
||||||
|
subject: entry.subject || "",
|
||||||
|
body: entry.body || "",
|
||||||
|
logged_by: entry.logged_by || "",
|
||||||
|
occurred_at: entry.occurred_at ? entry.occurred_at.slice(0, 16) : "",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async () => {
|
||||||
|
setEditSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {};
|
||||||
|
if (editForm.type) payload.type = editForm.type;
|
||||||
|
if (editForm.direction) payload.direction = editForm.direction;
|
||||||
|
if (editForm.subject !== undefined) payload.subject = editForm.subject || null;
|
||||||
|
if (editForm.body !== undefined) payload.body = editForm.body || null;
|
||||||
|
if (editForm.logged_by !== undefined) payload.logged_by = editForm.logged_by || null;
|
||||||
|
if (editForm.occurred_at) payload.occurred_at = new Date(editForm.occurred_at).toISOString();
|
||||||
|
await api.put(`/crm/comms/${editId}`, payload);
|
||||||
|
setEditId(null);
|
||||||
|
await loadAll();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || "Failed to save");
|
||||||
|
} finally {
|
||||||
|
setEditSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await api.delete(`/crm/comms/${id}`);
|
||||||
|
setDeleteId(null);
|
||||||
|
await loadAll();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || "Failed to delete");
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filtered = custFilter
|
const filtered = custFilter
|
||||||
? entries.filter((e) => e.customer_id === custFilter)
|
? entries.filter((e) => e.customer_id === custFilter)
|
||||||
: entries;
|
: entries;
|
||||||
@@ -208,7 +292,7 @@ export default function CommsPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-5">
|
<div className="flex items-center justify-between mb-5">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold" style={{ color: "var(--text-heading)" }}>Activity Log</h1>
|
<h1 className="text-xl font-bold" style={{ color: "var(--text-heading)" }}>Communications Log</h1>
|
||||||
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>
|
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||||
All customer communications across all channels
|
All customer communications across all channels
|
||||||
</p>
|
</p>
|
||||||
@@ -309,9 +393,14 @@ export default function CommsPage() {
|
|||||||
const customer = customers[entry.customer_id];
|
const customer = customers[entry.customer_id];
|
||||||
const isExpanded = expandedId === entry.id;
|
const isExpanded = expandedId === entry.id;
|
||||||
const isEmail = entry.type === "email";
|
const isEmail = entry.type === "email";
|
||||||
|
const isHov = hoveredId === entry.id;
|
||||||
|
const isPendingDelete = deleteId === entry.id;
|
||||||
|
const isEditing = editId === entry.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={entry.id} style={{ position: "relative", paddingLeft: 44 }}>
|
<div key={entry.id} style={{ position: "relative", paddingLeft: 44 }}
|
||||||
|
onMouseEnter={() => setHoveredId(entry.id)}
|
||||||
|
onMouseLeave={() => setHoveredId(null)}>
|
||||||
{/* Type icon marker */}
|
{/* Type icon marker */}
|
||||||
<div style={{ position: "absolute", left: 8, top: 11, zIndex: 1 }}>
|
<div style={{ position: "absolute", left: 8, top: 11, zIndex: 1 }}>
|
||||||
<CommTypeIconBadge type={entry.type} />
|
<CommTypeIconBadge type={entry.type} />
|
||||||
@@ -321,11 +410,61 @@ export default function CommsPage() {
|
|||||||
className="rounded-lg border"
|
className="rounded-lg border"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--bg-card)",
|
backgroundColor: "var(--bg-card)",
|
||||||
borderColor: "var(--border-primary)",
|
borderColor: isPendingDelete ? "var(--danger)" : isEditing ? "var(--accent)" : "var(--border-primary)",
|
||||||
cursor: entry.body ? "pointer" : "default",
|
cursor: entry.body && !isEditing ? "pointer" : "default",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
onClick={() => entry.body && toggleExpand(entry.id)}
|
onClick={() => !isEditing && entry.body && toggleExpand(entry.id)}
|
||||||
>
|
>
|
||||||
|
{/* Hover overlay: gradient + 3-col action panel (no layout shift) */}
|
||||||
|
{isHov && !isPendingDelete && !isEditing && (
|
||||||
|
<div style={{ position: "absolute", inset: 0, pointerEvents: "none", zIndex: 2, background: "linear-gradient(to left, rgba(24, 35, 48, 0.95) 0%, rgba(31, 41, 55, 0.2) 32%, transparent 45%)", borderRadius: "inherit" }}>
|
||||||
|
<div style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", display: "flex", flexDirection: "row", alignItems: "center", gap: 10, pointerEvents: "all" }}>
|
||||||
|
|
||||||
|
{/* Col 1 — date info */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 3 }}>
|
||||||
|
<span style={{ fontSize: 10, color: "rgba(255,255,255,0.55)", whiteSpace: "nowrap", lineHeight: 1.4 }}>
|
||||||
|
{entry.direction === "inbound" ? "Received" : entry.direction === "outbound" ? "Sent" : "Logged"} via {TYPE_LABELS[entry.type] || entry.type}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 10, color: "rgba(255,255,255,0.9)", whiteSpace: "nowrap", lineHeight: 1.4 }}>
|
||||||
|
{formatFullDateTime(entry.occurred_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div style={{ width: 1, alignSelf: "stretch", backgroundColor: "rgba(255,255,255,0.18)", flexShrink: 0, margin: "2px 0" }} />
|
||||||
|
|
||||||
|
{/* Col 2 — Full View / Reply */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||||||
|
<button type="button" onClick={(e) => { e.stopPropagation(); isEmail && setViewEntry(entry); }}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 5, width: 90, justifyContent: "center", padding: "4px 0", fontSize: 11, borderRadius: 5, cursor: isEmail ? "pointer" : "default", backdropFilter: "blur(4px)", whiteSpace: "nowrap", color: isEmail ? "#fff" : "rgba(255,255,255,0.3)", backgroundColor: isEmail ? "rgba(255,255,255,0.12)" : "transparent", border: `1px solid ${isEmail ? "rgba(255,255,255,0.25)" : "rgba(255,255,255,0.1)"}` }}>
|
||||||
|
<IconExpand /><span>Full View</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={(e) => { e.stopPropagation(); openReply(entry); }}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 5, width: 90, justifyContent: "center", padding: "4px 0", fontSize: 11, borderRadius: 5, cursor: "pointer", backdropFilter: "blur(4px)", whiteSpace: "nowrap", color: "#fff", backgroundColor: "rgba(255,255,255,0.12)", border: "1px solid rgba(255,255,255,0.25)" }}>
|
||||||
|
<IconReply /><span>Reply</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Col 3 — Edit / Delete (canEdit only) */}
|
||||||
|
{canEdit && (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||||||
|
<button type="button" onClick={(e) => { e.stopPropagation(); startEdit(entry); }}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 5, width: 90, justifyContent: "center", padding: "4px 0", fontSize: 11, borderRadius: 5, cursor: "pointer", backdropFilter: "blur(4px)", whiteSpace: "nowrap", color: "#fff", backgroundColor: "rgba(255,255,255,0.12)", border: "1px solid rgba(255,255,255,0.25)" }}>
|
||||||
|
<IconEdit /><span>Edit</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={(e) => { e.stopPropagation(); setDeleteId(entry.id); }}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 5, width: 90, justifyContent: "center", padding: "4px 0", fontSize: 11, borderRadius: 5, cursor: "pointer", backdropFilter: "blur(4px)", whiteSpace: "nowrap", color: "#fff", backgroundColor: "rgba(185,28,28,0.7)", border: "1px solid rgba(220,38,38,0.5)" }}>
|
||||||
|
<IconDelete /><span>Delete</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Entry header */}
|
{/* Entry header */}
|
||||||
<div className="flex items-center gap-2 px-4 py-3 flex-wrap">
|
<div className="flex items-center gap-2 px-4 py-3 flex-wrap">
|
||||||
<CommDirectionIcon direction={entry.direction} />
|
<CommDirectionIcon direction={entry.direction} />
|
||||||
@@ -352,35 +491,17 @@ export default function CommsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{/* Full View button (for email entries) */}
|
|
||||||
{isEmail && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => { e.stopPropagation(); setViewEntry(entry); }}
|
|
||||||
className="text-xs px-2 py-0.5 rounded cursor-pointer hover:opacity-80 flex-shrink-0"
|
|
||||||
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-primary)" }}
|
|
||||||
>
|
|
||||||
Full View
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<span className="text-xs flex-shrink-0" style={{ color: "var(--text-muted)" }}>
|
<span className="text-xs flex-shrink-0" style={{ color: "var(--text-muted)" }}>
|
||||||
{formatCommDateTime(entry.occurred_at)}
|
{formatRelativeTime(entry.occurred_at)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{entry.body && (
|
|
||||||
<span className="text-xs flex-shrink-0" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{isExpanded ? "▲" : "▼"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
{entry.body && (
|
{entry.body && (
|
||||||
<div className="pb-3" style={{ paddingLeft: 16, paddingRight: 16 }}>
|
<div className="pb-3" style={{ paddingLeft: 16, paddingRight: 16 }}>
|
||||||
<div style={{ borderTop: "1px solid var(--border-secondary)", marginLeft: 0, marginRight: 0 }} />
|
<div style={{ borderTop: "1px solid var(--border-secondary)" }} />
|
||||||
<p
|
<p className="text-sm mt-2"
|
||||||
className="text-sm mt-2"
|
|
||||||
style={{
|
style={{
|
||||||
color: "var(--text-primary)",
|
color: "var(--text-primary)",
|
||||||
display: "-webkit-box",
|
display: "-webkit-box",
|
||||||
@@ -388,38 +509,103 @@ export default function CommsPage() {
|
|||||||
WebkitBoxOrient: "vertical",
|
WebkitBoxOrient: "vertical",
|
||||||
overflow: isExpanded ? "visible" : "hidden",
|
overflow: isExpanded ? "visible" : "hidden",
|
||||||
whiteSpace: "pre-wrap",
|
whiteSpace: "pre-wrap",
|
||||||
}}
|
}}>
|
||||||
>
|
|
||||||
{entry.body}
|
{entry.body}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer: logged_by + attachments + Quick Reply */}
|
{/* Footer */}
|
||||||
{(entry.logged_by || (entry.attachments?.length > 0) || (isExpanded && isEmail)) && (
|
{(entry.logged_by || (entry.attachments?.length > 0) || isPendingDelete) && (
|
||||||
<div className="px-4 pb-3 flex items-center gap-3 flex-wrap">
|
<div className="px-4 pb-3 flex items-center gap-3 flex-wrap">
|
||||||
{entry.logged_by && (
|
{entry.logged_by && (
|
||||||
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>by {entry.logged_by}</span>
|
||||||
by {entry.logged_by}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
{entry.attachments?.length > 0 && (
|
{entry.attachments?.length > 0 && (
|
||||||
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
📎 {entry.attachments.length} attachment{entry.attachments.length !== 1 ? "s" : ""}
|
📎 {entry.attachments.length} attachment{entry.attachments.length !== 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isExpanded && isEmail && (
|
|
||||||
<button
|
{/* Delete confirmation */}
|
||||||
type="button"
|
{isPendingDelete && (
|
||||||
onClick={(e) => { e.stopPropagation(); openReply(entry); }}
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
className="ml-auto text-xs px-2 py-1 rounded-md cursor-pointer hover:opacity-90"
|
<span className="text-xs" style={{ color: "var(--danger-text)" }}>Delete this entry?</span>
|
||||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", border: "none" }}
|
<button type="button" disabled={deleting} onClick={(e) => { e.stopPropagation(); handleDelete(entry.id); }}
|
||||||
>
|
className="text-xs px-2 py-1 rounded cursor-pointer hover:opacity-90"
|
||||||
↩ Quick Reply
|
style={{ backgroundColor: "var(--danger)", color: "#fff", opacity: deleting ? 0.7 : 1 }}>
|
||||||
|
{deleting ? "..." : "Confirm"}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" onClick={(e) => { e.stopPropagation(); setDeleteId(null); }}
|
||||||
|
className="text-xs px-2 py-1 rounded cursor-pointer hover:opacity-80"
|
||||||
|
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)" }}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Inline edit form */}
|
||||||
|
{isEditing && (
|
||||||
|
<div className="px-4 pb-4 border-t" style={{ borderColor: "var(--border-secondary)" }} onClick={e => e.stopPropagation()}>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 10, marginTop: 12, marginBottom: 10 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--text-muted)", marginBottom: 4 }}>Type</div>
|
||||||
|
<select value={editForm.type} onChange={e => setEditForm(f => ({...f, type: e.target.value}))}
|
||||||
|
className="w-full px-2 py-1.5 text-xs rounded-md border"
|
||||||
|
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)" }}>
|
||||||
|
{["email","whatsapp","call","sms","note","in_person"].map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--text-muted)", marginBottom: 4 }}>Direction</div>
|
||||||
|
<select value={editForm.direction} onChange={e => setEditForm(f => ({...f, direction: e.target.value}))}
|
||||||
|
className="w-full px-2 py-1.5 text-xs rounded-md border"
|
||||||
|
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)" }}>
|
||||||
|
{["inbound","outbound","internal"].map(d => <option key={d} value={d}>{d}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--text-muted)", marginBottom: 4 }}>Date & Time</div>
|
||||||
|
<input type="datetime-local" value={editForm.occurred_at}
|
||||||
|
onChange={e => setEditForm(f => ({...f, occurred_at: e.target.value}))}
|
||||||
|
className="w-full px-2 py-1.5 text-xs rounded-md border"
|
||||||
|
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--text-muted)", marginBottom: 4 }}>Subject</div>
|
||||||
|
<input value={editForm.subject} onChange={e => setEditForm(f => ({...f, subject: e.target.value}))}
|
||||||
|
className="w-full px-2 py-1.5 text-xs rounded-md border"
|
||||||
|
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)" }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--text-muted)", marginBottom: 4 }}>Body</div>
|
||||||
|
<textarea value={editForm.body} onChange={e => setEditForm(f => ({...f, body: e.target.value}))}
|
||||||
|
rows={3} className="w-full px-2 py-1.5 text-xs rounded-md border resize-none"
|
||||||
|
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)" }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--text-muted)", marginBottom: 4 }}>Logged By</div>
|
||||||
|
<input value={editForm.logged_by} onChange={e => setEditForm(f => ({...f, logged_by: e.target.value}))}
|
||||||
|
className="w-full px-2 py-1.5 text-xs rounded-md border"
|
||||||
|
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)" }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button type="button" onClick={(e) => { e.stopPropagation(); setEditId(null); }}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-md cursor-pointer hover:opacity-80"
|
||||||
|
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-input)" }}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={editSaving} onClick={(e) => { e.stopPropagation(); handleSaveEdit(); }}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-md cursor-pointer hover:opacity-90"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", border: "none", opacity: editSaving ? 0.6 : 1 }}>
|
||||||
|
{editSaving ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -439,7 +439,7 @@ export default function MailPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-5">
|
<div className="flex items-center justify-between mb-5">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold" style={{ color: "var(--text-heading)" }}>Mail</h1>
|
<h1 className="text-xl font-bold" style={{ color: "var(--text-heading)" }}>Mailbox</h1>
|
||||||
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>All synced emails</p>
|
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>All synced emails</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export default function OrderList() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Orders</h1>
|
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Orders Manager</h1>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate("/crm/orders/new")}
|
onClick={() => navigate("/crm/orders/new")}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ const defaultForm = {
|
|||||||
sku: "",
|
sku: "",
|
||||||
category: "controller",
|
category: "controller",
|
||||||
description: "",
|
description: "",
|
||||||
|
name_en: "",
|
||||||
|
name_gr: "",
|
||||||
|
description_en: "",
|
||||||
|
description_gr: "",
|
||||||
price: "",
|
price: "",
|
||||||
currency: "EUR",
|
currency: "EUR",
|
||||||
status: "active",
|
status: "active",
|
||||||
@@ -109,6 +113,10 @@ export default function ProductForm() {
|
|||||||
sku: data.sku || "",
|
sku: data.sku || "",
|
||||||
category: data.category || "controller",
|
category: data.category || "controller",
|
||||||
description: data.description || "",
|
description: data.description || "",
|
||||||
|
name_en: data.name_en || data.name || "",
|
||||||
|
name_gr: data.name_gr || data.name || "",
|
||||||
|
description_en: data.description_en || data.description || "",
|
||||||
|
description_gr: data.description_gr || data.description || "",
|
||||||
price: data.price != null ? String(data.price) : "",
|
price: data.price != null ? String(data.price) : "",
|
||||||
currency: data.currency || "EUR",
|
currency: data.currency || "EUR",
|
||||||
status: data.status || (data.active !== false ? "active" : "discontinued"),
|
status: data.status || (data.active !== false ? "active" : "discontinued"),
|
||||||
@@ -167,10 +175,14 @@ export default function ProductForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const buildPayload = () => ({
|
const buildPayload = () => ({
|
||||||
name: form.name.trim(),
|
name: form.name_en.trim() || form.name.trim(),
|
||||||
sku: form.sku.trim() || null,
|
sku: form.sku.trim() || null,
|
||||||
category: form.category,
|
category: form.category,
|
||||||
description: form.description.trim() || null,
|
description: form.description_en.trim() || form.description.trim() || null,
|
||||||
|
name_en: form.name_en.trim() || null,
|
||||||
|
name_gr: form.name_gr.trim() || null,
|
||||||
|
description_en: form.description_en.trim() || null,
|
||||||
|
description_gr: form.description_gr.trim() || null,
|
||||||
price: form.price !== "" ? parseFloat(form.price) : null,
|
price: form.price !== "" ? parseFloat(form.price) : null,
|
||||||
currency: form.currency,
|
currency: form.currency,
|
||||||
status: form.status,
|
status: form.status,
|
||||||
@@ -196,8 +208,8 @@ export default function ProductForm() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!form.name.trim()) {
|
if (!form.name_en.trim() && !form.name.trim()) {
|
||||||
setError("Product name is required.");
|
setError("Product name (English) is required.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -258,7 +270,7 @@ export default function ProductForm() {
|
|||||||
const currentPhoto = photoPreview || existingPhotoUrl;
|
const currentPhoto = photoPreview || existingPhotoUrl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 1300, margin: "0 auto" }}>
|
<div style={{ maxWidth: 1800, margin: "0 auto" }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
|
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
|
||||||
@@ -334,7 +346,7 @@ export default function ProductForm() {
|
|||||||
<div style={{ display: "flex", gap: 20, alignItems: "flex-start" }}>
|
<div style={{ display: "flex", gap: 20, alignItems: "flex-start" }}>
|
||||||
|
|
||||||
{/* LEFT column */}
|
{/* LEFT column */}
|
||||||
<div style={{ flex: "0 0 460px", display: "flex", flexDirection: "column", gap: 16 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
|
|
||||||
{/* Product Details */}
|
{/* Product Details */}
|
||||||
<SectionCard title="Product Details">
|
<SectionCard title="Product Details">
|
||||||
@@ -387,17 +399,30 @@ export default function ProductForm() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Name + SKU */}
|
{/* Name (EN) + Name (GR) */}
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 14 }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 14 }}>
|
||||||
<Field label="Name *">
|
<Field label="Name — English *">
|
||||||
<input
|
<input
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
value={form.name}
|
value={form.name_en}
|
||||||
onChange={(e) => set("name", e.target.value)}
|
onChange={(e) => set("name_en", e.target.value)}
|
||||||
placeholder="e.g. Vesper Plus"
|
placeholder="e.g. Vesper Plus"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="Name — Greek">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.name_gr}
|
||||||
|
onChange={(e) => set("name_gr", e.target.value)}
|
||||||
|
placeholder="π.χ. Vesper Plus"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SKU + Category */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 14 }}>
|
||||||
<Field label="SKU">
|
<Field label="SKU">
|
||||||
<input
|
<input
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
@@ -407,10 +432,6 @@ export default function ProductForm() {
|
|||||||
placeholder="e.g. VSP-001"
|
placeholder="e.g. VSP-001"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Category */}
|
|
||||||
<div style={{ marginBottom: 14 }}>
|
|
||||||
<Field label="Category">
|
<Field label="Category">
|
||||||
<select
|
<select
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
@@ -425,15 +446,28 @@ export default function ProductForm() {
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description EN */}
|
||||||
<div style={{ marginBottom: 14 }}>
|
<div style={{ marginBottom: 14 }}>
|
||||||
<Field label="Description">
|
<Field label="Description — English">
|
||||||
<textarea
|
<textarea
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
style={{ ...inputStyle, resize: "vertical", minHeight: 72 }}
|
style={{ ...inputStyle, resize: "vertical", minHeight: 72 }}
|
||||||
value={form.description}
|
value={form.description_en}
|
||||||
onChange={(e) => set("description", e.target.value)}
|
onChange={(e) => set("description_en", e.target.value)}
|
||||||
placeholder="Optional product description..."
|
placeholder="Optional product description in English..."
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description GR */}
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<Field label="Description — Greek">
|
||||||
|
<textarea
|
||||||
|
className={inputClass}
|
||||||
|
style={{ ...inputStyle, resize: "vertical", minHeight: 72 }}
|
||||||
|
value={form.description_gr}
|
||||||
|
onChange={(e) => set("description_gr", e.target.value)}
|
||||||
|
placeholder="Προαιρετική περιγραφή προϊόντος στα Ελληνικά..."
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export default function ProductList() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
|
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
|
||||||
Products
|
Product Catalog
|
||||||
</h1>
|
</h1>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
414
frontend/src/crm/quotations/AllQuotationsList.jsx
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import api from "../../api/client";
|
||||||
|
|
||||||
|
const STATUS_STYLES = {
|
||||||
|
draft: { bg: "var(--bg-card-hover)", color: "var(--text-secondary)" },
|
||||||
|
sent: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
|
||||||
|
accepted: { bg: "var(--success-bg)", color: "var(--success-text)" },
|
||||||
|
rejected: { bg: "var(--danger-bg)", color: "var(--danger-text)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmt(n) {
|
||||||
|
const f = parseFloat(n) || 0;
|
||||||
|
return f.toLocaleString("el-GR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
return iso.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PDF.js loader (shared pattern) ───────────────────────────────────────────
|
||||||
|
function loadPdfJs() {
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
if (window.pdfjsLib) { res(); return; }
|
||||||
|
if (document.getElementById("__pdfjs2__")) {
|
||||||
|
const check = setInterval(() => {
|
||||||
|
if (window.pdfjsLib) { clearInterval(check); res(); }
|
||||||
|
}, 50);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const s = document.createElement("script");
|
||||||
|
s.id = "__pdfjs2__";
|
||||||
|
s.src = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js";
|
||||||
|
s.onload = () => {
|
||||||
|
window.pdfjsLib.GlobalWorkerOptions.workerSrc =
|
||||||
|
"https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
|
||||||
|
res();
|
||||||
|
};
|
||||||
|
s.onerror = rej;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function PdfThumbnail({ quotationId, onClick }) {
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await loadPdfJs();
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
const url = `/api/crm/quotations/${quotationId}/pdf`;
|
||||||
|
const loadingTask = window.pdfjsLib.getDocument({
|
||||||
|
url,
|
||||||
|
httpHeaders: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
const pdf = await loadingTask.promise;
|
||||||
|
if (cancelled) return;
|
||||||
|
const page = await pdf.getPage(1);
|
||||||
|
if (cancelled) return;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const viewport = page.getViewport({ scale: 1 });
|
||||||
|
const scale = Math.min(72 / viewport.width, 96 / viewport.height);
|
||||||
|
const scaled = page.getViewport({ scale });
|
||||||
|
canvas.width = scaled.width;
|
||||||
|
canvas.height = scaled.height;
|
||||||
|
await page.render({ canvasContext: canvas.getContext("2d"), viewport: scaled }).promise;
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setFailed(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [quotationId]);
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
width: 72, height: 96, borderRadius: 4, overflow: "hidden", flexShrink: 0,
|
||||||
|
cursor: "pointer", border: "1px solid var(--border-primary)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
backgroundColor: "var(--bg-primary)",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
return (
|
||||||
|
<div style={style} onClick={onClick} title="Open PDF">
|
||||||
|
<span style={{ fontSize: 28 }}>📑</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={style} onClick={onClick} title="Open PDF">
|
||||||
|
<canvas ref={canvasRef} style={{ width: "100%", height: "100%", objectFit: "contain", display: "block" }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraftThumbnail() {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: 72, height: 96, borderRadius: 4, flexShrink: 0,
|
||||||
|
border: "1px dashed var(--border-primary)",
|
||||||
|
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
|
||||||
|
backgroundColor: "var(--bg-primary)", gap: 4,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 18 }}>📄</span>
|
||||||
|
<span style={{ fontSize: 9, fontWeight: 700, color: "var(--text-muted)", letterSpacing: "0.06em", textTransform: "uppercase" }}>
|
||||||
|
DRAFT
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PdfViewModal({ quotationId, quotationNumber, onClose }) {
|
||||||
|
const [blobUrl, setBlobUrl] = useState(null);
|
||||||
|
const [loadingPdf, setLoadingPdf] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let objectUrl = null;
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
fetch(`/api/crm/quotations/${quotationId}/pdf`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
})
|
||||||
|
.then(r => {
|
||||||
|
if (!r.ok) throw new Error("Failed to load PDF");
|
||||||
|
return r.blob();
|
||||||
|
})
|
||||||
|
.then(blob => {
|
||||||
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
setBlobUrl(objectUrl);
|
||||||
|
})
|
||||||
|
.catch(() => setError(true))
|
||||||
|
.finally(() => setLoadingPdf(false));
|
||||||
|
return () => { if (objectUrl) URL.revokeObjectURL(objectUrl); };
|
||||||
|
}, [quotationId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 1000,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.88)",
|
||||||
|
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-card)", borderRadius: 10, overflow: "hidden",
|
||||||
|
width: "80vw", height: "88vh", display: "flex", flexDirection: "column",
|
||||||
|
boxShadow: "0 24px 64px rgba(0,0,0,0.5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
padding: "10px 16px", borderBottom: "1px solid var(--border-primary)", flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: "var(--text-heading)" }}>{quotationNumber}</span>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
{blobUrl && (
|
||||||
|
<a
|
||||||
|
href={blobUrl}
|
||||||
|
download={`${quotationNumber}.pdf`}
|
||||||
|
style={{ padding: "4px 12px", fontSize: 12, borderRadius: 6, textDecoration: "none", backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{ background: "none", border: "none", color: "var(--text-muted)", fontSize: 22, cursor: "pointer", lineHeight: 1, padding: "0 4px" }}
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
{loadingPdf && <span style={{ color: "var(--text-muted)", fontSize: 13 }}>Loading PDF...</span>}
|
||||||
|
{error && <span style={{ color: "var(--danger-text)", fontSize: 13 }}>Failed to load PDF.</span>}
|
||||||
|
{blobUrl && (
|
||||||
|
<iframe
|
||||||
|
src={blobUrl}
|
||||||
|
style={{ width: "100%", height: "100%", border: "none" }}
|
||||||
|
title={quotationNumber}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Component ─────────────────────────────────────────────────────────────
|
||||||
|
export default function AllQuotationsList() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [quotations, setQuotations] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [deleting, setDeleting] = useState(null);
|
||||||
|
const [pdfPreview, setPdfPreview] = useState(null); // { id, number }
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get("/crm/quotations/all");
|
||||||
|
setQuotations(Array.isArray(res) ? res : []);
|
||||||
|
} catch {
|
||||||
|
setQuotations([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
function openPdfModal(q) {
|
||||||
|
setPdfPreview({ id: q.id, number: q.quotation_number });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(q) {
|
||||||
|
if (!window.confirm(`Delete quotation ${q.quotation_number}? This cannot be undone.`)) return;
|
||||||
|
setDeleting(q.id);
|
||||||
|
try {
|
||||||
|
await api.delete(`/crm/quotations/${q.id}`);
|
||||||
|
setQuotations(prev => prev.filter(x => x.id !== q.id));
|
||||||
|
} catch {
|
||||||
|
alert("Failed to delete quotation");
|
||||||
|
} finally {
|
||||||
|
setDeleting(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid columns: thumbnail | number | customer | title | date | status | total
|
||||||
|
const GRID = "90px 120px 180px minmax(0,1fr) 110px 120px 130px";
|
||||||
|
|
||||||
|
function renderRow(q) {
|
||||||
|
const isDeleting = deleting === q.id;
|
||||||
|
const displayDate = q.is_legacy ? (q.legacy_date || q.created_at?.slice(0, 10) || "—") : (q.created_at?.slice(0, 10) || "—");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={q.id}
|
||||||
|
className="quotation-row"
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: GRID,
|
||||||
|
gap: 12,
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderBottom: "1px solid var(--border-secondary)",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: 110,
|
||||||
|
backgroundColor: "var(--bg-card)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() => navigate(`/crm/customers/${q.customer_id}?tab=Quotations`)}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.backgroundColor = "var(--bg-card)"}
|
||||||
|
>
|
||||||
|
{/* Thumbnail — stops row click */}
|
||||||
|
<div onClick={e => e.stopPropagation()}>
|
||||||
|
{(q.is_legacy ? !!q.legacy_pdf_path : !!q.nextcloud_pdf_url) ? (
|
||||||
|
<PdfThumbnail quotationId={q.id} onClick={() => openPdfModal(q)} />
|
||||||
|
) : (
|
||||||
|
<DraftThumbnail />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Number */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
<span style={{ fontFamily: "monospace", fontSize: 13, fontWeight: 600, color: "var(--text-primary)" }}>
|
||||||
|
{q.quotation_number}
|
||||||
|
</span>
|
||||||
|
{q.is_legacy && (
|
||||||
|
<span style={{
|
||||||
|
display: "inline-block", padding: "1px 6px", borderRadius: 4,
|
||||||
|
fontSize: 9, fontWeight: 700, letterSpacing: "0.08em",
|
||||||
|
backgroundColor: "var(--badge-orange-bg, rgba(124,58,0,0.12))",
|
||||||
|
color: "var(--badge-orange-text, #c0621e)",
|
||||||
|
textTransform: "uppercase", alignSelf: "flex-start",
|
||||||
|
}}>
|
||||||
|
LEGACY
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Customer */}
|
||||||
|
<div style={{ fontSize: 13, color: "var(--accent)", fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{q.customer_name || q.customer_id}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div style={{ overflow: "hidden", paddingRight: 8 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 500, color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{q.title || <span style={{ color: "var(--text-muted)", fontStyle: "italic", fontWeight: 400 }}>Untitled</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<div style={{ fontSize: 12, color: "var(--text-secondary)", textAlign: "center" }}>
|
||||||
|
{displayDate}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
<span style={{
|
||||||
|
display: "inline-block", padding: "2px 10px", borderRadius: 20,
|
||||||
|
fontSize: 11, fontWeight: 600,
|
||||||
|
backgroundColor: STATUS_STYLES[q.status]?.bg || "var(--bg-card-hover)",
|
||||||
|
color: STATUS_STYLES[q.status]?.color || "var(--text-secondary)",
|
||||||
|
}}>
|
||||||
|
{q.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total */}
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 600, color: "var(--success-text)", textAlign: "right", paddingRight: 16 }}>
|
||||||
|
{fmt(q.final_total)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete — hover-only overlay */}
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); handleDelete(q); }}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="quotation-delete-btn"
|
||||||
|
style={{
|
||||||
|
position: "absolute", top: 8, right: 10,
|
||||||
|
padding: "3px 10px", fontSize: 11, fontWeight: 600, borderRadius: 5,
|
||||||
|
border: "1px solid rgba(239,68,68,0.4)",
|
||||||
|
backgroundColor: "rgba(239,68,68,0.08)", color: "var(--danger-text)",
|
||||||
|
cursor: "pointer", opacity: 0, transition: "opacity 0.15s",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDeleting ? "..." : "Delete"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* PDF Preview Modal */}
|
||||||
|
{pdfPreview && (
|
||||||
|
<PdfViewModal
|
||||||
|
quotationId={pdfPreview.id}
|
||||||
|
quotationNumber={pdfPreview.number}
|
||||||
|
onClose={() => setPdfPreview(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Page header */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 24 }}>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ fontSize: 22, fontWeight: 700, color: "var(--text-heading)", margin: 0 }}>Quotations Manager</h1>
|
||||||
|
{!loading && (
|
||||||
|
<p style={{ fontSize: 13, color: "var(--text-muted)", margin: "4px 0 0" }}>
|
||||||
|
{quotations.length} quotation{quotations.length !== 1 ? "s" : ""} across all customers
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div style={{ textAlign: "center", padding: 60, color: "var(--text-muted)", fontSize: 13 }}>Loading...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && quotations.length === 0 && (
|
||||||
|
<div style={{
|
||||||
|
textAlign: "center", padding: "60px 20px",
|
||||||
|
backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)", borderRadius: 8,
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 36, marginBottom: 12 }}>📋</div>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 500, color: "var(--text-heading)", marginBottom: 6 }}>No quotations found</div>
|
||||||
|
<div style={{ fontSize: 13, color: "var(--text-muted)" }}>
|
||||||
|
Create a quotation from a customer's page to see it here.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && quotations.length > 0 && (
|
||||||
|
<div style={{ borderRadius: 8, border: "1px solid var(--border-primary)", overflow: "hidden" }}>
|
||||||
|
{/* Table header */}
|
||||||
|
<div style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: GRID,
|
||||||
|
backgroundColor: "var(--bg-card)",
|
||||||
|
borderBottom: "1px solid var(--border-primary)",
|
||||||
|
padding: "8px 16px",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
}}>
|
||||||
|
<div />
|
||||||
|
{[
|
||||||
|
{ label: "Number", align: "left" },
|
||||||
|
{ label: "Customer", align: "left" },
|
||||||
|
{ label: "Title", align: "left" },
|
||||||
|
{ label: "Date", align: "center" },
|
||||||
|
{ label: "Status", align: "center" },
|
||||||
|
{ label: "Total", align: "right", paddingRight: 16 },
|
||||||
|
].map(({ label, align, paddingRight }) => (
|
||||||
|
<div key={label} style={{ fontSize: 11, fontWeight: 600, color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.04em", textAlign: align, paddingRight }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{quotations.map(q => renderRow(q))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -101,6 +101,8 @@ function fmt(n) {
|
|||||||
const emptyItem = (sortOrder = 0) => ({
|
const emptyItem = (sortOrder = 0) => ({
|
||||||
product_id: null,
|
product_id: null,
|
||||||
description: "",
|
description: "",
|
||||||
|
description_en: "",
|
||||||
|
description_gr: "",
|
||||||
unit_type: "pcs",
|
unit_type: "pcs",
|
||||||
unit_cost: 0,
|
unit_cost: 0,
|
||||||
discount_percent: 0,
|
discount_percent: 0,
|
||||||
@@ -216,7 +218,12 @@ function ProductSearchModal({ onSelect, onClose }) {
|
|||||||
onMouseEnter={e => e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"}
|
onMouseEnter={e => e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"}
|
||||||
onMouseLeave={e => e.currentTarget.style.backgroundColor = ""}
|
onMouseLeave={e => e.currentTarget.style.backgroundColor = ""}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--text-primary)" }}>{p.name}</div>
|
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--text-primary)" }}>
|
||||||
|
{p.name_en || p.name}
|
||||||
|
{p.name_gr && p.name_gr !== (p.name_en || p.name) && (
|
||||||
|
<span style={{ color: "var(--text-muted)", fontWeight: 400, marginLeft: 6 }}>/ {p.name_gr}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div style={{ fontSize: 11, color: "var(--text-muted)" }}>
|
<div style={{ fontSize: 11, color: "var(--text-muted)" }}>
|
||||||
{p.sku && <span style={{ marginRight: 8 }}>SKU: {p.sku}</span>}
|
{p.sku && <span style={{ marginRight: 8 }}>SKU: {p.sku}</span>}
|
||||||
<span style={{ color: "var(--accent)" }}>{fmt(p.price)}</span>
|
<span style={{ color: "var(--accent)" }}>{fmt(p.price)}</span>
|
||||||
@@ -348,6 +355,8 @@ export default function QuotationForm() {
|
|||||||
items: (q.items || []).map(it => ({
|
items: (q.items || []).map(it => ({
|
||||||
product_id: it.product_id || null,
|
product_id: it.product_id || null,
|
||||||
description: it.description || "",
|
description: it.description || "",
|
||||||
|
description_en: it.description_en || "",
|
||||||
|
description_gr: it.description_gr || "",
|
||||||
unit_type: it.unit_type || "pcs",
|
unit_type: it.unit_type || "pcs",
|
||||||
unit_cost: it.unit_cost || 0,
|
unit_cost: it.unit_cost || 0,
|
||||||
discount_percent: it.discount_percent || 0,
|
discount_percent: it.discount_percent || 0,
|
||||||
@@ -388,7 +397,7 @@ export default function QuotationForm() {
|
|||||||
// Only pre-populate on new quotations (not edit mode where saved values take precedence)
|
// Only pre-populate on new quotations (not edit mode where saved values take precedence)
|
||||||
if (!clientPopulated) {
|
if (!clientPopulated) {
|
||||||
const name = [c.title, c.name, c.surname].filter(Boolean).join(" ");
|
const name = [c.title, c.name, c.surname].filter(Boolean).join(" ");
|
||||||
const location = [c.location?.address, c.location?.city, c.location?.region, c.location?.country].filter(Boolean).join(", ");
|
const location = [c.location?.address, c.location?.city, c.location?.postal_code, c.location?.region, c.location?.country].filter(Boolean).join(", ");
|
||||||
const phone = (c.contacts || []).find(ct => ct.type === "phone")?.value || "";
|
const phone = (c.contacts || []).find(ct => ct.type === "phone")?.value || "";
|
||||||
const email = (c.contacts || []).find(ct => ct.type === "email")?.value || "";
|
const email = (c.contacts || []).find(ct => ct.type === "email")?.value || "";
|
||||||
setForm(f => ({
|
setForm(f => ({
|
||||||
@@ -425,13 +434,19 @@ export default function QuotationForm() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const addProductFromCatalogue = useCallback((product) => {
|
const addProductFromCatalogue = useCallback((product) => {
|
||||||
setForm(f => ({
|
setForm(f => {
|
||||||
|
const lang = f.language || "en";
|
||||||
|
const nameEn = product.name_en || product.name || "";
|
||||||
|
const nameGr = product.name_gr || product.name || "";
|
||||||
|
return {
|
||||||
...f,
|
...f,
|
||||||
items: [
|
items: [
|
||||||
...f.items,
|
...f.items,
|
||||||
{
|
{
|
||||||
product_id: product.id,
|
product_id: product.id,
|
||||||
description: product.name,
|
description: lang === "gr" ? nameGr : nameEn,
|
||||||
|
description_en: nameEn,
|
||||||
|
description_gr: nameGr,
|
||||||
unit_type: "pcs",
|
unit_type: "pcs",
|
||||||
unit_cost: product.price || 0,
|
unit_cost: product.price || 0,
|
||||||
discount_percent: 0,
|
discount_percent: 0,
|
||||||
@@ -440,7 +455,8 @@ export default function QuotationForm() {
|
|||||||
sort_order: f.items.length,
|
sort_order: f.items.length,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
setShowProductModal(false);
|
setShowProductModal(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -494,6 +510,8 @@ export default function QuotationForm() {
|
|||||||
items: form.items.map((it, i) => ({
|
items: form.items.map((it, i) => ({
|
||||||
product_id: it.product_id || null,
|
product_id: it.product_id || null,
|
||||||
description: it.description || null,
|
description: it.description || null,
|
||||||
|
description_en: it.description_en || null,
|
||||||
|
description_gr: it.description_gr || null,
|
||||||
unit_type: it.unit_type || "pcs",
|
unit_type: it.unit_type || "pcs",
|
||||||
unit_cost: parseFloat(it.unit_cost) || 0,
|
unit_cost: parseFloat(it.unit_cost) || 0,
|
||||||
discount_percent: parseFloat(it.discount_percent) || 0,
|
discount_percent: parseFloat(it.discount_percent) || 0,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const STATUS_STYLES = {
|
|||||||
rejected: { bg: "var(--danger-bg)", color: "var(--danger-text)" },
|
rejected: { bg: "var(--danger-bg)", color: "var(--danger-text)" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const STATUSES = ["draft", "sent", "accepted", "rejected"];
|
||||||
|
|
||||||
function fmt(n) {
|
function fmt(n) {
|
||||||
const f = parseFloat(n) || 0;
|
const f = parseFloat(n) || 0;
|
||||||
return f.toLocaleString("el-GR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €";
|
return f.toLocaleString("el-GR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €";
|
||||||
@@ -24,7 +26,6 @@ function loadPdfJs() {
|
|||||||
return new Promise((res, rej) => {
|
return new Promise((res, rej) => {
|
||||||
if (window.pdfjsLib) { res(); return; }
|
if (window.pdfjsLib) { res(); return; }
|
||||||
if (document.getElementById("__pdfjs2__")) {
|
if (document.getElementById("__pdfjs2__")) {
|
||||||
// Script already injected — wait for it
|
|
||||||
const check = setInterval(() => {
|
const check = setInterval(() => {
|
||||||
if (window.pdfjsLib) { clearInterval(check); res(); }
|
if (window.pdfjsLib) { clearInterval(check); res(); }
|
||||||
}, 50);
|
}, 50);
|
||||||
@@ -78,16 +79,9 @@ function PdfThumbnail({ quotationId, onClick }) {
|
|||||||
}, [quotationId]);
|
}, [quotationId]);
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
width: 72,
|
width: 72, height: 96, borderRadius: 4, overflow: "hidden", flexShrink: 0,
|
||||||
height: 96,
|
cursor: "pointer", border: "1px solid var(--border-primary)",
|
||||||
borderRadius: 4,
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
overflow: "hidden",
|
|
||||||
flexShrink: 0,
|
|
||||||
cursor: "pointer",
|
|
||||||
border: "1px solid var(--border-primary)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
backgroundColor: "var(--bg-primary)",
|
backgroundColor: "var(--bg-primary)",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -200,6 +194,177 @@ function PdfViewModal({ quotationId, quotationNumber, onClose }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Legacy Quotation Modal ─────────────────────────────────────────────────────
|
||||||
|
function LegacyQuotationModal({ customerId, existing, onClose, onSaved }) {
|
||||||
|
const isEdit = !!existing;
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
title: existing?.title || "",
|
||||||
|
quotation_number: existing?.quotation_number || "",
|
||||||
|
legacy_date: existing?.legacy_date || new Date().toISOString().slice(0, 10),
|
||||||
|
status: existing?.status || "sent",
|
||||||
|
final_total: existing?.final_total != null ? String(existing.final_total) : "",
|
||||||
|
});
|
||||||
|
const [pdfFile, setPdfFile] = useState(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
width: "100%", padding: "7px 10px", fontSize: 13, borderRadius: 6,
|
||||||
|
border: "1px solid var(--border-primary)", backgroundColor: "var(--bg-primary)",
|
||||||
|
color: "var(--text-primary)", outline: "none", boxSizing: "border-box",
|
||||||
|
};
|
||||||
|
const labelStyle = { fontSize: 11, fontWeight: 600, color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.04em", marginBottom: 4, display: "block" };
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!form.title.trim()) { setError("Title is required"); return; }
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
let quotation;
|
||||||
|
if (isEdit) {
|
||||||
|
quotation = await api.put(`/crm/quotations/${existing.id}`, {
|
||||||
|
title: form.title,
|
||||||
|
status: form.status,
|
||||||
|
legacy_date: form.legacy_date || null,
|
||||||
|
extras_cost: parseFloat(form.final_total) || 0,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
quotation = await api.post("/crm/quotations", {
|
||||||
|
customer_id: customerId,
|
||||||
|
title: form.title,
|
||||||
|
status: form.status,
|
||||||
|
is_legacy: true,
|
||||||
|
legacy_date: form.legacy_date || null,
|
||||||
|
// Use extras_cost to carry the total for legacy quotations (no line items)
|
||||||
|
extras_cost: parseFloat(form.final_total) || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload PDF if provided
|
||||||
|
if (pdfFile) {
|
||||||
|
await api.upload(`/crm/quotations/${quotation.id}/legacy-pdf`, pdfFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e?.message || "Failed to save");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{ position: "fixed", inset: 0, zIndex: 1000, backgroundColor: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-card)", borderRadius: 10, width: 480, maxWidth: "95vw",
|
||||||
|
boxShadow: "0 24px 64px rgba(0,0,0,0.45)", display: "flex", flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "14px 20px", borderBottom: "1px solid var(--border-primary)" }}>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 700, color: "var(--text-heading)" }}>
|
||||||
|
{isEdit ? "Edit Legacy Quotation" : "Add Legacy Quotation"}
|
||||||
|
</span>
|
||||||
|
<button onClick={onClose} style={{ background: "none", border: "none", color: "var(--text-muted)", fontSize: 22, cursor: "pointer", lineHeight: 1 }}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div style={{ padding: "20px", display: "flex", flexDirection: "column", gap: 14 }}>
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Title *</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.title}
|
||||||
|
onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
|
||||||
|
placeholder="e.g. Church Sound System Offer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date + Status */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.legacy_date}
|
||||||
|
onChange={e => setForm(f => ({ ...f, legacy_date: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Status</label>
|
||||||
|
<select
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.status}
|
||||||
|
onChange={e => setForm(f => ({ ...f, status: e.target.value }))}
|
||||||
|
>
|
||||||
|
{STATUSES.map(s => <option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Total Amount (€)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.final_total}
|
||||||
|
onChange={e => setForm(f => ({ ...f, final_total: e.target.value }))}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PDF upload */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>PDF File (optional)</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf"
|
||||||
|
style={{ ...inputStyle, padding: "5px 10px" }}
|
||||||
|
onChange={e => setPdfFile(e.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
{existing?.legacy_pdf_path && !pdfFile && (
|
||||||
|
<div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 4 }}>
|
||||||
|
Current: {existing.legacy_pdf_path.split("/").pop()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div style={{ fontSize: 12, color: "var(--danger-text)" }}>{error}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", padding: "12px 20px", borderTop: "1px solid var(--border-primary)" }}>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{ padding: "7px 18px", fontSize: 13, borderRadius: 6, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
style={{ padding: "7px 18px", fontSize: 13, fontWeight: 600, borderRadius: 6, border: "none", cursor: "pointer", backgroundColor: "var(--accent)", color: "#fff" }}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : isEdit ? "Save Changes" : "Add Legacy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Component ─────────────────────────────────────────────────────────────
|
||||||
export default function QuotationList({ customerId, onSend }) {
|
export default function QuotationList({ customerId, onSend }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [quotations, setQuotations] = useState([]);
|
const [quotations, setQuotations] = useState([]);
|
||||||
@@ -207,6 +372,8 @@ export default function QuotationList({ customerId, onSend }) {
|
|||||||
const [deleting, setDeleting] = useState(null);
|
const [deleting, setDeleting] = useState(null);
|
||||||
const [regenerating, setRegenerating] = useState(null);
|
const [regenerating, setRegenerating] = useState(null);
|
||||||
const [pdfPreview, setPdfPreview] = useState(null); // { id, number }
|
const [pdfPreview, setPdfPreview] = useState(null); // { id, number }
|
||||||
|
const [showLegacyModal, setShowLegacyModal] = useState(false);
|
||||||
|
const [editingLegacy, setEditingLegacy] = useState(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!customerId) return;
|
if (!customerId) return;
|
||||||
@@ -223,6 +390,9 @@ export default function QuotationList({ customerId, onSend }) {
|
|||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const regular = quotations.filter(q => !q.is_legacy);
|
||||||
|
const legacy = quotations.filter(q => q.is_legacy);
|
||||||
|
|
||||||
async function handleDelete(q) {
|
async function handleDelete(q) {
|
||||||
if (!window.confirm(`Delete quotation ${q.quotation_number}? This cannot be undone.`)) return;
|
if (!window.confirm(`Delete quotation ${q.quotation_number}? This cannot be undone.`)) return;
|
||||||
setDeleting(q.id);
|
setDeleting(q.id);
|
||||||
@@ -240,10 +410,7 @@ export default function QuotationList({ customerId, onSend }) {
|
|||||||
setRegenerating(q.id);
|
setRegenerating(q.id);
|
||||||
try {
|
try {
|
||||||
const updated = await api.post(`/crm/quotations/${q.id}/regenerate-pdf`);
|
const updated = await api.post(`/crm/quotations/${q.id}/regenerate-pdf`);
|
||||||
setQuotations(prev => prev.map(x => x.id === updated.id ? {
|
setQuotations(prev => prev.map(x => x.id === updated.id ? { ...x, nextcloud_pdf_url: updated.nextcloud_pdf_url } : x));
|
||||||
...x,
|
|
||||||
nextcloud_pdf_url: updated.nextcloud_pdf_url,
|
|
||||||
} : x));
|
|
||||||
} catch {
|
} catch {
|
||||||
alert("PDF regeneration failed");
|
alert("PDF regeneration failed");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -256,56 +423,145 @@ export default function QuotationList({ customerId, onSend }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Grid columns: thumbnail | number | title | date | status | total | actions
|
// Grid columns: thumbnail | number | title | date | status | total | actions
|
||||||
const GRID = "90px 120px minmax(0,1fr) 130px 130px 130px 120px";
|
// Delete button is a hover-only overlay — no dedicated column needed
|
||||||
|
const GRID = "90px 120px minmax(0,1fr) 130px 130px 130px 90px";
|
||||||
|
|
||||||
|
function renderRow(q, isLegacy = false) {
|
||||||
|
const hasPdf = isLegacy ? !!q.legacy_pdf_path : !!q.nextcloud_pdf_url;
|
||||||
|
const displayDate = isLegacy ? (q.legacy_date || fmtDate(q.created_at)) : fmtDate(q.created_at);
|
||||||
|
const isDeleting = deleting === q.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div
|
||||||
{/* PDF Preview Modal */}
|
key={q.id}
|
||||||
{pdfPreview && (
|
className="quotation-row"
|
||||||
<PdfViewModal
|
|
||||||
quotationId={pdfPreview.id}
|
|
||||||
quotationNumber={pdfPreview.number}
|
|
||||||
onClose={() => setPdfPreview(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
|
|
||||||
<h2 style={{ fontSize: 15, fontWeight: 600, color: "var(--text-heading)" }}>Quotations</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(`/crm/quotations/new?customer_id=${customerId}`)}
|
|
||||||
style={{
|
style={{
|
||||||
padding: "7px 16px", fontSize: 13, fontWeight: 600, borderRadius: 6,
|
position: "relative",
|
||||||
border: "none", cursor: "pointer", backgroundColor: "var(--accent)", color: "#fff",
|
display: "grid",
|
||||||
|
gridTemplateColumns: GRID,
|
||||||
|
gap: 12,
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderBottom: "1px solid var(--border-secondary)",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: 110,
|
||||||
|
backgroundColor: "var(--bg-card)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.backgroundColor = "var(--bg-card)"}
|
||||||
|
>
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div>
|
||||||
|
{hasPdf ? (
|
||||||
|
<PdfThumbnail quotationId={q.id} onClick={() => openPdfModal(q)} />
|
||||||
|
) : (
|
||||||
|
<DraftThumbnail />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Number */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
<span style={{ fontFamily: "monospace", fontSize: 13, fontWeight: 600, color: "var(--text-primary)" }}>
|
||||||
|
{q.quotation_number}
|
||||||
|
</span>
|
||||||
|
{isLegacy && (
|
||||||
|
<span style={{
|
||||||
|
display: "inline-block", padding: "1px 6px", borderRadius: 4,
|
||||||
|
fontSize: 9, fontWeight: 700, letterSpacing: "0.08em",
|
||||||
|
backgroundColor: "var(--badge-orange-bg, #7c3a0020)", color: "var(--badge-orange-text, #c0621e)",
|
||||||
|
textTransform: "uppercase", alignSelf: "flex-start",
|
||||||
|
}}>
|
||||||
|
LEGACY
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div style={{ overflow: "hidden", paddingRight: 8 }}>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 600, color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{q.title || <span style={{ color: "var(--text-muted)", fontStyle: "italic", fontWeight: 400 }}>Untitled</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<div style={{ fontSize: 12, color: "var(--text-secondary)", textAlign: "center" }}>
|
||||||
|
{displayDate}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status badge */}
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
<span style={{
|
||||||
|
display: "inline-block", padding: "2px 10px", borderRadius: 20,
|
||||||
|
fontSize: 11, fontWeight: 600,
|
||||||
|
backgroundColor: STATUS_STYLES[q.status]?.bg || "var(--bg-card-hover)",
|
||||||
|
color: STATUS_STYLES[q.status]?.color || "var(--text-secondary)",
|
||||||
|
}}>
|
||||||
|
{q.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total */}
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 600, color: "var(--success-text)", textAlign: "right", paddingRight: 16 }}>
|
||||||
|
{fmt(q.final_total)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions (Edit / Send / Gen PDF — no Delete here) */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 5, alignItems: "stretch" }}>
|
||||||
|
{isLegacy ? (
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingLegacy(q); setShowLegacyModal(true); }}
|
||||||
|
style={{ padding: "4px 10px", fontSize: 11, fontWeight: 500, borderRadius: 5, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--text-secondary)", whiteSpace: "nowrap", textAlign: "center" }}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!q.nextcloud_pdf_url && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRegenerate(q)}
|
||||||
|
disabled={regenerating === q.id}
|
||||||
|
style={{ padding: "4px 10px", fontSize: 11, fontWeight: 500, borderRadius: 5, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--text-muted)", whiteSpace: "nowrap", textAlign: "center" }}
|
||||||
|
>
|
||||||
|
{regenerating === q.id ? "..." : "Gen PDF"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/crm/quotations/${q.id}`)}
|
||||||
|
style={{ padding: "4px 10px", fontSize: 11, fontWeight: 500, borderRadius: 5, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--text-secondary)", whiteSpace: "nowrap", textAlign: "center" }}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSend && onSend(q)}
|
||||||
|
style={{ padding: "4px 10px", fontSize: 11, fontWeight: 500, borderRadius: 5, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--accent)", whiteSpace: "nowrap", textAlign: "center" }}
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete — hover-only overlay in top-right corner */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(q)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="quotation-delete-btn"
|
||||||
|
style={{
|
||||||
|
position: "absolute", top: 8, right: 10,
|
||||||
|
padding: "3px 10px", fontSize: 11, fontWeight: 600, borderRadius: 5,
|
||||||
|
border: "1px solid rgba(239,68,68,0.4)",
|
||||||
|
backgroundColor: "rgba(239,68,68,0.08)", color: "var(--danger-text)",
|
||||||
|
cursor: "pointer", opacity: 0, transition: "opacity 0.15s",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
+ New Quotation
|
{isDeleting ? "..." : "Delete"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{loading && (
|
function TableHeader() {
|
||||||
<div style={{ textAlign: "center", padding: 40, color: "var(--text-muted)", fontSize: 13 }}>Loading...</div>
|
return (
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && quotations.length === 0 && (
|
|
||||||
<div style={{ textAlign: "center", padding: "40px 20px", backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)", borderRadius: 8 }}>
|
|
||||||
<div style={{ fontSize: 32, marginBottom: 10 }}>📄</div>
|
|
||||||
<div style={{ fontSize: 14, fontWeight: 500, color: "var(--text-heading)", marginBottom: 6 }}>No quotations yet</div>
|
|
||||||
<div style={{ fontSize: 13, color: "var(--text-muted)", marginBottom: 16 }}>
|
|
||||||
Create a quotation to generate a professional PDF offer for this customer.
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(`/crm/quotations/new?customer_id=${customerId}`)}
|
|
||||||
style={{ padding: "8px 20px", fontSize: 13, fontWeight: 600, borderRadius: 6, border: "none", cursor: "pointer", backgroundColor: "var(--accent)", color: "#fff" }}
|
|
||||||
>
|
|
||||||
+ New Quotation
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && quotations.length > 0 && (
|
|
||||||
<div style={{ borderRadius: 8, border: "1px solid var(--border-primary)", overflow: "hidden" }}>
|
|
||||||
{/* Table header */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: GRID,
|
gridTemplateColumns: GRID,
|
||||||
@@ -329,108 +585,104 @@ export default function QuotationList({ customerId, onSend }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* Rows */}
|
return (
|
||||||
{quotations.map(q => (
|
|
||||||
<div
|
|
||||||
key={q.id}
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: GRID,
|
|
||||||
gap: 12,
|
|
||||||
padding: "12px 16px",
|
|
||||||
borderBottom: "1px solid var(--border-secondary)",
|
|
||||||
alignItems: "center",
|
|
||||||
minHeight: 110,
|
|
||||||
backgroundColor: "var(--bg-card)",
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.backgroundColor = "var(--bg-card)"}
|
|
||||||
>
|
|
||||||
{/* Thumbnail — click opens modal if PDF exists */}
|
|
||||||
<div>
|
<div>
|
||||||
{q.nextcloud_pdf_url ? (
|
{/* PDF Preview Modal */}
|
||||||
<PdfThumbnail quotationId={q.id} onClick={() => openPdfModal(q)} />
|
{pdfPreview && (
|
||||||
) : (
|
<PdfViewModal
|
||||||
<DraftThumbnail />
|
quotationId={pdfPreview.id}
|
||||||
|
quotationNumber={pdfPreview.number}
|
||||||
|
onClose={() => setPdfPreview(null)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Legacy Quotation Modal */}
|
||||||
|
{showLegacyModal && (
|
||||||
|
<LegacyQuotationModal
|
||||||
|
customerId={customerId}
|
||||||
|
existing={editingLegacy}
|
||||||
|
onClose={() => { setShowLegacyModal(false); setEditingLegacy(null); }}
|
||||||
|
onSaved={load}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
|
||||||
|
<h2 style={{ fontSize: 15, fontWeight: 600, color: "var(--text-heading)" }}>Quotations</h2>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingLegacy(null); setShowLegacyModal(true); }}
|
||||||
|
style={{
|
||||||
|
padding: "7px 14px", fontSize: 13, fontWeight: 600, borderRadius: 6,
|
||||||
|
border: "1px solid var(--border-primary)", cursor: "pointer",
|
||||||
|
backgroundColor: "transparent", color: "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Legacy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/crm/quotations/new?customer_id=${customerId}`)}
|
||||||
|
style={{
|
||||||
|
padding: "7px 16px", fontSize: 13, fontWeight: 600, borderRadius: 6,
|
||||||
|
border: "none", cursor: "pointer", backgroundColor: "var(--accent)", color: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ New Quotation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Number */}
|
{loading && (
|
||||||
<div style={{ fontFamily: "monospace", fontSize: 13, fontWeight: 600, color: "var(--text-primary)" }}>
|
<div style={{ textAlign: "center", padding: 40, color: "var(--text-muted)", fontSize: 13 }}>Loading...</div>
|
||||||
{q.quotation_number}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title + subtitle */}
|
{!loading && regular.length === 0 && legacy.length === 0 && (
|
||||||
<div style={{ overflow: "hidden", paddingRight: 8 }}>
|
<div style={{ textAlign: "center", padding: "40px 20px", backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)", borderRadius: 8 }}>
|
||||||
<div style={{ fontSize: 16, fontWeight: 600, color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
<div style={{ fontSize: 32, marginBottom: 10 }}>📄</div>
|
||||||
{q.title || <span style={{ color: "var(--text-muted)", fontStyle: "italic", fontWeight: 400 }}>Untitled</span>}
|
<div style={{ fontSize: 14, fontWeight: 500, color: "var(--text-heading)", marginBottom: 6 }}>No quotations yet</div>
|
||||||
|
<div style={{ fontSize: 13, color: "var(--text-muted)", marginBottom: 16 }}>
|
||||||
|
Create a quotation to generate a professional PDF offer for this customer.
|
||||||
</div>
|
</div>
|
||||||
{q.subtitle && (
|
<button
|
||||||
<div style={{ fontSize: 12, color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", marginTop: 2 }}>
|
onClick={() => navigate(`/crm/quotations/new?customer_id=${customerId}`)}
|
||||||
{q.subtitle}
|
style={{ padding: "8px 20px", fontSize: 13, fontWeight: 600, borderRadius: 6, border: "none", cursor: "pointer", backgroundColor: "var(--accent)", color: "#fff" }}
|
||||||
|
>
|
||||||
|
+ New Quotation
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date */}
|
{/* Regular quotations table */}
|
||||||
<div style={{ fontSize: 12, color: "var(--text-secondary)", textAlign: "center" }}>
|
{!loading && regular.length > 0 && (
|
||||||
{fmtDate(q.created_at)}
|
<div style={{ borderRadius: 8, border: "1px solid var(--border-primary)", overflow: "hidden", marginBottom: legacy.length > 0 ? 24 : 0 }}>
|
||||||
|
<TableHeader />
|
||||||
|
{regular.map(q => renderRow(q, false))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Status badge */}
|
{/* Legacy quotations section — only shown if any exist */}
|
||||||
<div style={{ textAlign: "center" }}>
|
{!loading && legacy.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 10, marginTop: regular.length > 0 ? 0 : 0 }}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 700, color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.06em" }}>
|
||||||
|
Legacy Quotations
|
||||||
|
</span>
|
||||||
<span style={{
|
<span style={{
|
||||||
display: "inline-block", padding: "2px 10px", borderRadius: 20,
|
display: "inline-block", padding: "1px 8px", borderRadius: 10,
|
||||||
fontSize: 11, fontWeight: 600,
|
fontSize: 11, fontWeight: 600,
|
||||||
backgroundColor: STATUS_STYLES[q.status]?.bg || "var(--bg-card-hover)",
|
backgroundColor: "var(--badge-orange-bg, rgba(124,58,0,0.12))",
|
||||||
color: STATUS_STYLES[q.status]?.color || "var(--text-secondary)",
|
color: "var(--badge-orange-text, #c0621e)",
|
||||||
}}>
|
}}>
|
||||||
{q.status}
|
{legacy.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ borderRadius: 8, border: "1px solid var(--border-primary)", overflow: "hidden" }}>
|
||||||
{/* Total */}
|
<TableHeader />
|
||||||
<div style={{ fontSize: 16, fontWeight: 600, color: "var(--success-text)", textAlign: "right", paddingRight: 16 }}>
|
{legacy.map(q => renderRow(q, true))}
|
||||||
{fmt(q.final_total)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions — Edit + Delete same width; Gen PDF if no PDF yet */}
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 5, alignItems: "stretch", paddingLeft: 25, paddingRight: 25 }}>
|
|
||||||
{!q.nextcloud_pdf_url && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleRegenerate(q)}
|
|
||||||
disabled={regenerating === q.id}
|
|
||||||
style={{ padding: "4px 10px", fontSize: 11, fontWeight: 500, borderRadius: 5, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--text-muted)", whiteSpace: "nowrap", textAlign: "center" }}
|
|
||||||
>
|
|
||||||
{regenerating === q.id ? "..." : "Gen PDF"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(`/crm/quotations/${q.id}`)}
|
|
||||||
style={{ padding: "4px 10px", fontSize: 11, fontWeight: 500, borderRadius: 5, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--text-secondary)", whiteSpace: "nowrap", textAlign: "center" }}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => onSend && onSend(q)}
|
|
||||||
style={{ padding: "4px 10px", fontSize: 11, fontWeight: 500, borderRadius: 5, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--accent)", whiteSpace: "nowrap", textAlign: "center" }}
|
|
||||||
>
|
|
||||||
Send
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(q)}
|
|
||||||
disabled={deleting === q.id}
|
|
||||||
style={{ padding: "4px 10px", fontSize: 11, fontWeight: 500, borderRadius: 5, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--danger-text)", whiteSpace: "nowrap", textAlign: "center" }}
|
|
||||||
>
|
|
||||||
{deleting === q.id ? "..." : "Delete"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export { default as QuotationForm } from "./QuotationForm";
|
export { default as QuotationForm } from "./QuotationForm";
|
||||||
export { default as QuotationList } from "./QuotationList";
|
export { default as QuotationList } from "./QuotationList";
|
||||||
|
export { default as AllQuotationsList } from "./AllQuotationsList";
|
||||||
|
|||||||
@@ -1050,7 +1050,7 @@ export default function ApiReferencePage() {
|
|||||||
<div className="flex items-start justify-between gap-4 mb-1">
|
<div className="flex items-start justify-between gap-4 mb-1">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
|
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
|
||||||
Firmware API Reference
|
API Reference
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
|
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
|
||||||
Vesper command bus — v2 protocol · {totalCommands} commands across {NAMESPACES.length} namespaces
|
Vesper command bus — v2 protocol · {totalCommands} commands across {NAMESPACES.length} namespaces
|
||||||
|
|||||||
@@ -1626,14 +1626,21 @@ export default function DeviceDetail() {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [d, mqttData] = await Promise.all([api.get(`/devices/${id}`), api.get("/mqtt/status").catch(() => null)]);
|
// Phase 1: load device from DB immediately — renders page without waiting for MQTT
|
||||||
|
const d = await api.get(`/devices/${id}`);
|
||||||
setDevice(d);
|
setDevice(d);
|
||||||
if (d.staffNotes) setStaffNotes(d.staffNotes);
|
if (d.staffNotes) setStaffNotes(d.staffNotes);
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
if (mqttData?.devices && d.device_id) {
|
// Phase 2: fire async background fetches — do not block the render
|
||||||
|
if (d.device_id) {
|
||||||
|
api.get("/mqtt/status").then((mqttData) => {
|
||||||
|
if (mqttData?.devices) {
|
||||||
const match = mqttData.devices.find((s) => s.device_serial === d.device_id);
|
const match = mqttData.devices.find((s) => s.device_serial === d.device_id);
|
||||||
setMqttStatus(match || null);
|
setMqttStatus(match || null);
|
||||||
}
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
setUsersLoading(true);
|
setUsersLoading(true);
|
||||||
api.get(`/devices/${id}/users`).then((data) => {
|
api.get(`/devices/${id}/users`).then((data) => {
|
||||||
|
|||||||
@@ -137,13 +137,13 @@ export default function DeviceList() {
|
|||||||
if (onlineFilter === "false") params.set("online", "false");
|
if (onlineFilter === "false") params.set("online", "false");
|
||||||
if (tierFilter) params.set("tier", tierFilter);
|
if (tierFilter) params.set("tier", tierFilter);
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
const [data, mqttData] = await Promise.all([
|
// Phase 1: load devices from DB immediately
|
||||||
api.get(`/devices${qs ? `?${qs}` : ""}`),
|
const data = await api.get(`/devices${qs ? `?${qs}` : ""}`);
|
||||||
api.get("/mqtt/status").catch(() => null),
|
|
||||||
]);
|
|
||||||
setDevices(data.devices);
|
setDevices(data.devices);
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
// Build MQTT status lookup by device serial
|
// Phase 2: fetch MQTT status in background and update online indicators
|
||||||
|
api.get("/mqtt/status").then((mqttData) => {
|
||||||
if (mqttData?.devices) {
|
if (mqttData?.devices) {
|
||||||
const map = {};
|
const map = {};
|
||||||
for (const s of mqttData.devices) {
|
for (const s of mqttData.devices) {
|
||||||
@@ -151,6 +151,7 @@ export default function DeviceList() {
|
|||||||
}
|
}
|
||||||
setMqttStatusMap(map);
|
setMqttStatusMap(map);
|
||||||
}
|
}
|
||||||
|
}).catch(() => {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -289,7 +290,7 @@ export default function DeviceList() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Devices</h1>
|
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Device Fleet</h1>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate("/devices/new")}
|
onClick={() => navigate("/devices/new")}
|
||||||
|
|||||||