From 6f9fd5cba3ef09596cd6ba20588f3af89295be5e Mon Sep 17 00:00:00 2001 From: bonamin Date: Sun, 8 Mar 2026 22:30:56 +0200 Subject: [PATCH] fix: Bugs created after the overhaul, performance and layout fixes --- .env.example | 2 +- .gitignore | 5 + AUTOMATION_ENGINE_STRATEGY.md | 395 +++++ backend/Dockerfile | 4 +- backend/builder/database.py | 2 +- backend/config.py | 4 +- backend/crm/customers_router.py | 69 +- backend/crm/email_sync.py | 2 +- backend/crm/models.py | 23 +- backend/crm/nextcloud.py | 15 + backend/crm/nextcloud_router.py | 241 ++- backend/crm/quotation_models.py | 17 + backend/crm/quotations_router.py | 22 +- backend/crm/quotations_service.py | 88 +- backend/crm/service.py | 160 +- backend/crm/thumbnails.py | 125 ++ backend/database/__init__.py | 39 + .../{mqtt/database.py => database/core.py} | 12 +- backend/devices/router.py | 2 +- backend/main.py | 8 +- backend/manufacturing/audit.py | 2 +- backend/melodies/database.py | 2 +- backend/mqtt/logger.py | 2 +- backend/mqtt/router.py | 2 +- backend/mqtt_data.db | Bin 700416 -> 0 bytes backend/requirements.txt | 4 +- backend/templates/quotation.html | 4 +- docker-compose.yml | 2 +- frontend/package-lock.json | 436 ++++- frontend/package.json | 3 +- .../public/horizontal-logo-dark-console.png | Bin 0 -> 25528 bytes frontend/public/horizontal-logo-dark.png | Bin 0 -> 16838 bytes frontend/public/horizontal-logo-light.png | Bin 0 -> 14520 bytes frontend/src/App.jsx | 3 +- frontend/src/assets/global-icons/delete.svg | 2 + frontend/src/assets/global-icons/download.svg | 6 + frontend/src/assets/global-icons/edit.svg | 24 + frontend/src/assets/global-icons/expand.svg | 4 + .../src/assets/global-icons/nextcloud.svg | 4 + frontend/src/assets/global-icons/refresh.svg | 4 + frontend/src/assets/global-icons/reply.svg | 18 + frontend/src/assets/global-icons/video.svg | 19 + frontend/src/assets/global-icons/waveform.svg | 2 + frontend/src/assets/other-icons/important.svg | 17 + frontend/src/assets/other-icons/issues.svg | 23 + .../src/assets/other-icons/negotiations.svg | 2 + .../assets/side-menu-icons/activity-log.svg | 6 + frontend/src/assets/side-menu-icons/api.svg | 2 + .../src/assets/side-menu-icons/app-users.svg | 28 + .../src/assets/side-menu-icons/archetypes.svg | 17 + .../src/assets/side-menu-icons/blackbox.svg | 5 + .../side-menu-icons/communications-log.svg | 3 + .../assets/side-menu-icons/communications.svg | 3 + .../src/assets/side-menu-icons/composer.svg | 4 + frontend/src/assets/side-menu-icons/crm.svg | 36 + .../side-menu-icons/customer-overview.svg | 3 + .../src/assets/side-menu-icons/customers.svg | 3 + .../src/assets/side-menu-icons/dashboard.svg | 9 + .../side-menu-icons/device-inventory.svg | 22 + .../side-menu-icons/device-overview.svg | 3 + .../src/assets/side-menu-icons/devices.svg | 4 + .../src/assets/side-menu-icons/firmware.svg | 4 + frontend/src/assets/side-menu-icons/fleet.svg | 3 + .../src/assets/side-menu-icons/helpdesk.svg | 4 + .../src/assets/side-menu-icons/issues.svg | 4 + frontend/src/assets/side-menu-icons/mail.svg | 4 + .../assets/side-menu-icons/manufacturing.svg | 2 + .../side-menu-icons/melodies-editor.svg | 2 + .../src/assets/side-menu-icons/melodies.svg | 5 + .../side-menu-icons/melody-settings.svg | 12 + .../assets/side-menu-icons/mqtt-commands.svg | 4 + .../src/assets/side-menu-icons/mqtt-logs.svg | 7 + frontend/src/assets/side-menu-icons/mqtt.svg | 2 + .../src/assets/side-menu-icons/orders.svg | 4 + .../side-menu-icons/product-catalog.svg | 5 + .../src/assets/side-menu-icons/products.svg | 12 + .../src/assets/side-menu-icons/provision.svg | 5 + .../src/assets/side-menu-icons/quotations.svg | 33 + .../src/assets/side-menu-icons/settings.svg | 4 + frontend/src/assets/side-menu-icons/sms.svg | 23 + .../src/assets/side-menu-icons/sn-manager.svg | 6 + .../assets/side-menu-icons/staff-notes.svg | 3 + frontend/src/assets/side-menu-icons/staff.svg | 34 + .../src/assets/side-menu-icons/whatsapp.svg | 3 + .../src/crm/components/ComposeEmailModal.jsx | 135 +- frontend/src/crm/customers/CustomerDetail.jsx | 1422 +++++++++++++---- frontend/src/crm/customers/CustomerForm.jsx | 202 ++- frontend/src/crm/customers/CustomerList.jsx | 718 ++++++++- frontend/src/crm/inbox/CommsPage.jsx | 276 +++- frontend/src/crm/mail/MailPage.jsx | 2 +- frontend/src/crm/orders/OrderList.jsx | 2 +- frontend/src/crm/products/ProductForm.jsx | 72 +- frontend/src/crm/products/ProductList.jsx | 2 +- .../src/crm/quotations/AllQuotationsList.jsx | 414 +++++ frontend/src/crm/quotations/QuotationForm.jsx | 54 +- frontend/src/crm/quotations/QuotationList.jsx | 558 +++++-- frontend/src/crm/quotations/index.js | 1 + frontend/src/developer/ApiReferencePage.jsx | 2 +- frontend/src/devices/DeviceDetail.jsx | 15 +- frontend/src/devices/DeviceList.jsx | 25 +- frontend/src/equipment/NoteList.jsx | 2 +- frontend/src/firmware/FirmwareManager.jsx | 2 +- frontend/src/index.css | 21 +- frontend/src/layout/Header.jsx | 24 +- frontend/src/layout/MainLayout.jsx | 8 +- frontend/src/layout/Sidebar.jsx | 456 ++++-- .../src/manufacturing/DeviceInventory.jsx | 161 +- frontend/src/melodies/MelodyList.jsx | 2 +- .../src/melodies/archetypes/ArchetypeList.jsx | 2 +- frontend/src/users/UserList.jsx | 2 +- frontend/vite.config.js | 3 +- nginx/nginx.conf | 5 +- 112 files changed, 5771 insertions(+), 970 deletions(-) create mode 100644 AUTOMATION_ENGINE_STRATEGY.md create mode 100644 backend/crm/thumbnails.py create mode 100644 backend/database/__init__.py rename backend/{mqtt/database.py => database/core.py} (96%) delete mode 100644 backend/mqtt_data.db create mode 100644 frontend/public/horizontal-logo-dark-console.png create mode 100644 frontend/public/horizontal-logo-dark.png create mode 100644 frontend/public/horizontal-logo-light.png create mode 100644 frontend/src/assets/global-icons/delete.svg create mode 100644 frontend/src/assets/global-icons/download.svg create mode 100644 frontend/src/assets/global-icons/edit.svg create mode 100644 frontend/src/assets/global-icons/expand.svg create mode 100644 frontend/src/assets/global-icons/nextcloud.svg create mode 100644 frontend/src/assets/global-icons/refresh.svg create mode 100644 frontend/src/assets/global-icons/reply.svg create mode 100644 frontend/src/assets/global-icons/video.svg create mode 100644 frontend/src/assets/global-icons/waveform.svg create mode 100644 frontend/src/assets/other-icons/important.svg create mode 100644 frontend/src/assets/other-icons/issues.svg create mode 100644 frontend/src/assets/other-icons/negotiations.svg create mode 100644 frontend/src/assets/side-menu-icons/activity-log.svg create mode 100644 frontend/src/assets/side-menu-icons/api.svg create mode 100644 frontend/src/assets/side-menu-icons/app-users.svg create mode 100644 frontend/src/assets/side-menu-icons/archetypes.svg create mode 100644 frontend/src/assets/side-menu-icons/blackbox.svg create mode 100644 frontend/src/assets/side-menu-icons/communications-log.svg create mode 100644 frontend/src/assets/side-menu-icons/communications.svg create mode 100644 frontend/src/assets/side-menu-icons/composer.svg create mode 100644 frontend/src/assets/side-menu-icons/crm.svg create mode 100644 frontend/src/assets/side-menu-icons/customer-overview.svg create mode 100644 frontend/src/assets/side-menu-icons/customers.svg create mode 100644 frontend/src/assets/side-menu-icons/dashboard.svg create mode 100644 frontend/src/assets/side-menu-icons/device-inventory.svg create mode 100644 frontend/src/assets/side-menu-icons/device-overview.svg create mode 100644 frontend/src/assets/side-menu-icons/devices.svg create mode 100644 frontend/src/assets/side-menu-icons/firmware.svg create mode 100644 frontend/src/assets/side-menu-icons/fleet.svg create mode 100644 frontend/src/assets/side-menu-icons/helpdesk.svg create mode 100644 frontend/src/assets/side-menu-icons/issues.svg create mode 100644 frontend/src/assets/side-menu-icons/mail.svg create mode 100644 frontend/src/assets/side-menu-icons/manufacturing.svg create mode 100644 frontend/src/assets/side-menu-icons/melodies-editor.svg create mode 100644 frontend/src/assets/side-menu-icons/melodies.svg create mode 100644 frontend/src/assets/side-menu-icons/melody-settings.svg create mode 100644 frontend/src/assets/side-menu-icons/mqtt-commands.svg create mode 100644 frontend/src/assets/side-menu-icons/mqtt-logs.svg create mode 100644 frontend/src/assets/side-menu-icons/mqtt.svg create mode 100644 frontend/src/assets/side-menu-icons/orders.svg create mode 100644 frontend/src/assets/side-menu-icons/product-catalog.svg create mode 100644 frontend/src/assets/side-menu-icons/products.svg create mode 100644 frontend/src/assets/side-menu-icons/provision.svg create mode 100644 frontend/src/assets/side-menu-icons/quotations.svg create mode 100644 frontend/src/assets/side-menu-icons/settings.svg create mode 100644 frontend/src/assets/side-menu-icons/sms.svg create mode 100644 frontend/src/assets/side-menu-icons/sn-manager.svg create mode 100644 frontend/src/assets/side-menu-icons/staff-notes.svg create mode 100644 frontend/src/assets/side-menu-icons/staff.svg create mode 100644 frontend/src/assets/side-menu-icons/whatsapp.svg create mode 100644 frontend/src/crm/quotations/AllQuotationsList.jsx diff --git a/.env.example b/.env.example index 84b0234..134fdf3 100644 --- a/.env.example +++ b/.env.example @@ -25,7 +25,7 @@ DEBUG=true NGINX_PORT=80 # 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 FIRMWARE_STORAGE_PATH=./storage/firmware diff --git a/.gitignore b/.gitignore index 9c0f49b..77877d1 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,11 @@ firebase-service-account.json !/data/.gitkeep !/data/built_melodies/.gitkeep +# SQLite databases +*.db +*.db-shm +*.db-wal + # Python __pycache__/ *.pyc diff --git a/AUTOMATION_ENGINE_STRATEGY.md b/AUTOMATION_ENGINE_STRATEGY.md new file mode 100644 index 0000000..767d885 --- /dev/null +++ b/AUTOMATION_ENGINE_STRATEGY.md @@ -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`. diff --git a/backend/Dockerfile b/backend/Dockerfile index 9f0ae20..e3f5b44 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,6 +1,6 @@ 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 \ libpango-1.0-0 \ libpangocairo-1.0-0 \ @@ -8,6 +8,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libffi-dev \ shared-mime-info \ fonts-dejavu-core \ + ffmpeg \ + poppler-utils \ && apt-get clean && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/backend/builder/database.py b/backend/builder/database.py index d07d157..e2678c3 100644 --- a/backend/builder/database.py +++ b/backend/builder/database.py @@ -1,6 +1,6 @@ import json import logging -from mqtt.database import get_db +from database import get_db logger = logging.getLogger("builder.database") diff --git a/backend/config.py b/backend/config.py index 2ab92c5..604ead7 100644 --- a/backend/config.py +++ b/backend/config.py @@ -22,8 +22,8 @@ class Settings(BaseSettings): mosquitto_password_file: str = "/etc/mosquitto/passwd" mqtt_client_id: str = "bellsystems-admin-panel" - # SQLite (MQTT data storage) - sqlite_db_path: str = "./mqtt_data.db" + # SQLite (local application database) + sqlite_db_path: str = "./data/database.db" mqtt_data_retention_days: int = 90 # Local file storage diff --git a/backend/crm/customers_router.py b/backend/crm/customers_router.py index ade091f..100a2fd 100644 --- a/backend/crm/customers_router.py +++ b/backend/crm/customers_router.py @@ -1,6 +1,6 @@ import asyncio import logging -from fastapi import APIRouter, Depends, Query, BackgroundTasks +from fastapi import APIRouter, Depends, Query, BackgroundTasks, Body from typing import Optional from auth.models import TokenPayload @@ -14,15 +14,25 @@ logger = logging.getLogger(__name__) @router.get("", response_model=CustomerListResponse) -def list_customers( +async def list_customers( search: Optional[str] = Query(None), tag: Optional[str] = Query(None), + sort: Optional[str] = Query(None), _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)) +@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) def get_customer( customer_id: str, @@ -64,8 +74,57 @@ def update_customer( @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, _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} diff --git a/backend/crm/email_sync.py b/backend/crm/email_sync.py index 1829557..7280ccd 100644 --- a/backend/crm/email_sync.py +++ b/backend/crm/email_sync.py @@ -23,7 +23,7 @@ from email import encoders from typing import List, Optional, Tuple 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 logger = logging.getLogger("crm.email_sync") diff --git a/backend/crm/models.py b/backend/crm/models.py index 06fdb15..72ae30d 100644 --- a/backend/crm/models.py +++ b/backend/crm/models.py @@ -35,6 +35,10 @@ class ProductCreate(BaseModel): sku: Optional[str] = None category: ProductCategory 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 currency: str = "EUR" costs: Optional[ProductCosts] = None @@ -49,6 +53,10 @@ class ProductUpdate(BaseModel): sku: Optional[str] = None category: Optional[ProductCategory] = 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 currency: Optional[str] = None costs: Optional[ProductCosts] = None @@ -114,9 +122,11 @@ class OwnedItem(BaseModel): class CustomerLocation(BaseModel): + address: Optional[str] = None city: Optional[str] = None - country: Optional[str] = None + postal_code: Optional[str] = None region: Optional[str] = None + country: Optional[str] = None class CustomerCreate(BaseModel): @@ -124,6 +134,7 @@ class CustomerCreate(BaseModel): name: str surname: Optional[str] = None organization: Optional[str] = None + religion: Optional[str] = None contacts: List[CustomerContact] = [] notes: List[CustomerNote] = [] location: Optional[CustomerLocation] = None @@ -133,6 +144,8 @@ class CustomerCreate(BaseModel): linked_user_ids: List[str] = [] nextcloud_folder: Optional[str] = None 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): @@ -140,6 +153,7 @@ class CustomerUpdate(BaseModel): name: Optional[str] = None surname: Optional[str] = None organization: Optional[str] = None + religion: Optional[str] = None contacts: Optional[List[CustomerContact]] = None notes: Optional[List[CustomerNote]] = None location: Optional[CustomerLocation] = None @@ -148,6 +162,8 @@ class CustomerUpdate(BaseModel): owned_items: Optional[List[OwnedItem]] = None linked_user_ids: Optional[List[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 @@ -286,8 +302,11 @@ class CommCreate(BaseModel): class CommUpdate(BaseModel): + type: Optional[CommType] = None + direction: Optional[CommDirection] = None subject: Optional[str] = None body: Optional[str] = None + logged_by: Optional[str] = None occurred_at: Optional[str] = None @@ -333,6 +352,7 @@ class MediaCreate(BaseModel): direction: Optional[MediaDirection] = None tags: List[str] = [] uploaded_by: Optional[str] = None + thumbnail_path: Optional[str] = None class MediaInDB(BaseModel): @@ -346,6 +366,7 @@ class MediaInDB(BaseModel): tags: List[str] = [] uploaded_by: Optional[str] = None created_at: str + thumbnail_path: Optional[str] = None class MediaListResponse(BaseModel): diff --git a/backend/crm/nextcloud.py b/backend/crm/nextcloud.py index da67e0c..4dd2959 100644 --- a/backend/crm/nextcloud.py +++ b/backend/crm/nextcloud.py @@ -312,3 +312,18 @@ async def delete_file(relative_path: str) -> None: resp = await client.request("DELETE", url, auth=_auth()) if resp.status_code not in (200, 204, 404): 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}") diff --git a/backend/crm/nextcloud_router.py b/backend/crm/nextcloud_router.py index b1e8876..3ad3848 100644 --- a/backend/crm/nextcloud_router.py +++ b/backend/crm/nextcloud_router.py @@ -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). """ from fastapi import APIRouter, Depends, Query, UploadFile, File, Form, Response, HTTPException, Request +from fastapi.responses import StreamingResponse from typing import Optional from jose import JWTError @@ -17,7 +18,9 @@ from auth.models import TokenPayload from auth.dependencies import require_permission from auth.utils import decode_access_token from crm import nextcloud, service +from config import settings from crm.models import MediaCreate, MediaDirection +from crm.thumbnails import generate_thumbnail 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") async def browse( 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) + # 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 for item in all_files: parts = item["path"].split("/") @@ -84,33 +118,54 @@ async def proxy_file( except (JWTError, KeyError): raise HTTPException(status_code=403, detail="Invalid token") - content, mime_type = await nextcloud.download_file(path) - total = len(content) - + # Forward the Range header to Nextcloud so we get a true partial response + # 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") - if range_header and range_header.startswith("bytes="): - # Parse "bytes=start-end" - 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 + if range_header: + forward_headers["Range"] = range_header - return Response( - content=content, + import httpx as _httpx + + # 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, - 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" 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 resolved_direction = None if direction: @@ -184,6 +257,7 @@ async def upload_file( direction=resolved_direction, tags=tag_list, uploaded_by=_user.name, + thumbnail_path=thumb_path, )) return media_record @@ -244,6 +318,11 @@ async def sync_nextcloud_files( # Collect all NC files recursively (handles nested folders at any depth) 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: parts = item["path"].split("/") item["_subfolder"] = parts[2] if len(parts) > 2 else "media" @@ -274,6 +353,105 @@ async def sync_nextcloud_files( 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") async def untrack_deleted_files( customer_id: str = Form(...), @@ -287,15 +465,22 @@ async def untrack_deleted_files( nc_path = service.get_customer_nc_path(customer) 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) - 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) untracked = 0 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: await service.delete_media(m.id) untracked += 1 diff --git a/backend/crm/quotation_models.py b/backend/crm/quotation_models.py index 74e54f3..380f5b1 100644 --- a/backend/crm/quotation_models.py +++ b/backend/crm/quotation_models.py @@ -13,6 +13,8 @@ class QuotationStatus(str, Enum): class QuotationItemCreate(BaseModel): product_id: 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_cost: float = 0.0 discount_percent: float = 0.0 @@ -52,6 +54,10 @@ class QuotationCreate(BaseModel): client_location: Optional[str] = None client_phone: 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): @@ -79,6 +85,10 @@ class QuotationUpdate(BaseModel): client_location: Optional[str] = None client_phone: 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): @@ -118,6 +128,10 @@ class QuotationInDB(BaseModel): client_location: Optional[str] = None client_phone: 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): @@ -130,6 +144,9 @@ class QuotationListItem(BaseModel): created_at: str updated_at: str nextcloud_pdf_url: Optional[str] = None + is_legacy: bool = False + legacy_date: Optional[str] = None + legacy_pdf_path: Optional[str] = None class QuotationListResponse(BaseModel): diff --git a/backend/crm/quotations_router.py b/backend/crm/quotations_router.py index fc23271..733d93a 100644 --- a/backend/crm/quotations_router.py +++ b/backend/crm/quotations_router.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, UploadFile, File from fastapi.responses import StreamingResponse from typing import Optional import io @@ -28,6 +28,14 @@ async def get_next_number( 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) async def list_quotations_for_customer( customer_id: str, @@ -99,3 +107,15 @@ async def regenerate_pdf( ): """Force PDF regeneration and re-upload to Nextcloud.""" 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) diff --git a/backend/crm/quotations_service.py b/backend/crm/quotations_service.py index 3525595..ae0c1e2 100644 --- a/backend/crm/quotations_service.py +++ b/backend/crm/quotations_service.py @@ -19,7 +19,7 @@ from crm.quotation_models import ( QuotationUpdate, ) from crm.service import get_customer -from mqtt import database as mqtt_db +import database as mqtt_db logger = logging.getLogger(__name__) @@ -153,10 +153,42 @@ async def get_next_number() -> str: 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]: 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 " + "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", (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, nextcloud_pdf_path, nextcloud_pdf_url, client_org, client_name, client_location, client_phone, client_email, + is_legacy, legacy_date, legacy_pdf_path, created_at, updated_at ) VALUES ( ?, ?, ?, ?, ?, @@ -220,6 +253,7 @@ async def create_quotation(data: QuotationCreate, generate_pdf: bool = False) -> ?, ?, ?, ?, ?, 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["new_subtotal"], totals["vat_amount"], totals["final_total"], 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, ), ) @@ -240,11 +275,12 @@ async def create_quotation(data: QuotationCreate, generate_pdf: bool = False) -> item_id = str(uuid.uuid4()) await db.execute( """INSERT INTO crm_quotation_items - (id, quotation_id, product_id, description, unit_type, unit_cost, - discount_percent, quantity, vat_percent, line_total, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (id, quotation_id, product_id, description, description_en, description_gr, + unit_type, unit_cost, discount_percent, quantity, vat_percent, line_total, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( 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("discount_percent", 0), item.get("quantity", 1), 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) - if generate_pdf: + if generate_pdf and not data.is_legacy: quotation = await _do_generate_and_upload_pdf(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", "install_cost_discount", "extras_label", "extras_cost", "client_org", "client_name", "client_location", "client_phone", "client_email", + "legacy_date", "legacy_pdf_path", ] 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()) await db.execute( """INSERT INTO crm_quotation_items - (id, quotation_id, product_id, description, unit_type, unit_cost, - discount_percent, quantity, vat_percent, line_total, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (id, quotation_id, product_id, description, description_en, description_gr, + unit_type, unit_cost, discount_percent, quantity, vat_percent, line_total, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( 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("discount_percent", 0), item.get("quantity", 1), 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.""" from fastapi import HTTPException quotation = await get_quotation(quotation_id) - if not quotation.nextcloud_pdf_path: - raise HTTPException(status_code=404, detail="No PDF generated for this quotation") - pdf_bytes, _ = await nextcloud.download_file(quotation.nextcloud_pdf_path) + # For legacy quotations, the PDF is at legacy_pdf_path + path = quotation.legacy_pdf_path if quotation.is_legacy else 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 + + +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) diff --git a/backend/crm/service.py b/backend/crm/service.py index 3db0522..5e7c5be 100644 --- a/backend/crm/service.py +++ b/backend/crm/service.py @@ -1,3 +1,4 @@ +import asyncio import json import uuid from datetime import datetime @@ -6,7 +7,7 @@ from fastapi import HTTPException from shared.firebase import get_db from shared.exceptions import NotFoundError import re as _re -from mqtt import database as mqtt_db +import database as mqtt_db from crm.models import ( ProductCreate, ProductUpdate, ProductInDB, CustomerCreate, CustomerUpdate, CustomerInDB, @@ -20,6 +21,11 @@ COLLECTION = "crm_products" def _doc_to_product(doc) -> ProductInDB: 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) @@ -128,6 +134,7 @@ def _doc_to_customer(doc) -> CustomerInDB: def list_customers( search: str | None = None, tag: str | None = None, + sort: str | None = None, ) -> list[CustomerInDB]: db = get_db() query = db.collection(CUSTOMERS_COLLECTION) @@ -141,28 +148,64 @@ def list_customers( if search: s = search.lower() + s_nospace = s.replace(" ", "") name_match = s in (customer.name or "").lower() surname_match = s in (customer.surname 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( - 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 []) ) - loc = customer.location or {} - loc_match = ( - s in (loc.get("city", "") or "").lower() or - s in (loc.get("country", "") or "").lower() or - s in (loc.get("region", "") or "").lower() + loc = customer.location + loc_match = bool(loc) and ( + s in (loc.address or "").lower() or + s in (loc.city or "").lower() or + 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 [])) - 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 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 +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: db = get_db() 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: + from google.cloud.firestore_v1 import DELETE_FIELD db = get_db() doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id) 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["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) updated_doc = doc_ref.get() 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() doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id) doc = doc_ref.get() if not doc.exists: raise NotFoundError("Customer") + customer = _doc_to_customer(doc) 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 ─────────────────────────────────────────────────────────────────── @@ -594,11 +730,11 @@ async def create_media(data: MediaCreate) -> MediaInDB: await db.execute( """INSERT INTO crm_media (id, customer_id, order_id, filename, nextcloud_path, mime_type, - direction, tags, uploaded_by, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + direction, tags, uploaded_by, thumbnail_path, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (media_id, data.customer_id, data.order_id, data.filename, 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() diff --git a/backend/crm/thumbnails.py b/backend/crm/thumbnails.py new file mode 100644 index 0000000..741cf97 --- /dev/null +++ b/backend/crm/thumbnails.py @@ -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 diff --git a/backend/database/__init__.py b/backend/database/__init__.py new file mode 100644 index 0000000..2c5707d --- /dev/null +++ b/backend/database/__init__.py @@ -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", +] diff --git a/backend/mqtt/database.py b/backend/database/core.py similarity index 96% rename from backend/mqtt/database.py rename to backend/database/core.py index d76aa82..9583561 100644 --- a/backend/mqtt/database.py +++ b/backend/database/core.py @@ -2,10 +2,11 @@ import aiosqlite import asyncio import json import logging +import os from datetime import datetime, timedelta, timezone from config import settings -logger = logging.getLogger("mqtt.database") +logger = logging.getLogger("database") _db: aiosqlite.Connection | None = None @@ -162,6 +163,8 @@ SCHEMA_STATEMENTS = [ quotation_id TEXT NOT NULL, product_id TEXT, description TEXT, + description_en TEXT, + description_gr TEXT, unit_type TEXT NOT NULL DEFAULT 'pcs', unit_cost REAL NOT NULL DEFAULT 0, discount_percent REAL NOT NULL DEFAULT 0, @@ -177,6 +180,7 @@ SCHEMA_STATEMENTS = [ async def init_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.row_factory = aiosqlite.Row 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_phone 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: try: diff --git a/backend/devices/router.py b/backend/devices/router.py index deddb10..d2129ac 100644 --- a/backend/devices/router.py +++ b/backend/devices/router.py @@ -7,7 +7,7 @@ from devices.models import ( DeviceUsersResponse, DeviceUserInfo, ) from devices import service -from mqtt import database as mqtt_db +import database as mqtt_db from mqtt.models import DeviceAlertEntry, DeviceAlertsResponse router = APIRouter(prefix="/api/devices", tags=["devices"]) diff --git a/backend/main.py b/backend/main.py index 65402ef..70dfbc4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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.mail_accounts import get_mail_accounts from mqtt.client import mqtt_manager -from mqtt import database as mqtt_db +import database as db from melodies import service as melody_service app = FastAPI( @@ -88,10 +88,10 @@ async def email_sync_loop(): @app.on_event("startup") async def startup(): init_firebase() - await mqtt_db.init_db() + await db.init_db() await melody_service.migrate_from_firestore() 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()) sync_accounts = [a for a in get_mail_accounts() if a.get("sync_inbound") and a.get("imap_host")] if sync_accounts: @@ -104,7 +104,7 @@ async def startup(): @app.on_event("shutdown") async def shutdown(): mqtt_manager.stop() - await mqtt_db.close_db() + await db.close_db() await close_nextcloud_client() diff --git a/backend/manufacturing/audit.py b/backend/manufacturing/audit.py index 1173c2c..46ce43c 100644 --- a/backend/manufacturing/audit.py +++ b/backend/manufacturing/audit.py @@ -1,6 +1,6 @@ import json import logging -from mqtt.database import get_db +from database import get_db logger = logging.getLogger("manufacturing.audit") diff --git a/backend/melodies/database.py b/backend/melodies/database.py index a45afde..4f80437 100644 --- a/backend/melodies/database.py +++ b/backend/melodies/database.py @@ -1,6 +1,6 @@ import json import logging -from mqtt.database import get_db +from database import get_db logger = logging.getLogger("melodies.database") diff --git a/backend/mqtt/logger.py b/backend/mqtt/logger.py index a4860ba..302e922 100644 --- a/backend/mqtt/logger.py +++ b/backend/mqtt/logger.py @@ -1,5 +1,5 @@ import logging -from mqtt import database as db +import database as db logger = logging.getLogger("mqtt.logger") diff --git a/backend/mqtt/router.py b/backend/mqtt/router.py index 52720ef..b1b885f 100644 --- a/backend/mqtt/router.py +++ b/backend/mqtt/router.py @@ -8,7 +8,7 @@ from mqtt.models import ( CommandListResponse, HeartbeatEntry, ) from mqtt.client import mqtt_manager -from mqtt import database as db +import database as db from datetime import datetime, timezone router = APIRouter(prefix="/api/mqtt", tags=["mqtt"]) diff --git a/backend/mqtt_data.db b/backend/mqtt_data.db deleted file mode 100644 index 113c724f7edf80fb2e9f47dd03648316a4a31e4c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 700416 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⪙2;o%! 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.27.0 weasyprint>=62.0 -jinja2>=3.1.0 \ No newline at end of file +jinja2>=3.1.0 +Pillow>=10.0.0 +pdf2image>=1.17.0 \ No newline at end of file diff --git a/backend/templates/quotation.html b/backend/templates/quotation.html index 268a6e9..1b952a7 100644 --- a/backend/templates/quotation.html +++ b/backend/templates/quotation.html @@ -464,7 +464,7 @@
{{ L_CLIENT }}
- {% if customer.organization %}{% endif %}{% set name_parts = [customer.title, customer.name, customer.surname] | select | list %}{% if name_parts %}{% endif %}{% if customer.location %}{% set loc_parts = [customer.location.city, customer.location.region, customer.location.country] | select | list %}{% if loc_parts %}{% endif %}{% endif %}{% if customer_email %}{% endif %}{% if customer_phone %}{% endif %}
{{ L_ORG }}{{ customer.organization }}
{{ L_CONTACT }}{{ name_parts | join(' ') }}
{{ L_ADDRESS }}{{ loc_parts | join(', ') }}
Email{{ customer_email }}
{{ L_PHONE }}{{ customer_phone }}
+ {% if customer.organization %}{% endif %}{% set name_parts = [customer.title, customer.name, customer.surname] | select | list %}{% if name_parts %}{% endif %}{% if quotation.client_location %}{% 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 %}{% endif %}{% endif %}{% if customer_email %}{% endif %}{% if customer_phone %}{% endif %}
{{ L_ORG }}{{ customer.organization }}
{{ L_CONTACT }}{{ name_parts | join(' ') }}
{{ L_ADDRESS }}{{ quotation.client_location }}
{{ L_ADDRESS }}{{ loc_parts | join(', ') }}
Email{{ customer_email }}
{{ L_PHONE }}{{ customer_phone }}
@@ -490,7 +490,7 @@ {% for item in quotation.items %} - {{ item.description or '' }} + {% if lang == 'gr' %}{{ item.description_gr or item.description or '' }}{% else %}{{ item.description_en or item.description or '' }}{% endif %} {{ item.unit_cost | format_money }} {% if item.discount_percent and item.discount_percent > 0 %} diff --git a/docker-compose.yml b/docker-compose.yml index ed00efc..d1e5f34 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: volumes: - ./backend:/app # 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/firmware:/app/storage/firmware - ./data/firebase-service-account.json:/app/firebase-service-account.json:ro diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 494b33b..93aa77e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -26,7 +26,8 @@ "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", "tailwindcss": "^4.1.18", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vite-plugin-svgr": "^4.5.0" } }, "node_modules/@babel/code-frame": { @@ -1030,6 +1031,29 @@ "dev": true, "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": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -1380,6 +1404,231 @@ "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": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", @@ -1893,6 +2142,19 @@ "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": { "version": "1.0.30001770", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", @@ -1978,6 +2240,33 @@ "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": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2035,6 +2324,17 @@ "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": { "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", @@ -2056,6 +2356,29 @@ "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": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -2306,6 +2629,13 @@ "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": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2528,6 +2858,13 @@ "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": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2608,6 +2945,13 @@ "dev": true, "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": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -2926,6 +3270,13 @@ "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": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2949,6 +3300,16 @@ "dev": true, "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": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3015,6 +3376,17 @@ "dev": true, "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": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -3091,6 +3463,25 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3111,6 +3502,16 @@ "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3363,6 +3764,17 @@ "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": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3399,6 +3811,13 @@ "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": { "version": "4.1.18", "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1978053..9b27d82 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,7 @@ "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", "tailwindcss": "^4.1.18", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vite-plugin-svgr": "^4.5.0" } } diff --git a/frontend/public/horizontal-logo-dark-console.png b/frontend/public/horizontal-logo-dark-console.png new file mode 100644 index 0000000000000000000000000000000000000000..cb6fcac031fe06289ea5de529c89f88fc33cae6f GIT binary patch literal 25528 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

