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_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 0000000..cb6fcac Binary files /dev/null and b/frontend/public/horizontal-logo-dark-console.png differ diff --git a/frontend/public/horizontal-logo-dark.png b/frontend/public/horizontal-logo-dark.png new file mode 100644 index 0000000..f80e356 Binary files /dev/null and b/frontend/public/horizontal-logo-dark.png differ diff --git a/frontend/public/horizontal-logo-light.png b/frontend/public/horizontal-logo-light.png new file mode 100644 index 0000000..7c969cd Binary files /dev/null and b/frontend/public/horizontal-logo-light.png differ 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
+