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 113c724..0000000 Binary files a/backend/mqtt_data.db and /dev/null differ diff --git a/backend/requirements.txt b/backend/requirements.txt index a61b4cc..badc513 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -12,4 +12,6 @@ aiosqlite==0.20.0 resend==2.10.0 httpx>=0.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_ORG }} | {{ customer.organization }} |
| {{ L_CONTACT }} | {{ name_parts | join(' ') }} |
| {{ L_ADDRESS }} | {{ loc_parts | join(', ') }} |
| {{ customer_email }} | |
| {{ L_PHONE }} | {{ customer_phone }} |
| {{ L_ORG }} | {{ customer.organization }} |
| {{ L_CONTACT }} | {{ name_parts | join(' ') }} |
| {{ L_ADDRESS }} | {{ quotation.client_location }} |
| {{ L_ADDRESS }} | {{ loc_parts | join(', ') }} |
| {{ customer_email }} | |
| {{ L_PHONE }} | {{ customer_phone }} |
{note.text}
-- {note.by} · {note.at ? new Date(note.at).toLocaleDateString() : ""} -
-{note.text}
++ {note.by} · {note.at ? new Date(note.at).toLocaleDateString() : ""} +
+No notes.
+ )}Are you sure? This cannot be undone.
++ Drag to reorder · Click to toggle +
+ {orderedIds.map((id) => { + const col = ALL_COLUMNS.find((c) => c.id === id); + if (!col) return null; + return ( +| Name | -Organization | -Location | -Phone | -Tags | + {visibleCols.map((col) => ( ++ {col.label} + | + ))} + {canEdit &&} | |
|---|---|---|---|---|---|---|---|
| - {[c.title, c.name, c.surname].filter(Boolean).join(" ")} + {filteredCustomers.map((c, index) => ( + | |||||||
|
+ |
- {c.organization || "—"} | -{locationStr || "—"} | -- {primaryContact(c, "email") || "—"} - | -- {primaryContact(c, "phone") || "—"} - | -
-
- {(c.tags || []).slice(0, 3).map((tag) => (
-
- {tag}
-
- ))}
- {(c.tags || []).length > 3 && (
-
- +{c.tags.length - 3}
-
- )}
-
- |
-
All customer communications across all channels
@@ -309,9 +393,14 @@ export default function CommsPage() { const customer = customers[entry.customer_id]; const isExpanded = expandedId === entry.id; const isEmail = entry.type === "email"; + const isHov = hoveredId === entry.id; + const isPendingDelete = deleteId === entry.id; + const isEditing = editId === entry.id; return ( -+ }}> {entry.body}
All synced emails
+ {quotations.length} quotation{quotations.length !== 1 ? "s" : ""} across all customers +
+ )} +Vesper command bus — v2 protocol · {totalCommands} commands across {NAMESPACES.length} namespaces diff --git a/frontend/src/devices/DeviceDetail.jsx b/frontend/src/devices/DeviceDetail.jsx index 43c3fb0..0adb59a 100644 --- a/frontend/src/devices/DeviceDetail.jsx +++ b/frontend/src/devices/DeviceDetail.jsx @@ -1626,13 +1626,20 @@ export default function DeviceDetail() { const loadData = async () => { setLoading(true); try { - const [d, mqttData] = await Promise.all([api.get(`/devices/${id}`), api.get("/mqtt/status").catch(() => null)]); + // Phase 1: load device from DB immediately — renders page without waiting for MQTT + const d = await api.get(`/devices/${id}`); setDevice(d); if (d.staffNotes) setStaffNotes(d.staffNotes); + setLoading(false); - if (mqttData?.devices && d.device_id) { - const match = mqttData.devices.find((s) => s.device_serial === d.device_id); - setMqttStatus(match || null); + // Phase 2: fire async background fetches — do not block the render + if (d.device_id) { + api.get("/mqtt/status").then((mqttData) => { + if (mqttData?.devices) { + const match = mqttData.devices.find((s) => s.device_serial === d.device_id); + setMqttStatus(match || null); + } + }).catch(() => {}); } setUsersLoading(true); diff --git a/frontend/src/devices/DeviceList.jsx b/frontend/src/devices/DeviceList.jsx index 2c7a04b..9fd2845 100644 --- a/frontend/src/devices/DeviceList.jsx +++ b/frontend/src/devices/DeviceList.jsx @@ -137,20 +137,21 @@ export default function DeviceList() { if (onlineFilter === "false") params.set("online", "false"); if (tierFilter) params.set("tier", tierFilter); const qs = params.toString(); - const [data, mqttData] = await Promise.all([ - api.get(`/devices${qs ? `?${qs}` : ""}`), - api.get("/mqtt/status").catch(() => null), - ]); + // Phase 1: load devices from DB immediately + const data = await api.get(`/devices${qs ? `?${qs}` : ""}`); setDevices(data.devices); + setLoading(false); - // Build MQTT status lookup by device serial - if (mqttData?.devices) { - const map = {}; - for (const s of mqttData.devices) { - map[s.device_serial] = s; + // Phase 2: fetch MQTT status in background and update online indicators + api.get("/mqtt/status").then((mqttData) => { + if (mqttData?.devices) { + const map = {}; + for (const s of mqttData.devices) { + map[s.device_serial] = s; + } + setMqttStatusMap(map); } - setMqttStatusMap(map); - } + }).catch(() => {}); } catch (err) { setError(err.message); } finally { @@ -289,7 +290,7 @@ export default function DeviceList() { return (
{firmware.length} version{firmware.length !== 1 ? "s" : ""} diff --git a/frontend/src/index.css b/frontend/src/index.css index f524bb9..3a678cc 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -3,7 +3,7 @@ /* BellSystems Dark Theme - Custom Properties */ :root { --bg-primary: #111827; - --bg-secondary: #1f2937; + --bg-secondary: rgba(31, 41, 55, 0.959); --bg-card: #1f2937; --bg-card-hover: #2a3544; --bg-input: #111827; @@ -49,6 +49,11 @@ --badge-blue-bg: #1e3a5f; --badge-blue-text: #63b3ed; + /* ── CRM status icon colours ── */ + --crm-status-warn: #f08c00; /* yellow-orange — we sent last msg */ + --crm-status-alert: #f76707; /* orange — client sent last */ + --crm-status-danger: #f34b4b; /* red — issue, client sent */ + /* ── Spacing tokens ── */ --section-padding: 2.25rem 2.5rem 2.25rem; --section-padding-compact: 1.25rem 1.5rem; @@ -1229,9 +1234,23 @@ input[type="range"]::-moz-range-thumb { 50% { opacity: 0.4; } } +/* Quotation row — show delete button on row hover */ +.quotation-row:hover .quotation-delete-btn { + opacity: 1 !important; +} + /* File input */ input[type="file"]::file-selector-button { background-color: var(--bg-card) !important; color: var(--accent) !important; border: 1px solid var(--border-primary) !important; } + +/* ── CRM status icon breathe animation ── */ +@keyframes crm-breathe { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.45; transform: scale(0.88); } +} +.crm-icon-breathe { + animation: crm-breathe 2.2s ease-in-out infinite; +} diff --git a/frontend/src/layout/Header.jsx b/frontend/src/layout/Header.jsx index 336e002..87fbc3c 100644 --- a/frontend/src/layout/Header.jsx +++ b/frontend/src/layout/Header.jsx @@ -168,15 +168,11 @@ function Breadcrumb() { const display = segments.slice(1); return ( -