ai8z=E|@&XGiW`lX8g=MHk|(d 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 V2p1iqSGE8E002ovPDHLkV1lQ#qUZns literal 0 HcmV?d00001 diff --git a/frontend/public/horizontal-logo-light.png b/frontend/public/horizontal-logo-light.png new file mode 100644 index 0000000000000000000000000000000000000000..7c969cde489fc7da2065290c114c004c7573bd65 GIT binary patch literal 14520 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 VXIn2G>{UcK6$MTCdRfbe{|7uG38Vl3 literal 0 HcmV?d00001 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d002253..d5fdb05 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -34,7 +34,7 @@ import ApiReferencePage from "./developer/ApiReferencePage"; import { ProductList, ProductForm } from "./crm/products"; import { CustomerList, CustomerForm, CustomerDetail } from "./crm/customers"; 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 MailPage from "./crm/mail/MailPage"; @@ -174,6 +174,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/assets/global-icons/delete.svg b/frontend/src/assets/global-icons/delete.svg new file mode 100644 index 0000000..329236c --- /dev/null +++ b/frontend/src/assets/global-icons/delete.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/frontend/src/assets/global-icons/download.svg b/frontend/src/assets/global-icons/download.svg new file mode 100644 index 0000000..868c698 --- /dev/null +++ b/frontend/src/assets/global-icons/download.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/global-icons/edit.svg b/frontend/src/assets/global-icons/edit.svg new file mode 100644 index 0000000..fda12e4 --- /dev/null +++ b/frontend/src/assets/global-icons/edit.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/global-icons/expand.svg b/frontend/src/assets/global-icons/expand.svg new file mode 100644 index 0000000..fd8761f --- /dev/null +++ b/frontend/src/assets/global-icons/expand.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/assets/global-icons/nextcloud.svg b/frontend/src/assets/global-icons/nextcloud.svg new file mode 100644 index 0000000..69a3b98 --- /dev/null +++ b/frontend/src/assets/global-icons/nextcloud.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/assets/global-icons/refresh.svg b/frontend/src/assets/global-icons/refresh.svg new file mode 100644 index 0000000..272a879 --- /dev/null +++ b/frontend/src/assets/global-icons/refresh.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/assets/global-icons/reply.svg b/frontend/src/assets/global-icons/reply.svg new file mode 100644 index 0000000..c4e18f7 --- /dev/null +++ b/frontend/src/assets/global-icons/reply.svg @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/global-icons/video.svg b/frontend/src/assets/global-icons/video.svg new file mode 100644 index 0000000..89e55ff --- /dev/null +++ b/frontend/src/assets/global-icons/video.svg @@ -0,0 +1,19 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/global-icons/waveform.svg b/frontend/src/assets/global-icons/waveform.svg new file mode 100644 index 0000000..2ba72f1 --- /dev/null +++ b/frontend/src/assets/global-icons/waveform.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/frontend/src/assets/other-icons/important.svg b/frontend/src/assets/other-icons/important.svg new file mode 100644 index 0000000..87223d8 --- /dev/null +++ b/frontend/src/assets/other-icons/important.svg @@ -0,0 +1,17 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/other-icons/issues.svg b/frontend/src/assets/other-icons/issues.svg new file mode 100644 index 0000000..3ce103f --- /dev/null +++ b/frontend/src/assets/other-icons/issues.svg @@ -0,0 +1,23 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/other-icons/negotiations.svg b/frontend/src/assets/other-icons/negotiations.svg new file mode 100644 index 0000000..a6f4509 --- /dev/null +++ b/frontend/src/assets/other-icons/negotiations.svg @@ -0,0 +1,2 @@ + +handshake \ No newline at end of file diff --git a/frontend/src/assets/side-menu-icons/activity-log.svg b/frontend/src/assets/side-menu-icons/activity-log.svg new file mode 100644 index 0000000..f06747d --- /dev/null +++ b/frontend/src/assets/side-menu-icons/activity-log.svg @@ -0,0 +1,6 @@ + + + +logs + + \ No newline at end of file diff --git a/frontend/src/assets/side-menu-icons/api.svg b/frontend/src/assets/side-menu-icons/api.svg new file mode 100644 index 0000000..6b52d97 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/api.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/frontend/src/assets/side-menu-icons/app-users.svg b/frontend/src/assets/side-menu-icons/app-users.svg new file mode 100644 index 0000000..dffe376 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/app-users.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/side-menu-icons/archetypes.svg b/frontend/src/assets/side-menu-icons/archetypes.svg new file mode 100644 index 0000000..8dbed83 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/archetypes.svg @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/side-menu-icons/blackbox.svg b/frontend/src/assets/side-menu-icons/blackbox.svg new file mode 100644 index 0000000..8b62d66 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/blackbox.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/assets/side-menu-icons/communications-log.svg b/frontend/src/assets/side-menu-icons/communications-log.svg new file mode 100644 index 0000000..7142cc8 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/communications-log.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/side-menu-icons/communications.svg b/frontend/src/assets/side-menu-icons/communications.svg new file mode 100644 index 0000000..393b199 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/communications.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/side-menu-icons/composer.svg b/frontend/src/assets/side-menu-icons/composer.svg new file mode 100644 index 0000000..462337a --- /dev/null +++ b/frontend/src/assets/side-menu-icons/composer.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/assets/side-menu-icons/crm.svg b/frontend/src/assets/side-menu-icons/crm.svg new file mode 100644 index 0000000..0240f1f --- /dev/null +++ b/frontend/src/assets/side-menu-icons/crm.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/side-menu-icons/customer-overview.svg b/frontend/src/assets/side-menu-icons/customer-overview.svg new file mode 100644 index 0000000..586b4a7 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/customer-overview.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/side-menu-icons/customers.svg b/frontend/src/assets/side-menu-icons/customers.svg new file mode 100644 index 0000000..c38d16f --- /dev/null +++ b/frontend/src/assets/side-menu-icons/customers.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/src/assets/side-menu-icons/dashboard.svg b/frontend/src/assets/side-menu-icons/dashboard.svg new file mode 100644 index 0000000..65589ab --- /dev/null +++ b/frontend/src/assets/side-menu-icons/dashboard.svg @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/side-menu-icons/device-inventory.svg b/frontend/src/assets/side-menu-icons/device-inventory.svg new file mode 100644 index 0000000..1c33fc5 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/device-inventory.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/side-menu-icons/device-overview.svg b/frontend/src/assets/side-menu-icons/device-overview.svg new file mode 100644 index 0000000..71f5db3 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/device-overview.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/side-menu-icons/devices.svg b/frontend/src/assets/side-menu-icons/devices.svg new file mode 100644 index 0000000..784c662 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/devices.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/assets/side-menu-icons/firmware.svg b/frontend/src/assets/side-menu-icons/firmware.svg new file mode 100644 index 0000000..5557022 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/firmware.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/assets/side-menu-icons/fleet.svg b/frontend/src/assets/side-menu-icons/fleet.svg new file mode 100644 index 0000000..ee404f6 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/fleet.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/side-menu-icons/helpdesk.svg b/frontend/src/assets/side-menu-icons/helpdesk.svg new file mode 100644 index 0000000..e83655a --- /dev/null +++ b/frontend/src/assets/side-menu-icons/helpdesk.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/side-menu-icons/issues.svg b/frontend/src/assets/side-menu-icons/issues.svg new file mode 100644 index 0000000..67b03cc --- /dev/null +++ b/frontend/src/assets/side-menu-icons/issues.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/assets/side-menu-icons/mail.svg b/frontend/src/assets/side-menu-icons/mail.svg new file mode 100644 index 0000000..e3e6884 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/mail.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/assets/side-menu-icons/manufacturing.svg b/frontend/src/assets/side-menu-icons/manufacturing.svg new file mode 100644 index 0000000..5390c6e --- /dev/null +++ b/frontend/src/assets/side-menu-icons/manufacturing.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/frontend/src/assets/side-menu-icons/melodies-editor.svg b/frontend/src/assets/side-menu-icons/melodies-editor.svg new file mode 100644 index 0000000..e372953 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/melodies-editor.svg @@ -0,0 +1,2 @@ + + Sounds \ No newline at end of file diff --git a/frontend/src/assets/side-menu-icons/melodies.svg b/frontend/src/assets/side-menu-icons/melodies.svg new file mode 100644 index 0000000..69d8f71 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/melodies.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/assets/side-menu-icons/melody-settings.svg b/frontend/src/assets/side-menu-icons/melody-settings.svg new file mode 100644 index 0000000..0aa3fdc --- /dev/null +++ b/frontend/src/assets/side-menu-icons/melody-settings.svg @@ -0,0 +1,12 @@ + + + + ic_fluent_speaker_settings_24_filled + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/side-menu-icons/mqtt-commands.svg b/frontend/src/assets/side-menu-icons/mqtt-commands.svg new file mode 100644 index 0000000..0f61ba6 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/mqtt-commands.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/side-menu-icons/mqtt-logs.svg b/frontend/src/assets/side-menu-icons/mqtt-logs.svg new file mode 100644 index 0000000..6e0d2c5 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/mqtt-logs.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/src/assets/side-menu-icons/mqtt.svg b/frontend/src/assets/side-menu-icons/mqtt.svg new file mode 100644 index 0000000..824fc2d --- /dev/null +++ b/frontend/src/assets/side-menu-icons/mqtt.svg @@ -0,0 +1,2 @@ + +Eclipse Mosquitto icon diff --git a/frontend/src/assets/side-menu-icons/orders.svg b/frontend/src/assets/side-menu-icons/orders.svg new file mode 100644 index 0000000..7a7b4d4 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/orders.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/assets/side-menu-icons/product-catalog.svg b/frontend/src/assets/side-menu-icons/product-catalog.svg new file mode 100644 index 0000000..cf30224 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/product-catalog.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/assets/side-menu-icons/products.svg b/frontend/src/assets/side-menu-icons/products.svg new file mode 100644 index 0000000..69a39d8 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/products.svg @@ -0,0 +1,12 @@ + + + + product-management + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/side-menu-icons/provision.svg b/frontend/src/assets/side-menu-icons/provision.svg new file mode 100644 index 0000000..6010b70 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/provision.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/assets/side-menu-icons/quotations.svg b/frontend/src/assets/side-menu-icons/quotations.svg new file mode 100644 index 0000000..cb6c17d --- /dev/null +++ b/frontend/src/assets/side-menu-icons/quotations.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + diff --git a/frontend/src/assets/side-menu-icons/settings.svg b/frontend/src/assets/side-menu-icons/settings.svg new file mode 100644 index 0000000..eebb382 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/settings.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/assets/side-menu-icons/sms.svg b/frontend/src/assets/side-menu-icons/sms.svg new file mode 100644 index 0000000..42037aa --- /dev/null +++ b/frontend/src/assets/side-menu-icons/sms.svg @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/assets/side-menu-icons/sn-manager.svg b/frontend/src/assets/side-menu-icons/sn-manager.svg new file mode 100644 index 0000000..8ee24d9 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/sn-manager.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/assets/side-menu-icons/staff-notes.svg b/frontend/src/assets/side-menu-icons/staff-notes.svg new file mode 100644 index 0000000..fc9f011 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/staff-notes.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/side-menu-icons/staff.svg b/frontend/src/assets/side-menu-icons/staff.svg new file mode 100644 index 0000000..53230c8 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/staff.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/side-menu-icons/whatsapp.svg b/frontend/src/assets/side-menu-icons/whatsapp.svg new file mode 100644 index 0000000..3c843a7 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/whatsapp.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/crm/components/ComposeEmailModal.jsx b/frontend/src/crm/components/ComposeEmailModal.jsx index f151e2d..86e044c 100644 --- a/frontend/src/crm/components/ComposeEmailModal.jsx +++ b/frontend/src/crm/components/ComposeEmailModal.jsx @@ -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 ( +

e.currentTarget.querySelector("input")?.focus()} + > + {list.map((email) => ( + + {email} + + + ))} + 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", + }} + /> +
+ ); +} + // ── Main component ──────────────────────────────────────────────────────────── export default function ComposeEmailModal({ open, @@ -75,8 +144,10 @@ export default function ComposeEmailModal({ customerId = null, onSent, }) { - const [to, setTo] = useState(defaultTo); - const [cc, setCc] = useState(""); + const [toList, setToList] = useState(defaultTo ? [defaultTo] : []); + const [toInput, setToInput] = useState(""); + const [ccList, setCcList] = useState([]); + const [ccInput, setCcInput] = useState(""); const [subject, setSubject] = useState(defaultSubject); const [fromAccount, setFromAccount] = useState(defaultFromAccount || ""); const [mailAccounts, setMailAccounts] = useState([]); @@ -99,8 +170,10 @@ export default function ComposeEmailModal({ // Reset fields when opened useEffect(() => { if (open) { - setTo(defaultTo); - setCc(""); + setToList(defaultTo ? [defaultTo] : []); + setToInput(""); + setCcList([]); + setCcInput(""); setSubject(defaultSubject); setFromAccount(defaultFromAccount || ""); setAttachments([]); @@ -361,27 +434,35 @@ export default function ComposeEmailModal({ const handleSend = async () => { const { html, text } = getContent(); - const toClean = to.trim(); - const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(toClean); + // Commit any pending input in the TO/CC fields before validating + 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 (!to.trim()) { setError("Please enter a recipient email address."); return; } - if (!emailOk) { setError("Please enter a valid recipient email address."); return; } + if (finalToList.length === 0) { setError("Please enter a recipient email address."); return; } if (!subject.trim()) { setError("Please enter a subject."); 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(""); setSending(true); try { - const ccList = cc.split(",").map((s) => s.trim()).filter(Boolean); const token = localStorage.getItem("access_token"); const fd = new FormData(); if (customerId) fd.append("customer_id", customerId); if (fromAccount) fd.append("from_account", fromAccount); - fd.append("to", to.trim()); + fd.append("to", finalToList[0]); fd.append("subject", subject.trim()); fd.append("body", text); fd.append("body_html", html); - fd.append("cc", JSON.stringify(ccList)); + fd.append("cc", JSON.stringify(finalCcList)); for (const { file } of attachments) { fd.append("files", file, file.name); } @@ -478,23 +559,18 @@ export default function ComposeEmailModal({
- setTo(e.target.value)} +
- setCc(e.target.value)} - placeholder="cc1@example.com, cc2@..." +
@@ -598,6 +674,19 @@ export default function ComposeEmailModal({ > ✍ Add Signature + {(() => { + 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 ( + + {totalMB.toFixed(1)}/{ATTACH_LIMIT_MB} MB attached + {totalMB > ATTACH_LIMIT_MB && " — too large"} + + ); + })()} Tip: Paste images directly with Ctrl+V diff --git a/frontend/src/crm/customers/CustomerDetail.jsx b/frontend/src/crm/customers/CustomerDetail.jsx index 06fb654..ea10f9b 100644 --- a/frontend/src/crm/customers/CustomerDetail.jsx +++ b/frontend/src/crm/customers/CustomerDetail.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useRef } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import api from "../../api/client"; import { useAuth } from "../../auth/AuthContext"; import ComposeEmailModal from "../components/ComposeEmailModal"; @@ -7,6 +7,44 @@ import MailViewModal from "../components/MailViewModal"; import QuotationList from "../quotations/QuotationList"; import { CommTypeIconBadge, CommDirectionIcon } from "../components/CommIcons"; +// Inline SVG icons — all use currentColor +const IconExpand = ({ size = 13 }) => ; +const IconReply = () => ; +const IconEdit = () => ; +const IconDelete = ({ size = 13 }) => ; + +// Media-specific SVG icons (currentColor) +const IconRefresh = () => ( + + + +); +const IconVideo = ({ style }) => ( + + + +); +const IconWaveform = () => ( + + + +); + +const IconNextcloud = ({ size = 16 }) => ( + + + +); + + +const IconDownload = ({ size = 16 }) => ( + + + + + +); + const CONTACT_TYPE_ICONS = { email: "📧", phone: "📞", @@ -28,6 +66,7 @@ const COMM_TYPE_LABELS = { in_person: "in person", }; const COMM_DATE_FMT = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", year: "numeric" }); +const COMM_FULL_DATE_FMT = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "long", year: "numeric" }); const COMM_TIME_FMT = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", hour12: true }); function formatCommDate(value) { @@ -37,11 +76,32 @@ function formatCommDate(value) { return COMM_DATE_FMT.format(d); } -function formatCommDateTime(value) { +function formatRelativeTime(value) { if (!value) return ""; const d = new Date(value); 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 inputClass = "w-full px-3 py-2 text-sm rounded-md border"; @@ -356,6 +416,16 @@ function OverviewQuickSection({ title, onViewAll, empty, children }) { ); } +function formatFileSize(bytes) { + if (!bytes || bytes <= 0) return null; + const kb = bytes / 1024; + if (kb < 900) return `${Math.round(kb)} KB`; + const mb = kb / 1024; + if (mb < 900) return mb < 10 ? `${mb.toFixed(1)} MB` : `${Math.round(mb)} MB`; + const gb = mb / 1024; + return gb < 10 ? `${gb.toFixed(1)} GB` : `${Math.round(gb)} GB`; +} + function getCategoryMeta(subfolder) { const mapped = LEGACY_SUBFOLDER_MAP[subfolder] || subfolder; return MEDIA_CATEGORIES.find(c => c.value === mapped) || MEDIA_CATEGORIES.find(c => c.value === "documents"); @@ -372,6 +442,7 @@ const CATEGORY_GROUPS = [ export default function CustomerDetail() { const { id } = useParams(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const { user, hasPermission } = useAuth(); const canEdit = hasPermission("crm", "edit"); @@ -379,7 +450,15 @@ export default function CustomerDetail() { const [customer, setCustomer] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); - const [activeTab, setActiveTab] = useState("Overview"); + const [activeTab, setActiveTab] = useState(() => { + const tab = searchParams.get("tab"); + const TABS = ["Overview", "Orders", "Quotations", "Communication", "Files & Media", "Devices"]; + return TABS.includes(tab) ? tab : "Overview"; + }); + + // Status toggles + const [lastCommDirection, setLastCommDirection] = useState(null); + const [statusToggling, setStatusToggling] = useState(null); // "negotiating" | "problem" // Orders tab const [orders, setOrders] = useState([]); @@ -409,12 +488,17 @@ export default function CustomerDetail() { const [composeDefaultServerAttachments, setComposeDefaultServerAttachments] = useState([]); const [commsDeleteId, setCommsDeleteId] = useState(null); // id pending delete confirm const [commsDeleting, setCommsDeleting] = useState(false); + const [commsHoverId, setCommsHoverId] = useState(null); // id of hovered comm entry + const [commsEditId, setCommsEditId] = useState(null); // id being edited + const [commsEditForm, setCommsEditForm] = useState({}); // edit form state + const [commsEditSaving, setCommsEditSaving] = useState(false); // Media tab const [media, setMedia] = useState([]); const [mediaLoading, setMediaLoading] = useState(false); const [ncFiles, setNcFiles] = useState([]); // files browsed from Nextcloud const [ncBrowsing, setNcBrowsing] = useState(false); + const [ncThumbMapState, setNcThumbMapState] = useState(null); // null = not yet loaded const [showUpload, setShowUpload] = useState(false); const [showCreateTxt, setShowCreateTxt] = useState(false); const [createTxtName, setCreateTxtName] = useState("new-file.txt"); @@ -424,15 +508,29 @@ export default function CustomerDetail() { // Media tab — view/filter/sort/upload enhancements const [mediaView, setMediaView] = useState("tile"); // "tile" | "list" - const [mediaFilter, setMediaFilter] = useState("all"); + const [mediaFilter, setMediaFilter] = useState("all"); // legacy single filter (kept for group logic) + const [mediaFilterTypes, setMediaFilterTypes] = useState(new Set()); // multi-select category filters + const [mediaTypeFilter, setMediaTypeFilter] = useState("all"); // "all" | "photos" | "videos" + const [mediaFilterModalOpen, setMediaFilterModalOpen] = useState(false); const [mediaSort, setMediaSort] = useState("date_desc"); + const [mediaSearch, setMediaSearch] = useState(""); + const [mediaPageSize, setMediaPageSize] = useState(20); // default: 20 per page + const [mediaPage, setMediaPage] = useState(1); const [groupView, setGroupView] = useState(true); // group by category when filter=all + const previewListRef = useRef([]); // ordered list of previewable files for prev/next nav // Upload modal: each entry is { file: File, name: string, subfolder: string, tags: string[], tagInput: string, progress: null|number|"done"|"error" } const [uploadFiles, setUploadFiles] = useState([]); const [uploadDragging, setUploadDragging] = useState(false); const [uploadRunning, setUploadRunning] = useState(false); + // Upload modal bulk-set state + const [bulkSubfolder, setBulkSubfolder] = useState("media"); + const [bulkTagInput, setBulkTagInput] = useState(""); + const [bulkTags, setBulkTags] = useState([]); const [syncLoading, setSyncLoading] = useState(false); const [untrackLoading, setUntrackLoading] = useState(false); + const [thumbGenLoading, setThumbGenLoading] = useState(false); + const [thumbGenResult, setThumbGenResult] = useState(null); // { generated, skipped, failed } | null + const [mediaGearOpen, setMediaGearOpen] = useState(false); const [previewFile, setPreviewFile] = useState(null); // { path, mime_type, filename } | null const [selectedPaths, setSelectedPaths] = useState(new Set()); // multi-select const [selectionMenuOpen, setSelectionMenuOpen] = useState(false); @@ -467,6 +565,7 @@ export default function CustomerDetail() { const handler = (e) => { if (e.key !== "Escape") return; if (previewFile) { setPreviewFile(null); return; } + if (mediaFilterModalOpen) { setMediaFilterModalOpen(false); return; } if (showUpload) { setShowUpload(false); return; } if (showCreateTxt) { setShowCreateTxt(false); return; } if (showAddLinked) { setShowAddLinked(false); return; } @@ -479,7 +578,7 @@ export default function CustomerDetail() { }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [previewFile, showUpload, showCreateTxt, showAddLinked, showAddOwned, commsViewEntry, composeEmailOpen, commsDeleteId, showCommsForm, showEmailCompose]); + }, [previewFile, mediaFilterModalOpen, showUpload, showCreateTxt, showAddLinked, showAddOwned, commsViewEntry, composeEmailOpen, commsDeleteId, showCommsForm, showEmailCompose]); const loadCustomer = useCallback(() => { setLoading(true); @@ -494,6 +593,12 @@ export default function CustomerDetail() { loadCustomer(); }, [loadCustomer]); + useEffect(() => { + api.get(`/crm/customers/${id}/last-comm-direction`) + .then((res) => setLastCommDirection(res.direction || null)) + .catch(() => setLastCommDirection(null)); + }, [id]); + const loadOrders = useCallback(() => { setOrdersLoading(true); api.get(`/crm/orders?customer_id=${id}`) @@ -526,9 +631,33 @@ export default function CustomerDetail() { const browseNextcloud = useCallback(() => { setNcBrowsing(true); + setNcThumbMapState(null); // mark as loading api.get(`/crm/nextcloud/browse-all?customer_id=${id}`) - .then((data) => setNcFiles(data.items || [])) - .catch(() => setNcFiles([])) + .then((data) => { + const items = data.items || []; + setNcFiles(items); + // Build thumb map: original file path → thumb NC path. + // Thumbs are always stored as {stem}.jpg, so we match by stem + // (stripping the source file's own extension before comparing). + const thumbsByKey = {}; // "parentFolder|stem" → thumb path + for (const f of items) { + if (!f.path.includes("/.thumbs/")) continue; + const parentFolder = f.path.split("/.thumbs/")[0]; + const thumbFilename = f.path.split("/").pop(); + const stem = thumbFilename.replace(/\.[^.]+$/, ""); + thumbsByKey[`${parentFolder}|${stem}`] = f.path; + } + const thumbMap = {}; + for (const f of items) { + if (f.path.includes("/.thumbs/")) continue; + const parentFolder = f.path.split("/").slice(0, -1).join("/"); + const stem = f.path.split("/").pop().replace(/\.[^.]+$/, ""); + const thumbPath = thumbsByKey[`${parentFolder}|${stem}`]; + if (thumbPath) thumbMap[f.path] = thumbPath; + } + setNcThumbMapState(thumbMap); + }) + .catch(() => { setNcFiles([]); setNcThumbMapState({}); }) .finally(() => setNcBrowsing(false)); }, [id]); @@ -541,7 +670,7 @@ export default function CustomerDetail() { if (activeTab === "Overview") { loadOrders(); loadComms(); loadDevicesAndProducts(); loadLatestQuotations(); } if (activeTab === "Orders") loadOrders(); if (activeTab === "Communication") loadComms(); - if (activeTab === "Files & Media") { loadMedia(); browseNextcloud(); } + if (activeTab === "Files & Media") { setNcThumbMapState(null); loadMedia(); browseNextcloud(); } if (activeTab === "Devices") loadDevicesAndProducts(); }, [activeTab, loadOrders, loadComms, loadMedia, browseNextcloud, loadDevicesAndProducts, loadLatestQuotations]); @@ -569,6 +698,37 @@ export default function CustomerDetail() { } if (!customer) return null; + const handleToggleNegotiating = async () => { + setStatusToggling("negotiating"); + try { + const updated = await api.post(`/crm/customers/${id}/toggle-negotiating`); + setCustomer(updated); + // refresh direction + api.get(`/crm/customers/${id}/last-comm-direction`) + .then((res) => setLastCommDirection(res.direction || null)) + .catch(() => {}); + } catch (err) { + alert(err.message); + } finally { + setStatusToggling(null); + } + }; + + const handleToggleProblem = async () => { + setStatusToggling("problem"); + try { + const updated = await api.post(`/crm/customers/${id}/toggle-problem`); + setCustomer(updated); + api.get(`/crm/customers/${id}/last-comm-direction`) + .then((res) => setLastCommDirection(res.direction || null)) + .catch(() => {}); + } catch (err) { + alert(err.message); + } finally { + setStatusToggling(null); + } + }; + const handleAddComms = async () => { setCommsSaving(true); try { @@ -627,6 +787,38 @@ export default function CustomerDetail() { } }; + const startEditComm = (entry) => { + setCommsEditId(entry.id); + setCommsEditForm({ + 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 handleSaveEditComm = async () => { + setCommsEditSaving(true); + try { + const payload = {}; + if (commsEditForm.type) payload.type = commsEditForm.type; + if (commsEditForm.direction) payload.direction = commsEditForm.direction; + if (commsEditForm.subject !== undefined) payload.subject = commsEditForm.subject || null; + if (commsEditForm.body !== undefined) payload.body = commsEditForm.body || null; + if (commsEditForm.logged_by !== undefined) payload.logged_by = commsEditForm.logged_by || null; + if (commsEditForm.occurred_at) payload.occurred_at = new Date(commsEditForm.occurred_at).toISOString(); + await api.put(`/crm/comms/${commsEditId}`, payload); + setCommsEditId(null); + loadComms(); + } catch (err) { + alert(err.message || "Failed to save entry"); + } finally { + setCommsEditSaving(false); + } + }; + const addFilesToUploadQueue = (files) => { const entries = files.map(file => ({ file, @@ -751,6 +943,55 @@ export default function CustomerDetail() { } }; + const handleClearThumbs = async () => { + if (!window.confirm("Delete ALL thumbnails for this customer? They can be regenerated afterwards.")) return; + setMediaGearOpen(false); + try { + const formData = new FormData(); + formData.append("customer_id", id); + const token = localStorage.getItem("access_token"); + const resp = await fetch("/api/crm/nextcloud/clear-thumbs", { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || `Server error ${resp.status}`); + } + browseNextcloud(); + } catch (err) { + alert(`Clear thumbnails failed: ${err.message}`); + } + }; + + const handleGenerateThumbs = async () => { + setThumbGenLoading(true); + setThumbGenResult(null); + setMediaGearOpen(false); + try { + const formData = new FormData(); + formData.append("customer_id", id); + const token = localStorage.getItem("access_token"); + const resp = await fetch("/api/crm/nextcloud/generate-thumbs", { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || `Server error ${resp.status}`); + } + const result = await resp.json(); + setThumbGenResult(result); + browseNextcloud(); + } catch (err) { + alert(`Thumbnail generation failed: ${err.message}`); + } finally { + setThumbGenLoading(false); + } + }; + const handleDeleteMedia = async (mediaId, ncPath) => { if (!window.confirm("Delete this file from Nextcloud and remove the record?")) return; setMediaDeleting((prev) => ({ ...prev, [mediaId]: true })); @@ -816,7 +1057,7 @@ export default function CustomerDetail() { }); const loc = customer.location || {}; - const locationStr = [loc.city, loc.region, loc.country].filter(Boolean).join(", "); + const locationStr = [loc.address, loc.city, loc.postal_code, loc.region, loc.country].filter(Boolean).join(", "); return (
@@ -844,6 +1085,9 @@ export default function CustomerDetail() { )}
+ {/* Divider after header */} +
+ {/* Tabs */}
{TABS.map((tab) => ( @@ -867,74 +1111,158 @@ export default function CustomerDetail() { {/* Overview Tab */} {activeTab === "Overview" && (
- {/* Hero: Basic Info + Contacts + Notes — full width */} -
-

Basic Info

-
- - - - - -
+ {/* Hero: Basic Info card — 75/25 split */} +
- {(customer.tags || []).length > 0 && ( -
-
Tags
-
- {customer.tags.map((tag) => ( - - {tag} - - ))} -
+ {/* LEFT: all info */} +
+ {/* Row 1: Name / Org / Language / Religion */} +
+ + + +
- )} - {(customer.contacts || []).length > 0 && ( -
-
Contacts
-
- {customer.contacts.map((c, i) => ( -
- {CONTACT_TYPE_ICONS[c.type] || "🔗"} - {c.type}{c.label ? ` (${c.label})` : ""} - {c.value} - {c.primary && ( - - Primary - - )} -
- ))} + {/* Tags row */} + {(customer.tags || []).length > 0 && ( +
+
Tags
+
+ {customer.tags.map((tag) => ( + + {tag} + + ))} +
-
- )} + )} - {(customer.notes || []).length > 0 && ( + {/* Address row */} + {locationStr && ( +
+ +
+ )} + + {/* Contacts */} + {(customer.contacts || []).length > 0 && ( +
+
Contacts
+
+ {customer.contacts.map((c, i) => ( +
+
+ {CONTACT_TYPE_ICONS[c.type] || "🔗"} + {c.type}{c.label ? ` (${c.label})` : ""} + {c.value} + {c.primary && ( + + Primary + + )} +
+ {i < customer.contacts.length - 1 && ( + | + )} +
+ ))} +
+
+ )} + + {/* Notes */}
Notes
-
- {customer.notes.map((note, i) => ( -
-

{note.text}

-

- {note.by} · {note.at ? new Date(note.at).toLocaleDateString() : ""} -

-
- ))} -
+ {(customer.notes || []).length > 0 ? ( +
+ {customer.notes.map((note, i) => ( +
+

{note.text}

+

+ {note.by} · {note.at ? new Date(note.at).toLocaleDateString() : ""} +

+
+ ))} +
+ ) : ( +

No notes.

+ )}
- )} +
+ + {/* RIGHT: Status panel */} +
+
Status
+ + {/* Negotiations subsection */} +
+
Negotiations
+ {customer.negotiating && ( +
+ {lastCommDirection === "inbound" ? "Ongoing – Pending Reply" : "Ongoing"} +
+ )} + {canEdit && ( + + )} +
+ + {/* Issue subsection */} +
+
Issue
+ {customer.has_problem && ( +
+ {lastCommDirection === "outbound" ? "Unsolved – Pending Reply" : "Unsolved Issue"} +
+ )} + {canEdit && ( + + )} +
+
{/* Full-width: Latest Communications */} @@ -952,7 +1280,7 @@ export default function CustomerDetail() { return (
{ setActiveTab("Communication"); @@ -1311,9 +1639,13 @@ export default function CustomerDetail() { const hasBody = !!entry.body; const isEmail = entry.type === "email"; const isPendingDelete = commsDeleteId === entry.id; + const isEditing = commsEditId === entry.id; + const isHovered = commsHoverId === entry.id; return ( -
+
setCommsHoverId(entry.id)} + onMouseLeave={() => setCommsHoverId(null)}> {/* Type icon marker */}
@@ -1323,11 +1655,67 @@ export default function CustomerDetail() { className="rounded-lg border" style={{ backgroundColor: "var(--bg-card)", - borderColor: isPendingDelete ? "var(--danger)" : "var(--border-primary)", - cursor: hasBody ? "pointer" : "default", + borderColor: isPendingDelete ? "var(--danger)" : isEditing ? "var(--accent)" : "var(--border-primary)", + cursor: hasBody && !isEditing ? "pointer" : "default", + position: "relative", + overflow: "hidden", }} - onClick={() => hasBody && toggleComm(entry.id)} + onClick={() => !isEditing && hasBody && toggleComm(entry.id)} > + {/* Hover overlay: gradient + 3-col action panel (no layout shift) */} + {isHovered && !isPendingDelete && !isEditing && ( +
+
+ + {/* Col 1 — date info */} +
+ + {entry.direction === "inbound" ? "Received" : entry.direction === "outbound" ? "Sent" : "Logged"} via {COMM_TYPE_LABELS[entry.type] || entry.type} + + + {formatFullDateTime(entry.occurred_at)} + +
+ + {/* Divider */} +
+ + {/* Col 2 — Full View / Reply */} +
+ + +
+ + {/* Col 3 — Edit / Delete (canEdit only) */} + {canEdit && ( +
+ + +
+ )} + +
+
+ )} + {/* Header row - order: direction icon, subject */}
@@ -1337,25 +1725,9 @@ export default function CustomerDetail() { )}
- {/* Full View for email */} - {isEmail && ( - - )} - {formatCommDateTime(entry.occurred_at)} + {formatRelativeTime(entry.occurred_at)} - {hasBody && ( - - {isExpanded ? "▲" : "▼"} - - )}
@@ -1379,8 +1751,8 @@ export default function CustomerDetail() {
)} - {/* Footer — no top border (second line removed) */} - {(entry.logged_by || (entry.attachments?.length > 0) || isExpanded || isPendingDelete) && ( + {/* Footer */} + {(entry.logged_by || (entry.attachments?.length > 0) || isPendingDelete) && (
{entry.logged_by && ( by {entry.logged_by} @@ -1391,41 +1763,6 @@ export default function CustomerDetail() { )} - {/* Quick Reply for email */} - {isExpanded && isEmail && canEdit && ( - - )} - - {/* Delete button */} - {canEdit && !isPendingDelete && ( - - )} - {/* Delete confirmation */} {isPendingDelete && (
@@ -1453,6 +1790,67 @@ export default function CustomerDetail() { )}
)} + + {/* Inline edit form */} + {isEditing && ( +
e.stopPropagation()}> +
+
+
Type
+ +
+
+
Direction
+ +
+
+
Date & Time
+ setCommsEditForm(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)" }} /> +
+
+
+
Subject
+ setCommsEditForm(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)" }} /> +
+
+
Body
+