Compare commits
1 Commits
8c15c932b6
...
b280d62ee5
| Author | SHA1 | Date | |
|---|---|---|---|
| b280d62ee5 |
153
.claude/backend-mqtt-alerts-prompt.md
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# Backend Task: Subscribe to Vesper MQTT Alert Topics
|
||||||
|
|
||||||
|
> Use this document as a prompt / task brief for implementing the backend side
|
||||||
|
> of the Vesper MQTT alert system. The firmware changes are complete.
|
||||||
|
> Full topic spec: `docs/reference/mqtt-events.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the firmware now publishes
|
||||||
|
|
||||||
|
The Vesper firmware (v155+) publishes on three status topics:
|
||||||
|
|
||||||
|
### 1. `vesper/{device_id}/status/heartbeat` (unchanged)
|
||||||
|
- Every 30 seconds, retained, QoS 1
|
||||||
|
- You already handle this — **no change needed** except: suppress any log entry / display update triggered by heartbeat arrival. Update `last_seen` silently. Only surface an event when the device goes *silent* (no heartbeat for 90s).
|
||||||
|
|
||||||
|
### 2. `vesper/{device_id}/status/alerts` (NEW)
|
||||||
|
- Published only when a subsystem state changes (HEALTHY → WARNING, WARNING → CRITICAL, etc.)
|
||||||
|
- QoS 1, not retained
|
||||||
|
- One message per state transition — not repeated until state changes again
|
||||||
|
|
||||||
|
**Alert payload:**
|
||||||
|
```json
|
||||||
|
{ "subsystem": "FileManager", "state": "WARNING", "msg": "ConfigManager health check failed" }
|
||||||
|
```
|
||||||
|
**Cleared payload (recovery):**
|
||||||
|
```json
|
||||||
|
{ "subsystem": "FileManager", "state": "CLEARED" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. `vesper/{device_id}/status/info` (NEW)
|
||||||
|
- Published on significant device state changes (playback start/stop, etc.)
|
||||||
|
- QoS 0, not retained
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "playback_started", "payload": { "melody_uid": "ABC123" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to implement in the backend (FastAPI + MQTT)
|
||||||
|
|
||||||
|
### Subscribe to new topics
|
||||||
|
|
||||||
|
Add to your MQTT subscription list:
|
||||||
|
```python
|
||||||
|
client.subscribe("vesper/+/status/alerts", qos=1)
|
||||||
|
client.subscribe("vesper/+/status/info", qos=0)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database model — active alerts per device
|
||||||
|
|
||||||
|
Create a table (or document) to store the current alert state per device:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE device_alerts (
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
subsystem TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL, -- WARNING | CRITICAL | FAILED
|
||||||
|
message TEXT,
|
||||||
|
updated_at TIMESTAMP NOT NULL,
|
||||||
|
PRIMARY KEY (device_id, subsystem)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Or equivalent in your ORM / MongoDB / Redis structure.
|
||||||
|
|
||||||
|
### MQTT message handler — alerts topic
|
||||||
|
|
||||||
|
```python
|
||||||
|
def on_alerts_message(device_id: str, payload: dict):
|
||||||
|
subsystem = payload["subsystem"]
|
||||||
|
state = payload["state"]
|
||||||
|
message = payload.get("msg", "")
|
||||||
|
|
||||||
|
if state == "CLEARED":
|
||||||
|
# Remove alert from active set
|
||||||
|
db.device_alerts.delete(device_id=device_id, subsystem=subsystem)
|
||||||
|
else:
|
||||||
|
# Upsert — create or update
|
||||||
|
db.device_alerts.upsert(
|
||||||
|
device_id = device_id,
|
||||||
|
subsystem = subsystem,
|
||||||
|
state = state,
|
||||||
|
message = message,
|
||||||
|
updated_at = now()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Optionally push a WebSocket event to the console UI
|
||||||
|
ws_broadcast(device_id, {"event": "alert_update", "subsystem": subsystem, "state": state})
|
||||||
|
```
|
||||||
|
|
||||||
|
### MQTT message handler — info topic
|
||||||
|
|
||||||
|
```python
|
||||||
|
def on_info_message(device_id: str, payload: dict):
|
||||||
|
event_type = payload["type"]
|
||||||
|
data = payload.get("payload", {})
|
||||||
|
|
||||||
|
# Store or forward as needed — e.g. update device playback state
|
||||||
|
if event_type == "playback_started":
|
||||||
|
db.devices.update(device_id, playback_active=True, melody_uid=data.get("melody_uid"))
|
||||||
|
elif event_type == "playback_stopped":
|
||||||
|
db.devices.update(device_id, playback_active=False, melody_uid=None)
|
||||||
|
```
|
||||||
|
|
||||||
|
### API endpoint — get active alerts for a device
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/devices/{device_id}/alerts
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the current active alert set (the upserted rows from the table above):
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "subsystem": "FileManager", "state": "WARNING", "message": "SD mount failed", "updated_at": "..." },
|
||||||
|
{ "subsystem": "TimeKeeper", "state": "WARNING", "message": "NTP sync failed", "updated_at": "..." }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
An empty array means the device is fully healthy (no active alerts).
|
||||||
|
|
||||||
|
### Console UI guidance
|
||||||
|
|
||||||
|
- Device list: show a coloured dot next to each device (green = no alerts, yellow = warnings, red = critical/failed). Update via WebSocket push.
|
||||||
|
- Device detail page: show an "Active Alerts" section that renders the alert set statically. Do not render a scrolling alert log — just the current state.
|
||||||
|
- When a `CLEARED` event arrives, remove the entry from the UI immediately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What NOT to do
|
||||||
|
|
||||||
|
- **Do not log every heartbeat** as a visible event. Heartbeats are internal housekeeping.
|
||||||
|
- **Do not poll the device** for health status — the device pushes on change.
|
||||||
|
- **Do not store alerts as an append-only log** — upsert by `(device_id, subsystem)`. The server holds the current state, not a history.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
1. Flash a device with firmware v155+
|
||||||
|
2. Subscribe manually:
|
||||||
|
```bash
|
||||||
|
mosquitto_sub -h <broker> -t "vesper/+/status/alerts" -v
|
||||||
|
mosquitto_sub -h <broker> -t "vesper/+/status/info" -v
|
||||||
|
```
|
||||||
|
3. Remove the SD card from the device — expect a `FileManager` `WARNING` alert within 5 minutes (next health check cycle), or trigger it immediately via:
|
||||||
|
```json
|
||||||
|
{ "v": 2, "cmd": "system.health" }
|
||||||
|
```
|
||||||
|
sent to `vesper/{device_id}/control`
|
||||||
|
4. Reinsert the SD card — expect a `FileManager` `CLEARED` alert on the next health check
|
||||||
243
.claude/crm-build-plan.md
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
# BellSystems CRM — Build Plan & Step Prompts
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A bespoke CRM module built directly into the existing BellSystems web console.
|
||||||
|
Stack: FastAPI backend (Firestore), React + Vite frontend.
|
||||||
|
No new auth — uses the existing JWT + permission system.
|
||||||
|
No file storage on VPS — all media lives on Nextcloud via WebDAV.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Summary
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- New module: `backend/crm/` with `models.py`, `service.py`, `router.py`
|
||||||
|
- Firestore collections: `crm_customers`, `crm_orders`, `crm_products`
|
||||||
|
- SQLite (existing `mqtt_data.db`) for comms_log (high-write, queryable)
|
||||||
|
- Router registered in `backend/main.py` as `/api/crm`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- New section: `frontend/src/crm/`
|
||||||
|
- Routes added to `frontend/src/App.jsx`
|
||||||
|
- Nav entries added to `frontend/src/layout/Sidebar.jsx`
|
||||||
|
|
||||||
|
### Integrations (later steps)
|
||||||
|
- Nextcloud: WebDAV via `httpx` in backend
|
||||||
|
- Email: IMAP (read) + SMTP (send) via `imaplib` / `smtplib`
|
||||||
|
- WhatsApp: Meta Cloud API webhook
|
||||||
|
- FreePBX: Asterisk AMI socket listener
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model Reference
|
||||||
|
|
||||||
|
### `crm_customers` (Firestore)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "auto",
|
||||||
|
"name": "Στέλιος Μπιμπης",
|
||||||
|
"organization": "Ενορία Αγ. Παρασκευής",
|
||||||
|
"contacts": [
|
||||||
|
{ "type": "email", "label": "personal", "value": "...", "primary": true },
|
||||||
|
{ "type": "phone", "label": "mobile", "value": "...", "primary": true }
|
||||||
|
],
|
||||||
|
"notes": [
|
||||||
|
{ "text": "...", "by": "user_name", "at": "ISO datetime" }
|
||||||
|
],
|
||||||
|
"location": { "city": "", "country": "", "region": "" },
|
||||||
|
"language": "el",
|
||||||
|
"tags": [],
|
||||||
|
"owned_items": [
|
||||||
|
{ "type": "console_device", "device_id": "UID", "label": "..." },
|
||||||
|
{ "type": "product", "product_id": "pid", "product_name": "...", "quantity": 1, "serial_numbers": [] },
|
||||||
|
{ "type": "freetext", "description": "...", "serial_number": "", "notes": "" }
|
||||||
|
],
|
||||||
|
"linked_user_ids": [],
|
||||||
|
"nextcloud_folder": "05_Customers/FOLDER_NAME",
|
||||||
|
"created_at": "ISO",
|
||||||
|
"updated_at": "ISO"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `crm_orders` (Firestore)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "auto",
|
||||||
|
"customer_id": "ref",
|
||||||
|
"order_number": "ORD-2026-001",
|
||||||
|
"status": "draft",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "console_device|product|freetext",
|
||||||
|
"product_id": "",
|
||||||
|
"product_name": "",
|
||||||
|
"description": "",
|
||||||
|
"quantity": 1,
|
||||||
|
"unit_price": 0.0,
|
||||||
|
"serial_numbers": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"subtotal": 0.0,
|
||||||
|
"discount": { "type": "percentage|fixed", "value": 0, "reason": "" },
|
||||||
|
"total_price": 0.0,
|
||||||
|
"currency": "EUR",
|
||||||
|
"shipping": {
|
||||||
|
"method": "",
|
||||||
|
"tracking_number": "",
|
||||||
|
"carrier": "",
|
||||||
|
"shipped_at": null,
|
||||||
|
"delivered_at": null,
|
||||||
|
"destination": ""
|
||||||
|
},
|
||||||
|
"payment_status": "pending",
|
||||||
|
"invoice_path": "",
|
||||||
|
"notes": "",
|
||||||
|
"created_at": "ISO",
|
||||||
|
"updated_at": "ISO"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `crm_products` (Firestore)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "auto",
|
||||||
|
"name": "Vesper Plus",
|
||||||
|
"sku": "VSP-001",
|
||||||
|
"category": "controller|striker|clock|part|repair_service",
|
||||||
|
"description": "",
|
||||||
|
"price": 0.0,
|
||||||
|
"currency": "EUR",
|
||||||
|
"costs": {
|
||||||
|
"pcb": 0.0, "components": 0.0, "enclosure": 0.0,
|
||||||
|
"labor_hours": 0, "labor_rate": 0.0, "shipping_in": 0.0,
|
||||||
|
"total": 0.0
|
||||||
|
},
|
||||||
|
"stock": { "on_hand": 0, "reserved": 0, "available": 0 },
|
||||||
|
"nextcloud_folder": "02_Products/FOLDER",
|
||||||
|
"linked_device_type": "",
|
||||||
|
"active": true,
|
||||||
|
"created_at": "ISO",
|
||||||
|
"updated_at": "ISO"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `crm_comms_log` (SQLite table — existing mqtt_data.db)
|
||||||
|
```sql
|
||||||
|
CREATE TABLE crm_comms_log (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
customer_id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL, -- email|whatsapp|call|sms|note|in_person
|
||||||
|
direction TEXT NOT NULL, -- inbound|outbound|internal
|
||||||
|
subject TEXT,
|
||||||
|
body TEXT,
|
||||||
|
attachments TEXT, -- JSON array of {filename, nextcloud_path}
|
||||||
|
ext_message_id TEXT, -- IMAP uid, WhatsApp msg id, AMI call id
|
||||||
|
logged_by TEXT,
|
||||||
|
occurred_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `crm_media` (SQLite table — existing mqtt_data.db)
|
||||||
|
```sql
|
||||||
|
CREATE TABLE crm_media (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
customer_id TEXT,
|
||||||
|
order_id TEXT,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
nextcloud_path TEXT NOT NULL,
|
||||||
|
mime_type TEXT,
|
||||||
|
direction TEXT, -- received|sent|internal
|
||||||
|
tags TEXT, -- JSON array
|
||||||
|
uploaded_by TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## IMPORTANT NOTES FOR ALL STEPS
|
||||||
|
|
||||||
|
- **Backend location**: `c:\development\bellsystems-cp\backend\`
|
||||||
|
- **Frontend location**: `c:\development\bellsystems-cp\frontend\`
|
||||||
|
- **Auth pattern**: All routes use `Depends(require_permission("crm", "view"))` or `"edit"`. Import from `auth.dependencies`.
|
||||||
|
- **Firestore pattern**: Use `from shared.firebase import get_db`. See `backend/devices/service.py` for reference patterns.
|
||||||
|
- **SQLite pattern**: Use `from mqtt import database as mqtt_db` — `mqtt_db.db` is the aiosqlite connection. See `backend/mqtt/database.py`.
|
||||||
|
- **Frontend auth**: `getAuthHeaders()` from `../api/auth` gives Bearer token headers. See any existing page for pattern.
|
||||||
|
- **Frontend routing**: Routes live in `frontend/src/App.jsx`. Sidebar nav in `frontend/src/layout/Sidebar.jsx`.
|
||||||
|
- **Token**: localStorage key is `"access_token"`.
|
||||||
|
- **UI pattern**: Use existing component style — `SectionCard`, `FieldRow`, inline styles for grids. See `frontend/src/devices/` for reference.
|
||||||
|
- **No new dependencies unless absolutely necessary.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — Backend: CRM Module Scaffold + Products CRUD
|
||||||
|
|
||||||
|
**File**: `.claude/crm-step-01.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 — Backend: Customers CRUD
|
||||||
|
|
||||||
|
**File**: `.claude/crm-step-02.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3 — Backend: Orders CRUD
|
||||||
|
|
||||||
|
**File**: `.claude/crm-step-03.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4 — Backend: Comms Log + Media (SQLite)
|
||||||
|
|
||||||
|
**File**: `.claude/crm-step-04.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 5 — Frontend: Products Module
|
||||||
|
|
||||||
|
**File**: `.claude/crm-step-05.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 6 — Frontend: Customers List + Detail Page
|
||||||
|
|
||||||
|
**File**: `.claude/crm-step-06.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 7 — Frontend: Orders Module
|
||||||
|
|
||||||
|
**File**: `.claude/crm-step-07.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 8 — Frontend: Comms Log + Media Tab (manual entry)
|
||||||
|
|
||||||
|
**File**: `.claude/crm-step-08.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 9 — Integration: Nextcloud WebDAV
|
||||||
|
|
||||||
|
**File**: `.claude/crm-step-09.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 10 — Integration: IMAP/SMTP Email
|
||||||
|
|
||||||
|
**File**: `.claude/crm-step-10.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 11 — Integration: WhatsApp Business API
|
||||||
|
|
||||||
|
**File**: `.claude/crm-step-11.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 12 — Integration: FreePBX AMI Call Logging
|
||||||
|
|
||||||
|
**File**: `.claude/crm-step-12.md`
|
||||||
49
.claude/crm-step-01.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# CRM Step 01 — Backend: Module Scaffold + Products CRUD
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Read `.claude/crm-build-plan.md` first for full data models, conventions, and IMPORTANT NOTES.
|
||||||
|
|
||||||
|
## Task
|
||||||
|
Create the `backend/crm/` module with Products CRUD. This is the first CRM backend step.
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
### 1. `backend/crm/__init__.py` — empty
|
||||||
|
|
||||||
|
### 2. `backend/crm/models.py`
|
||||||
|
Pydantic models for Products:
|
||||||
|
- `ProductCosts` — pcb, components, enclosure, labor_hours, labor_rate, shipping_in, total (all float/int, all optional)
|
||||||
|
- `ProductStock` — on_hand, reserved, available (int, defaults 0)
|
||||||
|
- `ProductCategory` enum — controller, striker, clock, part, repair_service
|
||||||
|
- `ProductCreate` — name, sku (optional), category, description (optional), price (float), currency (default "EUR"), costs (ProductCosts optional), stock (ProductStock optional), nextcloud_folder (optional), linked_device_type (optional), active (bool default True)
|
||||||
|
- `ProductUpdate` — all fields Optional
|
||||||
|
- `ProductInDB` — extends ProductCreate + id (str), created_at (str), updated_at (str)
|
||||||
|
- `ProductListResponse` — products: List[ProductInDB], total: int
|
||||||
|
|
||||||
|
### 3. `backend/crm/service.py`
|
||||||
|
Firestore collection: `crm_products`
|
||||||
|
Functions:
|
||||||
|
- `list_products(search=None, category=None, active_only=False) -> List[ProductInDB]`
|
||||||
|
- `get_product(product_id) -> ProductInDB` — raises HTTPException 404 if not found
|
||||||
|
- `create_product(data: ProductCreate) -> ProductInDB` — generates UUID id, sets created_at/updated_at to ISO now
|
||||||
|
- `update_product(product_id, data: ProductUpdate) -> ProductInDB` — partial update (only set fields), updates updated_at
|
||||||
|
- `delete_product(product_id) -> None` — raises 404 if not found
|
||||||
|
|
||||||
|
### 4. `backend/crm/router.py`
|
||||||
|
Prefix: `/api/crm/products`, tag: `crm-products`
|
||||||
|
All routes require `require_permission("crm", "view")` for GET, `require_permission("crm", "edit")` for POST/PUT/DELETE.
|
||||||
|
- `GET /` → list_products (query params: search, category, active_only)
|
||||||
|
- `GET /{product_id}` → get_product
|
||||||
|
- `POST /` → create_product
|
||||||
|
- `PUT /{product_id}` → update_product
|
||||||
|
- `DELETE /{product_id}` → delete_product
|
||||||
|
|
||||||
|
### 5. Register in `backend/main.py`
|
||||||
|
Add: `from crm.router import router as crm_products_router`
|
||||||
|
Add: `app.include_router(crm_products_router)` (after existing routers)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Use `uuid.uuid4()` for IDs
|
||||||
|
- Use `datetime.utcnow().isoformat()` for timestamps
|
||||||
|
- Follow exact Firestore pattern from `backend/devices/service.py`
|
||||||
|
- No new pip dependencies needed
|
||||||
61
.claude/crm-step-02.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# CRM Step 02 — Backend: Customers CRUD
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Read `.claude/crm-build-plan.md` first for full data models, conventions, and IMPORTANT NOTES.
|
||||||
|
Step 01 must be complete (`backend/crm/` module exists).
|
||||||
|
|
||||||
|
## Task
|
||||||
|
Add Customers models, service, and router to `backend/crm/`.
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
### 1. Add to `backend/crm/models.py`
|
||||||
|
|
||||||
|
**Contact entry:**
|
||||||
|
- `ContactType` enum — email, phone, whatsapp, other
|
||||||
|
- `CustomerContact` — type (ContactType), label (str, e.g. "personal"/"church"), value (str), primary (bool default False)
|
||||||
|
|
||||||
|
**Note entry:**
|
||||||
|
- `CustomerNote` — text (str), by (str), at (str ISO datetime)
|
||||||
|
|
||||||
|
**Owned items (3 tiers):**
|
||||||
|
- `OwnedItemType` enum — console_device, product, freetext
|
||||||
|
- `OwnedItem`:
|
||||||
|
- type: OwnedItemType
|
||||||
|
- For console_device: device_id (Optional[str]), label (Optional[str])
|
||||||
|
- For product: product_id (Optional[str]), product_name (Optional[str]), quantity (Optional[int]), serial_numbers (Optional[List[str]])
|
||||||
|
- For freetext: description (Optional[str]), serial_number (Optional[str]), notes (Optional[str])
|
||||||
|
|
||||||
|
**Location:**
|
||||||
|
- `CustomerLocation` — city (Optional[str]), country (Optional[str]), region (Optional[str])
|
||||||
|
|
||||||
|
**Customer models:**
|
||||||
|
- `CustomerCreate` — name (str), organization (Optional[str]), contacts (List[CustomerContact] default []), notes (List[CustomerNote] default []), location (Optional[CustomerLocation]), language (str default "el"), tags (List[str] default []), owned_items (List[OwnedItem] default []), linked_user_ids (List[str] default []), nextcloud_folder (Optional[str])
|
||||||
|
- `CustomerUpdate` — all fields Optional
|
||||||
|
- `CustomerInDB` — extends CustomerCreate + id, created_at, updated_at
|
||||||
|
- `CustomerListResponse` — customers: List[CustomerInDB], total: int
|
||||||
|
|
||||||
|
### 2. Add to `backend/crm/service.py`
|
||||||
|
Firestore collection: `crm_customers`
|
||||||
|
Functions:
|
||||||
|
- `list_customers(search=None, tag=None) -> List[CustomerInDB]`
|
||||||
|
- search matches against name, organization, and any contact value
|
||||||
|
- `get_customer(customer_id) -> CustomerInDB` — 404 if not found
|
||||||
|
- `create_customer(data: CustomerCreate) -> CustomerInDB`
|
||||||
|
- `update_customer(customer_id, data: CustomerUpdate) -> CustomerInDB`
|
||||||
|
- `delete_customer(customer_id) -> None`
|
||||||
|
|
||||||
|
### 3. Add to `backend/crm/router.py`
|
||||||
|
Add a second router or extend existing file with prefix `/api/crm/customers`:
|
||||||
|
- `GET /` — list_customers (query: search, tag)
|
||||||
|
- `GET /{customer_id}` — get_customer
|
||||||
|
- `POST /` — create_customer
|
||||||
|
- `PUT /{customer_id}` — update_customer
|
||||||
|
- `DELETE /{customer_id}` — delete_customer
|
||||||
|
|
||||||
|
Register this router in `backend/main.py` alongside the products router.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- OwnedItem is a flexible struct — store all fields, service doesn't validate which fields are relevant per type (frontend handles that)
|
||||||
|
- linked_user_ids are Firebase Auth UIDs (strings) — no validation needed here, just store them
|
||||||
|
- Search in list_customers: do client-side filter after fetching all (small dataset)
|
||||||
60
.claude/crm-step-03.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# CRM Step 03 — Backend: Orders CRUD
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Read `.claude/crm-build-plan.md` first for full data models, conventions, and IMPORTANT NOTES.
|
||||||
|
Steps 01 and 02 must be complete.
|
||||||
|
|
||||||
|
## Task
|
||||||
|
Add Orders models, service, and router to `backend/crm/`.
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
### 1. Add to `backend/crm/models.py`
|
||||||
|
|
||||||
|
**Enums:**
|
||||||
|
- `OrderStatus` — draft, confirmed, in_production, shipped, delivered, cancelled
|
||||||
|
- `PaymentStatus` — pending, partial, paid
|
||||||
|
|
||||||
|
**Structs:**
|
||||||
|
- `OrderDiscount` — type (str: "percentage" | "fixed"), value (float default 0), reason (Optional[str])
|
||||||
|
- `OrderShipping` — method (Optional[str]), tracking_number (Optional[str]), carrier (Optional[str]), shipped_at (Optional[str]), delivered_at (Optional[str]), destination (Optional[str])
|
||||||
|
- `OrderItem`:
|
||||||
|
- type: str (console_device | product | freetext)
|
||||||
|
- product_id: Optional[str]
|
||||||
|
- product_name: Optional[str]
|
||||||
|
- description: Optional[str] ← for freetext items
|
||||||
|
- quantity: int default 1
|
||||||
|
- unit_price: float default 0.0
|
||||||
|
- serial_numbers: List[str] default []
|
||||||
|
|
||||||
|
**Order models:**
|
||||||
|
- `OrderCreate` — customer_id (str), order_number (Optional[str] — auto-generated if not provided), status (OrderStatus default draft), items (List[OrderItem] default []), subtotal (float default 0), discount (Optional[OrderDiscount]), total_price (float default 0), currency (str default "EUR"), shipping (Optional[OrderShipping]), payment_status (PaymentStatus default pending), invoice_path (Optional[str]), notes (Optional[str])
|
||||||
|
- `OrderUpdate` — all fields Optional
|
||||||
|
- `OrderInDB` — extends OrderCreate + id, created_at, updated_at
|
||||||
|
- `OrderListResponse` — orders: List[OrderInDB], total: int
|
||||||
|
|
||||||
|
### 2. Add to `backend/crm/service.py`
|
||||||
|
Firestore collection: `crm_orders`
|
||||||
|
|
||||||
|
Auto order number generation: `ORD-{YYYY}-{NNN}` — query existing orders for current year, increment max.
|
||||||
|
|
||||||
|
Functions:
|
||||||
|
- `list_orders(customer_id=None, status=None, payment_status=None) -> List[OrderInDB]`
|
||||||
|
- `get_order(order_id) -> OrderInDB` — 404 if not found
|
||||||
|
- `create_order(data: OrderCreate) -> OrderInDB` — auto-generate order_number if not set
|
||||||
|
- `update_order(order_id, data: OrderUpdate) -> OrderInDB`
|
||||||
|
- `delete_order(order_id) -> None`
|
||||||
|
|
||||||
|
### 3. Add to `backend/crm/router.py`
|
||||||
|
Prefix `/api/crm/orders`:
|
||||||
|
- `GET /` — list_orders (query: customer_id, status, payment_status)
|
||||||
|
- `GET /{order_id}` — get_order
|
||||||
|
- `POST /` — create_order
|
||||||
|
- `PUT /{order_id}` — update_order
|
||||||
|
- `DELETE /{order_id}` — delete_order
|
||||||
|
|
||||||
|
Register in `backend/main.py`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- subtotal and total_price are stored as-is (calculated by frontend before POST/PUT). Backend does not recalculate.
|
||||||
|
- Order number generation doesn't need to be atomic/perfect — just a best-effort sequential label.
|
||||||
96
.claude/crm-step-04.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# CRM Step 04 — Backend: Comms Log + Media (SQLite)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Read `.claude/crm-build-plan.md` for full schema, conventions, and IMPORTANT NOTES.
|
||||||
|
Steps 01–03 must be complete.
|
||||||
|
|
||||||
|
## Task
|
||||||
|
Add `crm_comms_log` and `crm_media` tables to the existing SQLite DB, plus CRUD endpoints.
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
### 1. Add tables to `backend/mqtt/database.py`
|
||||||
|
Inside `init_db()`, add these CREATE TABLE IF NOT EXISTS statements alongside existing tables:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS crm_comms_log (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
customer_id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
direction TEXT NOT NULL,
|
||||||
|
subject TEXT,
|
||||||
|
body TEXT,
|
||||||
|
attachments TEXT DEFAULT '[]',
|
||||||
|
ext_message_id TEXT,
|
||||||
|
logged_by TEXT,
|
||||||
|
occurred_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS crm_media (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
customer_id TEXT,
|
||||||
|
order_id TEXT,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
nextcloud_path TEXT NOT NULL,
|
||||||
|
mime_type TEXT,
|
||||||
|
direction TEXT,
|
||||||
|
tags TEXT DEFAULT '[]',
|
||||||
|
uploaded_by TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Add to `backend/crm/models.py`
|
||||||
|
|
||||||
|
**Comms:**
|
||||||
|
- `CommType` enum — email, whatsapp, call, sms, note, in_person
|
||||||
|
- `CommDirection` enum — inbound, outbound, internal
|
||||||
|
- `CommAttachment` — filename (str), nextcloud_path (str)
|
||||||
|
- `CommCreate` — customer_id, type (CommType), direction (CommDirection), subject (Optional[str]), body (Optional[str]), attachments (List[CommAttachment] default []), ext_message_id (Optional[str]), logged_by (Optional[str]), occurred_at (str ISO — default to now if not provided)
|
||||||
|
- `CommUpdate` — subject, body, occurred_at all Optional
|
||||||
|
- `CommInDB` — all fields + id, created_at
|
||||||
|
- `CommListResponse` — entries: List[CommInDB], total: int
|
||||||
|
|
||||||
|
**Media:**
|
||||||
|
- `MediaDirection` enum — received, sent, internal
|
||||||
|
- `MediaCreate` — customer_id (Optional[str]), order_id (Optional[str]), filename, nextcloud_path, mime_type (Optional), direction (MediaDirection optional), tags (List[str] default []), uploaded_by (Optional[str])
|
||||||
|
- `MediaInDB` — all fields + id, created_at
|
||||||
|
- `MediaListResponse` — items: List[MediaInDB], total: int
|
||||||
|
|
||||||
|
### 3. Add to `backend/crm/service.py`
|
||||||
|
Import `from mqtt import database as mqtt_db` for aiosqlite access.
|
||||||
|
|
||||||
|
**Comms functions (all async):**
|
||||||
|
- `list_comms(customer_id, type=None, direction=None, limit=100) -> List[CommInDB]`
|
||||||
|
— SELECT ... WHERE customer_id=? ORDER BY occurred_at DESC
|
||||||
|
- `get_comm(comm_id) -> CommInDB` — 404 if not found
|
||||||
|
- `create_comm(data: CommCreate) -> CommInDB` — uuid id, created_at now, store attachments as JSON string
|
||||||
|
- `update_comm(comm_id, data: CommUpdate) -> CommInDB`
|
||||||
|
- `delete_comm(comm_id) -> None`
|
||||||
|
|
||||||
|
**Media functions (all async):**
|
||||||
|
- `list_media(customer_id=None, order_id=None) -> List[MediaInDB]`
|
||||||
|
- `create_media(data: MediaCreate) -> MediaInDB`
|
||||||
|
- `delete_media(media_id) -> None`
|
||||||
|
|
||||||
|
Parse `attachments` and `tags` JSON strings back to lists when returning models.
|
||||||
|
|
||||||
|
### 4. Add to `backend/crm/router.py`
|
||||||
|
Prefix `/api/crm/comms`:
|
||||||
|
- `GET /` — list_comms (query: customer_id required, type, direction)
|
||||||
|
- `POST /` — create_comm
|
||||||
|
- `PUT /{comm_id}` — update_comm
|
||||||
|
- `DELETE /{comm_id}` — delete_comm
|
||||||
|
|
||||||
|
Prefix `/api/crm/media`:
|
||||||
|
- `GET /` — list_media (query: customer_id or order_id)
|
||||||
|
- `POST /` — create_media (metadata only — no file upload here, that's Step 9)
|
||||||
|
- `DELETE /{media_id}` — delete_media
|
||||||
|
|
||||||
|
Register both in `backend/main.py`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Use `mqtt_db.db` — it is an aiosqlite connection, use `async with mqtt_db.db.execute(...)` pattern
|
||||||
|
- Look at `backend/mqtt/database.py` for exact aiosqlite usage pattern
|
||||||
|
- attachments and tags are stored as JSON strings in SQLite, deserialized to lists in the Pydantic model
|
||||||
55
.claude/crm-step-05.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# CRM Step 05 — Frontend: Products Module
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Read `.claude/crm-build-plan.md` for full context and IMPORTANT NOTES.
|
||||||
|
Backend Steps 01–04 must be complete and running.
|
||||||
|
|
||||||
|
## Task
|
||||||
|
Build the Products section of the CRM frontend.
|
||||||
|
|
||||||
|
## Files to create
|
||||||
|
|
||||||
|
### `frontend/src/crm/products/ProductList.jsx`
|
||||||
|
- Fetch `GET /api/crm/products` with auth headers
|
||||||
|
- Show a table/list: Name, SKU, Category, Price, Stock (available), Active badge
|
||||||
|
- Search input (client-side filter on name/sku)
|
||||||
|
- Filter dropdown for category
|
||||||
|
- "New Product" button → navigate to `/crm/products/new`
|
||||||
|
- Row click → navigate to `/crm/products/:id`
|
||||||
|
|
||||||
|
### `frontend/src/crm/products/ProductForm.jsx`
|
||||||
|
Used for both create and edit. Receives `productId` prop (null = create mode).
|
||||||
|
Fields:
|
||||||
|
- name (required), sku, category (dropdown from enum), description (textarea)
|
||||||
|
- price (number), currency (default EUR)
|
||||||
|
- Costs section (collapsible): pcb, components, enclosure, labor_hours, labor_rate, shipping_in — show computed total
|
||||||
|
- Stock section: on_hand, reserved — show available = on_hand - reserved (readonly)
|
||||||
|
- nextcloud_folder, linked_device_type, active (toggle)
|
||||||
|
- Save / Cancel buttons
|
||||||
|
- In edit mode: show Delete button with confirmation
|
||||||
|
|
||||||
|
On save: POST `/api/crm/products` or PUT `/api/crm/products/:id`
|
||||||
|
On delete: DELETE `/api/crm/products/:id` then navigate back to list
|
||||||
|
|
||||||
|
### `frontend/src/crm/products/index.js`
|
||||||
|
Export both components.
|
||||||
|
|
||||||
|
## Routing
|
||||||
|
In `frontend/src/App.jsx` add:
|
||||||
|
```jsx
|
||||||
|
<Route path="/crm/products" element={<ProductList />} />
|
||||||
|
<Route path="/crm/products/new" element={<ProductForm />} />
|
||||||
|
<Route path="/crm/products/:id" element={<ProductForm />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sidebar
|
||||||
|
In `frontend/src/layout/Sidebar.jsx` add a "CRM" section with:
|
||||||
|
- Products → `/crm/products`
|
||||||
|
(Customers and Orders will be added in later steps)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Use existing UI patterns: SectionCard wrapper, inline styles for layout grid
|
||||||
|
- Follow the same auth header pattern as other frontend modules (getAuthHeaders from `../api/auth` or equivalent)
|
||||||
|
- Currency is always EUR for now — no need for a selector
|
||||||
|
- Computed costs total = pcb + components + enclosure + (labor_hours * labor_rate) + shipping_in, shown live as user types
|
||||||
|
- Category values: controller, striker, clock, part, repair_service — display as human-readable labels
|
||||||
84
.claude/crm-step-06.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# CRM Step 06 — Frontend: Customers List + Detail Page
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Read `.claude/crm-build-plan.md` for full context, data models, and IMPORTANT NOTES.
|
||||||
|
Backend Steps 01–04 and Frontend Step 05 must be complete.
|
||||||
|
|
||||||
|
## Task
|
||||||
|
Build the Customers section — the core of the CRM.
|
||||||
|
|
||||||
|
## Files to create
|
||||||
|
|
||||||
|
### `frontend/src/crm/customers/CustomerList.jsx`
|
||||||
|
- Fetch `GET /api/crm/customers` (query: search, tag)
|
||||||
|
- Show cards or table rows: Name, Organization, Location, Tags, primary contact
|
||||||
|
- Search input → query param `search`
|
||||||
|
- "New Customer" button → `/crm/customers/new`
|
||||||
|
- Row/card click → `/crm/customers/:id`
|
||||||
|
|
||||||
|
### `frontend/src/crm/customers/CustomerForm.jsx`
|
||||||
|
Create/edit form. Receives `customerId` prop (null = create).
|
||||||
|
|
||||||
|
**Sections:**
|
||||||
|
1. **Basic Info** — name, organization, language, tags (pill input), nextcloud_folder
|
||||||
|
2. **Location** — city, country, region
|
||||||
|
3. **Contacts** — dynamic list of `{ type, label, value, primary }` entries. Add/remove rows. Radio to set primary per type group.
|
||||||
|
4. **Notes** — dynamic list of `{ text, by, at }`. Add new note button. Existing notes shown as read-only with author/date. `by` auto-filled from current user name.
|
||||||
|
5. **Owned Items** — dynamic list with type selector:
|
||||||
|
- `console_device`: device_id text input + label
|
||||||
|
- `product`: product selector (fetch `/api/crm/products` for dropdown) + quantity + serial_numbers (comma-separated input)
|
||||||
|
- `freetext`: description + serial_number + notes
|
||||||
|
Add/remove rows.
|
||||||
|
6. **Linked App Accounts** — list of Firebase UIDs (simple text inputs, add/remove). Label: "Linked App User IDs"
|
||||||
|
|
||||||
|
Save: POST or PUT. Delete with confirmation.
|
||||||
|
|
||||||
|
### `frontend/src/crm/customers/CustomerDetail.jsx`
|
||||||
|
The main customer page. Fetches customer by ID. Tab layout:
|
||||||
|
|
||||||
|
**Tab 1: Overview**
|
||||||
|
- Show all info from CustomerForm fields in read-only view
|
||||||
|
- "Edit" button → opens CustomerForm in a modal or navigates to edit route
|
||||||
|
|
||||||
|
**Tab 2: Orders**
|
||||||
|
- Fetch `GET /api/crm/orders?customer_id=:id`
|
||||||
|
- List orders: order_number, status badge, total_price, date
|
||||||
|
- "New Order" button → navigate to `/crm/orders/new?customer_id=:id`
|
||||||
|
- Row click → `/crm/orders/:id`
|
||||||
|
|
||||||
|
**Tab 3: Comms**
|
||||||
|
- Fetch `GET /api/crm/comms?customer_id=:id`
|
||||||
|
- Timeline view sorted by occurred_at descending
|
||||||
|
- Each entry shows: type icon, direction indicator, subject/body preview, date
|
||||||
|
- "Log Entry" button → inline form to create a new comms entry (type, direction, subject, body, occurred_at)
|
||||||
|
|
||||||
|
**Tab 4: Media**
|
||||||
|
- Fetch `GET /api/crm/media?customer_id=:id`
|
||||||
|
- Grid of files: filename, direction badge (Received/Sent/Internal), date
|
||||||
|
- "Add Media Record" button → form with filename, nextcloud_path, direction, tags (manual entry for now — Nextcloud integration comes in Step 9)
|
||||||
|
|
||||||
|
**Tab 5: Devices** (read-only summary)
|
||||||
|
- Display `owned_items` grouped by type
|
||||||
|
- For console_device items: link to `/devices/:device_id` in a new tab
|
||||||
|
|
||||||
|
### `frontend/src/crm/customers/index.js`
|
||||||
|
Export all components.
|
||||||
|
|
||||||
|
## Routing in `frontend/src/App.jsx`
|
||||||
|
```jsx
|
||||||
|
<Route path="/crm/customers" element={<CustomerList />} />
|
||||||
|
<Route path="/crm/customers/new" element={<CustomerForm />} />
|
||||||
|
<Route path="/crm/customers/:id" element={<CustomerDetail />} />
|
||||||
|
<Route path="/crm/customers/:id/edit" element={<CustomerForm />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sidebar update
|
||||||
|
Add to CRM section:
|
||||||
|
- Customers → `/crm/customers`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- ALL hooks in CustomerDetail must be before any early returns (loading/error states)
|
||||||
|
- Tag input: comma or enter to add, click pill to remove
|
||||||
|
- Contact type icons: use simple text labels or emoji (📧 📞 💬) — keep it simple
|
||||||
|
- Comms type icons: simple colored badges per type (email=blue, whatsapp=green, call=yellow, note=grey)
|
||||||
|
- No file upload UI yet in Media tab — just nextcloud_path text field for now (Step 9 adds real upload)
|
||||||
71
.claude/crm-step-07.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# CRM Step 07 — Frontend: Orders Module
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Read `.claude/crm-build-plan.md` for full context, data models, and IMPORTANT NOTES.
|
||||||
|
Steps 01–06 must be complete.
|
||||||
|
|
||||||
|
## Task
|
||||||
|
Build the Orders section.
|
||||||
|
|
||||||
|
## Files to create
|
||||||
|
|
||||||
|
### `frontend/src/crm/orders/OrderList.jsx`
|
||||||
|
- Fetch `GET /api/crm/orders` (query: status, payment_status)
|
||||||
|
- Table: Order #, Customer name (resolve from customer_id via separate fetch or denormalize), Status badge, Total, Payment status, Date
|
||||||
|
- Filter dropdowns: Status, Payment Status
|
||||||
|
- "New Order" button → `/crm/orders/new`
|
||||||
|
- Row click → `/crm/orders/:id`
|
||||||
|
|
||||||
|
### `frontend/src/crm/orders/OrderForm.jsx`
|
||||||
|
Create/edit. Receives `orderId` prop and optional `customerId` from query param.
|
||||||
|
|
||||||
|
**Sections:**
|
||||||
|
1. **Customer** — searchable dropdown (fetch `/api/crm/customers`). Shows name + organization.
|
||||||
|
2. **Order Info** — order_number (auto, editable), status (dropdown), currency
|
||||||
|
3. **Items** — dynamic list. Each item:
|
||||||
|
- type selector: console_device | product | freetext
|
||||||
|
- product: dropdown from `/api/crm/products` (auto-fills product_name, unit_price)
|
||||||
|
- console_device: text input for device_id + label
|
||||||
|
- freetext: description text input
|
||||||
|
- quantity (number), unit_price (number), serial_numbers (comma-separated)
|
||||||
|
- Remove row button
|
||||||
|
- Add Item button
|
||||||
|
4. **Pricing** — show computed subtotal (sum of qty * unit_price). Discount: type toggle (% or fixed) + value input + reason. Show computed total = subtotal - discount. These values are sent to backend as-is.
|
||||||
|
5. **Payment** — payment_status dropdown, invoice_path (nextcloud path text input)
|
||||||
|
6. **Shipping** — method, carrier, tracking_number, destination, shipped_at (date), delivered_at (date)
|
||||||
|
7. **Notes** — textarea
|
||||||
|
|
||||||
|
Save → POST or PUT. Delete with confirmation.
|
||||||
|
|
||||||
|
### `frontend/src/crm/orders/OrderDetail.jsx`
|
||||||
|
Read-only view of a single order.
|
||||||
|
- Header: order number, status badge, customer name (link to customer)
|
||||||
|
- Items table: product/description, qty, unit price, line total
|
||||||
|
- Pricing summary: subtotal, discount, total
|
||||||
|
- Shipping card: all shipping fields
|
||||||
|
- Payment card: status, invoice path (if set, show as link)
|
||||||
|
- Notes
|
||||||
|
- Edit button → OrderForm
|
||||||
|
- Back to customer button
|
||||||
|
|
||||||
|
### `frontend/src/crm/orders/index.js`
|
||||||
|
Export all components.
|
||||||
|
|
||||||
|
## Routing in `frontend/src/App.jsx`
|
||||||
|
```jsx
|
||||||
|
<Route path="/crm/orders" element={<OrderList />} />
|
||||||
|
<Route path="/crm/orders/new" element={<OrderForm />} />
|
||||||
|
<Route path="/crm/orders/:id" element={<OrderDetail />} />
|
||||||
|
<Route path="/crm/orders/:id/edit" element={<OrderForm />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sidebar update
|
||||||
|
Add to CRM section:
|
||||||
|
- Orders → `/crm/orders`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Status badge colors: draft=grey, confirmed=blue, in_production=orange, shipped=purple, delivered=green, cancelled=red
|
||||||
|
- Payment status: pending=yellow, partial=orange, paid=green
|
||||||
|
- Discount calculation: if type=percentage → total = subtotal * (1 - value/100). if type=fixed → total = subtotal - value
|
||||||
|
- When a product is selected from dropdown in item row, auto-fill unit_price from product.price (user can override)
|
||||||
|
- Order list needs customer names — either fetch all customers once and build a map, or add customer_name as a denormalized field when creating/updating orders (simpler: fetch customer list once)
|
||||||
53
.claude/crm-step-08.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# CRM Step 08 — Frontend: Comms Log + Media (Manual Entry Polish)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Read `.claude/crm-build-plan.md` for full context and IMPORTANT NOTES.
|
||||||
|
Steps 01–07 must be complete.
|
||||||
|
|
||||||
|
## Task
|
||||||
|
Two things:
|
||||||
|
1. A standalone **Inbox** page — unified comms view across all customers
|
||||||
|
2. Polish the Comms and Media tabs on CustomerDetail (from Step 06) — improve the UI
|
||||||
|
|
||||||
|
## Files to create/update
|
||||||
|
|
||||||
|
### `frontend/src/crm/inbox/InboxPage.jsx`
|
||||||
|
- Fetch `GET /api/crm/comms?customer_id=ALL` — wait, this doesn't exist yet.
|
||||||
|
→ Instead, fetch all customers, then fetch comms for each? No — too many requests.
|
||||||
|
→ Add a new backend endpoint first (see below).
|
||||||
|
- Show a unified timeline of all comms entries across all customers, sorted by occurred_at desc
|
||||||
|
- Each entry shows: customer name (link), type badge, direction, subject/body preview, date
|
||||||
|
- Filter by type (email/whatsapp/call/note/etc), direction, customer (dropdown)
|
||||||
|
- Pagination or virtual scroll (limit to last 100 entries)
|
||||||
|
|
||||||
|
### Backend addition needed — add to `backend/crm/router.py` and `service.py`:
|
||||||
|
`GET /api/crm/comms/all` — fetch all comms (no customer_id filter), sorted by occurred_at DESC, limit 200.
|
||||||
|
`list_all_comms(type=None, direction=None, limit=200) -> List[CommInDB]` in service.
|
||||||
|
|
||||||
|
### Comms tab improvements (update CustomerDetail.jsx)
|
||||||
|
- Full timeline view with visual connector line between entries
|
||||||
|
- Each entry is expandable — click to see full body
|
||||||
|
- Entry form as an inline slide-down panel (not a modal)
|
||||||
|
- Form fields: type (icons + labels), direction, subject, body (textarea), occurred_at (datetime-local input, defaults to now), attachments (add nextcloud_path manually for now)
|
||||||
|
- After save, refresh comms list
|
||||||
|
|
||||||
|
### Media tab improvements (update CustomerDetail.jsx)
|
||||||
|
- Group media by direction: "Received" section, "Sent" section, "Internal" section
|
||||||
|
- Show filename, tags as pills, date
|
||||||
|
- "Add Media" inline form: filename (required), nextcloud_path (required), direction (dropdown), tags (pill input)
|
||||||
|
- Delete button per item with confirmation
|
||||||
|
|
||||||
|
## Routing in `frontend/src/App.jsx`
|
||||||
|
```jsx
|
||||||
|
<Route path="/crm/inbox" element={<InboxPage />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sidebar update
|
||||||
|
Add to CRM section (at top of CRM group):
|
||||||
|
- Inbox → `/crm/inbox`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- This step is mostly UI polish + the inbox page. No new integrations.
|
||||||
|
- The inbox page is the "central comms view" from the original requirements — all messages in one place
|
||||||
|
- Keep the comms entry form simple: only show attachment fields if user clicks "Add attachment"
|
||||||
|
- Type badges: email=blue, whatsapp=green, call=amber, sms=teal, note=grey, in_person=purple
|
||||||
92
.claude/crm-step-09.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# CRM Step 09 — Integration: Nextcloud WebDAV
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Read `.claude/crm-build-plan.md` for full context and IMPORTANT NOTES.
|
||||||
|
Steps 01–08 must be complete.
|
||||||
|
|
||||||
|
## Task
|
||||||
|
Connect the console to Nextcloud via WebDAV so that:
|
||||||
|
1. Files in a customer's Nextcloud folder are listed in the Media tab automatically
|
||||||
|
2. Uploading a file from the console sends it to Nextcloud
|
||||||
|
3. Files can be downloaded/previewed via a backend proxy
|
||||||
|
|
||||||
|
## Backend changes
|
||||||
|
|
||||||
|
### 1. Add Nextcloud settings to `backend/config.py`
|
||||||
|
```python
|
||||||
|
nextcloud_url: str = "https://nextcloud.bonamin.gr" # e.g. https://cloud.example.com
|
||||||
|
nextcloud_email: str = "bellsystems.gr@gmail.com"
|
||||||
|
nextcloud_username: str = "bellsystems-console"
|
||||||
|
nextcloud_password: str = "ydE916VdaQdbP2CQGhD!"
|
||||||
|
nextcloud_app_password: str = "rtLCp-NCy3y-gNZdg-38MtN-r8D2N"
|
||||||
|
nextcloud_base_path: str = "BellSystems" # root folder inside Nextcloud
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create `backend/crm/nextcloud.py`
|
||||||
|
WebDAV client using `httpx` (already available). Functions:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def list_folder(nextcloud_path: str) -> List[dict]:
|
||||||
|
"""
|
||||||
|
PROPFIND request to Nextcloud WebDAV.
|
||||||
|
Returns list of {filename, path, mime_type, size, last_modified, is_dir}
|
||||||
|
Parse the XML response (use xml.etree.ElementTree).
|
||||||
|
URL: {nextcloud_url}/remote.php/dav/files/{username}/{nextcloud_base_path}/{nextcloud_path}
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def upload_file(nextcloud_path: str, filename: str, content: bytes, mime_type: str) -> str:
|
||||||
|
"""
|
||||||
|
PUT request to upload file.
|
||||||
|
Returns the full nextcloud_path of the uploaded file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def download_file(nextcloud_path: str) -> tuple[bytes, str]:
|
||||||
|
"""
|
||||||
|
GET request. Returns (content_bytes, mime_type).
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def delete_file(nextcloud_path: str) -> None:
|
||||||
|
"""
|
||||||
|
DELETE request.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
Use HTTP Basic Auth with nextcloud_username/nextcloud_password.
|
||||||
|
If nextcloud_url is empty string, raise HTTPException 503 "Nextcloud not configured".
|
||||||
|
|
||||||
|
### 3. Add to `backend/crm/router.py`
|
||||||
|
|
||||||
|
**Media/Nextcloud endpoints:**
|
||||||
|
|
||||||
|
`GET /api/crm/nextcloud/browse?path=05_Customers/FOLDER`
|
||||||
|
→ calls `list_folder(path)`, returns file list
|
||||||
|
|
||||||
|
`GET /api/crm/nextcloud/file?path=05_Customers/FOLDER/photo.jpg`
|
||||||
|
→ calls `download_file(path)`, returns `Response(content=bytes, media_type=mime_type)`
|
||||||
|
→ This is the proxy endpoint — frontend uses this to display images
|
||||||
|
|
||||||
|
`POST /api/crm/nextcloud/upload`
|
||||||
|
→ accepts `UploadFile` + form field `nextcloud_path` (destination folder)
|
||||||
|
→ calls `upload_file(...)`, then calls `create_media(...)` to save the metadata record
|
||||||
|
→ returns the created `MediaInDB`
|
||||||
|
|
||||||
|
`DELETE /api/crm/nextcloud/file?path=...`
|
||||||
|
→ calls `delete_file(path)`, also deletes the matching `crm_media` record if found
|
||||||
|
|
||||||
|
## Frontend changes
|
||||||
|
|
||||||
|
### Update Media tab in `CustomerDetail.jsx`
|
||||||
|
- On load: if `customer.nextcloud_folder` is set, fetch `GET /api/crm/nextcloud/browse?path={customer.nextcloud_folder}` and merge results with existing `crm_media` records. Show files from both sources — deduplicate by nextcloud_path.
|
||||||
|
- Image files: render as `<img src="/api/crm/nextcloud/file?path=..." />` via the proxy endpoint
|
||||||
|
- Other files: show as a download link hitting the same proxy endpoint
|
||||||
|
- Upload button: file picker → POST to `/api/crm/nextcloud/upload` with file + destination path (default to customer's Sent Media subfolder)
|
||||||
|
- Show upload progress indicator
|
||||||
|
|
||||||
|
### Update Media tab in `CustomerDetail.jsx` — subfolder selector
|
||||||
|
When uploading, let user choose subfolder: "Sent Media" | "Received Media" | "Internal" (maps to direction field too)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- `httpx` is likely already in requirements. If not, add it: `httpx>=0.27.0`
|
||||||
|
- PROPFIND response is XML (DAV namespace). Parse `D:response` elements, extract `D:href` and `D:prop` children.
|
||||||
|
- The proxy approach means the VPS never stores files — it just streams them through from Nextcloud
|
||||||
|
- nextcloud_base_path in config allows the root to be `BellSystems` so paths in DB are relative to that root
|
||||||
102
.claude/crm-step-10.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# CRM Step 10 — Integration: IMAP/SMTP Email
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Read `.claude/crm-build-plan.md` for full context and IMPORTANT NOTES.
|
||||||
|
Steps 01–09 must be complete.
|
||||||
|
|
||||||
|
## Task
|
||||||
|
Integrate the company email mailbox so that:
|
||||||
|
1. Emails from/to a customer's email addresses appear in their Comms tab
|
||||||
|
2. New emails can be composed and sent from the console
|
||||||
|
3. A background sync runs periodically to pull new emails
|
||||||
|
|
||||||
|
## Backend changes
|
||||||
|
|
||||||
|
### 1. Add email settings to `backend/config.py`
|
||||||
|
```python
|
||||||
|
imap_host: str = ""
|
||||||
|
imap_port: int = 993
|
||||||
|
imap_username: str = ""
|
||||||
|
imap_password: str = ""
|
||||||
|
imap_use_ssl: bool = True
|
||||||
|
smtp_host: str = ""
|
||||||
|
smtp_port: int = 587
|
||||||
|
smtp_username: str = ""
|
||||||
|
smtp_password: str = ""
|
||||||
|
smtp_use_tls: bool = True
|
||||||
|
email_sync_interval_minutes: int = 15
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create `backend/crm/email_sync.py`
|
||||||
|
Using standard library `imaplib` and `email` (no new deps).
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def sync_emails():
|
||||||
|
"""
|
||||||
|
Connect to IMAP. Search UNSEEN or since last sync date.
|
||||||
|
For each email:
|
||||||
|
- Parse from/to/subject/body (text/plain preferred, fallback to stripped HTML)
|
||||||
|
- Check if from-address or to-address matches any customer contact (search crm_customers)
|
||||||
|
- If match found: create crm_comms_log entry with type=email, ext_message_id=message-id header
|
||||||
|
- Skip if ext_message_id already exists in crm_comms_log (dedup)
|
||||||
|
Store last sync time in a simple SQLite table crm_sync_state:
|
||||||
|
CREATE TABLE IF NOT EXISTS crm_sync_state (key TEXT PRIMARY KEY, value TEXT)
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def send_email(to: str, subject: str, body: str, cc: List[str] = []) -> str:
|
||||||
|
"""
|
||||||
|
Send email via SMTP. Returns message-id.
|
||||||
|
After sending, create a crm_comms_log entry: type=email, direction=outbound.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Add SQLite table to `backend/mqtt/database.py`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS crm_sync_state (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Add email endpoints to `backend/crm/router.py`
|
||||||
|
|
||||||
|
`POST /api/crm/email/send`
|
||||||
|
Body: `{ customer_id, to, subject, body, cc (optional) }`
|
||||||
|
→ calls `send_email(...)`, links to customer in comms_log
|
||||||
|
|
||||||
|
`POST /api/crm/email/sync`
|
||||||
|
→ manually trigger `sync_emails()` (for testing / on-demand)
|
||||||
|
→ returns count of new emails found
|
||||||
|
|
||||||
|
### 5. Add background sync to `backend/main.py`
|
||||||
|
In the `startup` event, add a periodic task:
|
||||||
|
```python
|
||||||
|
async def email_sync_loop():
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(settings.email_sync_interval_minutes * 60)
|
||||||
|
try:
|
||||||
|
from crm.email_sync import sync_emails
|
||||||
|
await sync_emails()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[EMAIL SYNC] Error: {e}")
|
||||||
|
|
||||||
|
asyncio.create_task(email_sync_loop())
|
||||||
|
```
|
||||||
|
Only start if `settings.imap_host` is set (non-empty).
|
||||||
|
|
||||||
|
## Frontend changes
|
||||||
|
|
||||||
|
### Update Comms tab in `CustomerDetail.jsx`
|
||||||
|
- Email entries show: from/to, subject, body (truncated with expand)
|
||||||
|
- "Compose Email" button → modal with: to (pre-filled from customer primary email), subject, body (textarea), CC
|
||||||
|
- On send: POST `/api/crm/email/send`, add new entry to comms list
|
||||||
|
|
||||||
|
### Update `InboxPage.jsx`
|
||||||
|
- Add "Sync Now" button → POST `/api/crm/email/sync`, show result count toast
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- `imaplib` is synchronous — wrap in `asyncio.run_in_executor(None, sync_fn)` for the async context
|
||||||
|
- For HTML emails: strip tags with a simple regex or `html.parser` — no need for an HTML renderer
|
||||||
|
- Email body matching: compare email From/To headers against ALL customer contacts where type=email
|
||||||
|
- Don't sync attachments yet — just text content. Attachment handling can be a future step.
|
||||||
|
- If imap_host is empty string, the sync loop doesn't start and the send endpoint returns 503
|
||||||
81
.claude/crm-step-11.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# CRM Step 11 — Integration: WhatsApp Business API
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Read `.claude/crm-build-plan.md` for full context and IMPORTANT NOTES.
|
||||||
|
Steps 01–10 must be complete.
|
||||||
|
|
||||||
|
## Prerequisites (manual setup required before this step)
|
||||||
|
- A Meta Business account with WhatsApp Business API enabled
|
||||||
|
- A dedicated phone number registered to WhatsApp Business API (NOT a personal number)
|
||||||
|
- A Meta App with webhook configured to point to: `https://yourdomain.com/api/crm/whatsapp/webhook`
|
||||||
|
- The following values ready: `WHATSAPP_PHONE_NUMBER_ID`, `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_VERIFY_TOKEN`
|
||||||
|
|
||||||
|
## Task
|
||||||
|
Receive inbound WhatsApp messages via webhook and send outbound messages, all logged to crm_comms_log.
|
||||||
|
|
||||||
|
## Backend changes
|
||||||
|
|
||||||
|
### 1. Add to `backend/config.py`
|
||||||
|
```python
|
||||||
|
whatsapp_phone_number_id: str = ""
|
||||||
|
whatsapp_access_token: str = ""
|
||||||
|
whatsapp_verify_token: str = "change-me" # you set this in Meta webhook config
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create `backend/crm/whatsapp.py`
|
||||||
|
```python
|
||||||
|
async def send_whatsapp(to_phone: str, message: str) -> str:
|
||||||
|
"""
|
||||||
|
POST to https://graph.facebook.com/v19.0/{phone_number_id}/messages
|
||||||
|
Headers: Authorization: Bearer {access_token}
|
||||||
|
Body: { messaging_product: "whatsapp", to: to_phone, type: "text", text: { body: message } }
|
||||||
|
Returns the wamid (WhatsApp message ID).
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Add webhook + send endpoints to `backend/crm/router.py`
|
||||||
|
|
||||||
|
`GET /api/crm/whatsapp/webhook`
|
||||||
|
— Meta webhook verification. Check `hub.verify_token` == settings.whatsapp_verify_token.
|
||||||
|
Return `hub.challenge` if valid, else 403.
|
||||||
|
**No auth required on this endpoint.**
|
||||||
|
|
||||||
|
`POST /api/crm/whatsapp/webhook`
|
||||||
|
— Receive inbound message events from Meta.
|
||||||
|
**No auth required on this endpoint.**
|
||||||
|
Parse payload:
|
||||||
|
```
|
||||||
|
entry[0].changes[0].value.messages[0]
|
||||||
|
.from → sender phone number (e.g. "306974015758")
|
||||||
|
.id → wamid
|
||||||
|
.type → "text"
|
||||||
|
.text.body → message content
|
||||||
|
.timestamp → unix timestamp
|
||||||
|
```
|
||||||
|
For each message:
|
||||||
|
1. Look up customer by phone number in crm_customers contacts (where type=phone or whatsapp)
|
||||||
|
2. If found: create crm_comms_log entry (type=whatsapp, direction=inbound, ext_message_id=wamid)
|
||||||
|
3. If not found: still log it but with customer_id="unknown:{phone}"
|
||||||
|
|
||||||
|
`POST /api/crm/whatsapp/send`
|
||||||
|
Body: `{ customer_id, to_phone, message }`
|
||||||
|
Requires auth.
|
||||||
|
→ calls `send_whatsapp(...)`, creates outbound comms_log entry
|
||||||
|
|
||||||
|
## Frontend changes
|
||||||
|
|
||||||
|
### Update Comms tab in `CustomerDetail.jsx`
|
||||||
|
- WhatsApp entries: green background, WhatsApp icon
|
||||||
|
- "Send WhatsApp" button → modal with: to_phone (pre-filled from customer's whatsapp/phone contacts), message textarea
|
||||||
|
- On send: POST `/api/crm/whatsapp/send`
|
||||||
|
|
||||||
|
### Update `InboxPage.jsx`
|
||||||
|
- WhatsApp entries are already included (from crm_comms_log)
|
||||||
|
- Add type filter option for "WhatsApp"
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Phone number format: Meta sends numbers without `+` (e.g. "306974015758"). Normalize when matching against customer contacts (strip `+` and spaces).
|
||||||
|
- Webhook payload can contain multiple entries and messages — iterate and handle each
|
||||||
|
- Rate limits: Meta free tier = 1000 conversations/month (a conversation = 24h window with a customer). More than enough.
|
||||||
|
- If whatsapp_phone_number_id is empty, the send endpoint returns 503. The webhook endpoint must always be available (it's a public endpoint).
|
||||||
|
- Media messages (images, docs): in this step, just log "Media message received" as body text. Full media download is a future enhancement.
|
||||||
97
.claude/crm-step-12.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# CRM Step 12 — Integration: FreePBX AMI Call Logging
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Read `.claude/crm-build-plan.md` for full context and IMPORTANT NOTES.
|
||||||
|
Steps 01–11 must be complete.
|
||||||
|
|
||||||
|
## Prerequisites (manual setup before this step)
|
||||||
|
- FreePBX server with AMI (Asterisk Manager Interface) enabled
|
||||||
|
- An AMI user created in FreePBX: Admin → Asterisk Manager Users
|
||||||
|
- Username + password (set these in config)
|
||||||
|
- Permissions needed: read = "call,cdr" (minimum)
|
||||||
|
- Network access from VPS to FreePBX AMI port (default: 5038)
|
||||||
|
- Values ready: `AMI_HOST`, `AMI_PORT` (5038), `AMI_USERNAME`, `AMI_PASSWORD`
|
||||||
|
|
||||||
|
## Task
|
||||||
|
Connect to FreePBX AMI over TCP, listen for call events, and auto-log them to crm_comms_log matched against customer phone numbers.
|
||||||
|
|
||||||
|
## Backend changes
|
||||||
|
|
||||||
|
### 1. Add to `backend/config.py`
|
||||||
|
```python
|
||||||
|
ami_host: str = ""
|
||||||
|
ami_port: int = 5038
|
||||||
|
ami_username: str = ""
|
||||||
|
ami_password: str = ""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create `backend/crm/ami_listener.py`
|
||||||
|
AMI uses a plain TCP socket with a text protocol (key: value\r\n pairs, events separated by \r\n\r\n).
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
from config import settings
|
||||||
|
from mqtt import database as mqtt_db
|
||||||
|
|
||||||
|
async def ami_connect_and_listen():
|
||||||
|
"""
|
||||||
|
1. Open TCP connection to ami_host:ami_port
|
||||||
|
2. Read the banner line
|
||||||
|
3. Send login action:
|
||||||
|
Action: Login\r\n
|
||||||
|
Username: {ami_username}\r\n
|
||||||
|
Secret: {ami_password}\r\n\r\n
|
||||||
|
4. Read response — check for "Response: Success"
|
||||||
|
5. Loop reading events. Parse each event block into a dict.
|
||||||
|
6. Handle Event: Hangup:
|
||||||
|
- CallerID: the phone number (field: CallerIDNum)
|
||||||
|
- Duration: call duration seconds (field: Duration, may not always be present)
|
||||||
|
- Channel direction: inbound if DestChannel starts with "PJSIP/" or "SIP/",
|
||||||
|
outbound if Channel starts with "PJSIP/" or "SIP/"
|
||||||
|
- Normalize CallerIDNum: strip leading + and spaces
|
||||||
|
- Look up customer by normalized phone
|
||||||
|
- Create crm_comms_log entry: type=call, direction=inbound|outbound,
|
||||||
|
body=f"Call duration: {duration}s", ext_message_id=Uniqueid field
|
||||||
|
7. On disconnect: wait 30s, reconnect. Infinite retry loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def start_ami_listener():
|
||||||
|
"""Entry point — only starts if ami_host is set."""
|
||||||
|
if not settings.ami_host:
|
||||||
|
return
|
||||||
|
asyncio.create_task(ami_connect_and_listen())
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Add to `backend/main.py` startup
|
||||||
|
```python
|
||||||
|
from crm.ami_listener import start_ami_listener
|
||||||
|
# in startup():
|
||||||
|
await start_ami_listener()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Add manual log endpoint to `backend/crm/router.py`
|
||||||
|
`POST /api/crm/calls/log`
|
||||||
|
Body: `{ customer_id, direction, duration_seconds, notes, occurred_at }`
|
||||||
|
Requires auth.
|
||||||
|
→ create crm_comms_log entry (type=call) manually
|
||||||
|
→ useful if auto-logging misses a call or for logging calls made outside the office
|
||||||
|
|
||||||
|
## Frontend changes
|
||||||
|
|
||||||
|
### Update Comms tab in `CustomerDetail.jsx`
|
||||||
|
- Call entries: amber/yellow color, phone icon
|
||||||
|
- Show duration if available (parse from body)
|
||||||
|
- "Log Call" button → quick modal with: direction (inbound/outbound), duration (minutes + seconds), notes, occurred_at
|
||||||
|
- On save: POST `/api/crm/calls/log`
|
||||||
|
|
||||||
|
### Update `InboxPage.jsx`
|
||||||
|
- Add "Call" to type filter options
|
||||||
|
- Call entries show customer name, direction arrow, duration
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- AMI protocol reference: each event/response is a block of `Key: Value` lines terminated by `\r\n\r\n`
|
||||||
|
- The `Hangup` event fires at end of call and includes Duration in seconds
|
||||||
|
- CallerIDNum for inbound calls is the caller's number. For outbound it's typically the extension — may need to use `DestCallerIDNum` instead. Test against your FreePBX setup.
|
||||||
|
- Phone matching uses the same normalization as WhatsApp step (strip `+`, spaces, leading zeros if needed)
|
||||||
|
- If AMI connection drops (FreePBX restart, network blip), the reconnect loop handles it silently
|
||||||
|
- This gives you: auto-logged inbound calls matched to customers, duration recorded, plus a manual log option for anything missed
|
||||||
@@ -13,6 +13,8 @@ MQTT_BROKER_PORT=1883
|
|||||||
MQTT_ADMIN_USERNAME=admin
|
MQTT_ADMIN_USERNAME=admin
|
||||||
MQTT_ADMIN_PASSWORD=your-mqtt-admin-password
|
MQTT_ADMIN_PASSWORD=your-mqtt-admin-password
|
||||||
MOSQUITTO_PASSWORD_FILE=/etc/mosquitto/passwd
|
MOSQUITTO_PASSWORD_FILE=/etc/mosquitto/passwd
|
||||||
|
# Must be unique per running instance (VPS vs local dev)
|
||||||
|
MQTT_CLIENT_ID=bellsystems-admin-panel
|
||||||
# HMAC secret used to derive per-device MQTT passwords (must match firmware)
|
# HMAC secret used to derive per-device MQTT passwords (must match firmware)
|
||||||
MQTT_SECRET=change-me-in-production
|
MQTT_SECRET=change-me-in-production
|
||||||
|
|
||||||
@@ -26,3 +28,10 @@ NGINX_PORT=80
|
|||||||
SQLITE_DB_PATH=./mqtt_data.db
|
SQLITE_DB_PATH=./mqtt_data.db
|
||||||
BUILT_MELODIES_STORAGE_PATH=./storage/built_melodies
|
BUILT_MELODIES_STORAGE_PATH=./storage/built_melodies
|
||||||
FIRMWARE_STORAGE_PATH=./storage/firmware
|
FIRMWARE_STORAGE_PATH=./storage/firmware
|
||||||
|
|
||||||
|
# Nextcloud WebDAV
|
||||||
|
NEXTCLOUD_URL=https://cloud.example.com
|
||||||
|
NEXTCLOUD_USERNAME=service-account@example.com
|
||||||
|
NEXTCLOUD_PASSWORD=your-password-here
|
||||||
|
NEXTCLOUD_DAV_USER=admin
|
||||||
|
NEXTCLOUD_BASE_PATH=BellSystems/Console
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# WeasyPrint system dependencies (libpango, libcairo, etc.)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libpangocairo-1.0-0 \
|
||||||
|
libgdk-pixbuf-2.0-0 \
|
||||||
|
libffi-dev \
|
||||||
|
shared-mime-info \
|
||||||
|
fonts-dejavu-core \
|
||||||
|
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
|
|||||||
@@ -10,45 +10,141 @@ class Role(str, Enum):
|
|||||||
user = "user"
|
user = "user"
|
||||||
|
|
||||||
|
|
||||||
class SectionPermissions(BaseModel):
|
class MelodiesPermissions(BaseModel):
|
||||||
|
view: bool = False
|
||||||
|
add: bool = False
|
||||||
|
delete: bool = False
|
||||||
|
safe_edit: bool = False
|
||||||
|
full_edit: bool = False
|
||||||
|
archetype_access: bool = False
|
||||||
|
settings_access: bool = False
|
||||||
|
compose_access: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class DevicesPermissions(BaseModel):
|
||||||
|
view: bool = False
|
||||||
|
add: bool = False
|
||||||
|
delete: bool = False
|
||||||
|
safe_edit: bool = False
|
||||||
|
edit_bells: bool = False
|
||||||
|
edit_clock: bool = False
|
||||||
|
edit_warranty: bool = False
|
||||||
|
full_edit: bool = False
|
||||||
|
control: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AppUsersPermissions(BaseModel):
|
||||||
|
view: bool = False
|
||||||
|
add: bool = False
|
||||||
|
delete: bool = False
|
||||||
|
safe_edit: bool = False
|
||||||
|
full_edit: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class IssuesNotesPermissions(BaseModel):
|
||||||
|
view: bool = False
|
||||||
|
add: bool = False
|
||||||
|
delete: bool = False
|
||||||
|
edit: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class MailPermissions(BaseModel):
|
||||||
|
view: bool = False
|
||||||
|
compose: bool = False
|
||||||
|
reply: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class CrmPermissions(BaseModel):
|
||||||
|
activity_log: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class CrmCustomersPermissions(BaseModel):
|
||||||
|
full_access: bool = False
|
||||||
|
overview: bool = False
|
||||||
|
orders_view: bool = False
|
||||||
|
orders_edit: bool = False
|
||||||
|
quotations_view: bool = False
|
||||||
|
quotations_edit: bool = False
|
||||||
|
comms_view: bool = False
|
||||||
|
comms_log: bool = False
|
||||||
|
comms_edit: bool = False
|
||||||
|
comms_compose: bool = False
|
||||||
|
add: bool = False
|
||||||
|
delete: bool = False
|
||||||
|
files_view: bool = False
|
||||||
|
files_edit: bool = False
|
||||||
|
devices_view: bool = False
|
||||||
|
devices_edit: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class CrmProductsPermissions(BaseModel):
|
||||||
view: bool = False
|
view: bool = False
|
||||||
add: bool = False
|
add: bool = False
|
||||||
edit: bool = False
|
edit: bool = False
|
||||||
delete: bool = False
|
|
||||||
|
|
||||||
|
class MfgPermissions(BaseModel):
|
||||||
|
view_inventory: bool = False
|
||||||
|
edit: bool = False
|
||||||
|
provision: bool = False
|
||||||
|
firmware_view: bool = False
|
||||||
|
firmware_edit: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ApiReferencePermissions(BaseModel):
|
||||||
|
access: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class MqttPermissions(BaseModel):
|
||||||
|
access: bool = False
|
||||||
|
|
||||||
|
|
||||||
class StaffPermissions(BaseModel):
|
class StaffPermissions(BaseModel):
|
||||||
melodies: SectionPermissions = SectionPermissions()
|
melodies: MelodiesPermissions = MelodiesPermissions()
|
||||||
devices: SectionPermissions = SectionPermissions()
|
devices: DevicesPermissions = DevicesPermissions()
|
||||||
app_users: SectionPermissions = SectionPermissions()
|
app_users: AppUsersPermissions = AppUsersPermissions()
|
||||||
equipment: SectionPermissions = SectionPermissions()
|
issues_notes: IssuesNotesPermissions = IssuesNotesPermissions()
|
||||||
manufacturing: SectionPermissions = SectionPermissions()
|
mail: MailPermissions = MailPermissions()
|
||||||
mqtt: bool = False
|
crm: CrmPermissions = CrmPermissions()
|
||||||
|
crm_customers: CrmCustomersPermissions = CrmCustomersPermissions()
|
||||||
|
crm_products: CrmProductsPermissions = CrmProductsPermissions()
|
||||||
|
mfg: MfgPermissions = MfgPermissions()
|
||||||
|
api_reference: ApiReferencePermissions = ApiReferencePermissions()
|
||||||
|
mqtt: MqttPermissions = MqttPermissions()
|
||||||
|
|
||||||
|
|
||||||
# Default permissions per role
|
|
||||||
def default_permissions_for_role(role: str) -> Optional[dict]:
|
def default_permissions_for_role(role: str) -> Optional[dict]:
|
||||||
if role in ("sysadmin", "admin"):
|
if role in ("sysadmin", "admin"):
|
||||||
return None # Full access, permissions field not used
|
return None # Full access, permissions field not used
|
||||||
full = {"view": True, "add": True, "edit": True, "delete": True}
|
|
||||||
view_only = {"view": True, "add": False, "edit": False, "delete": False}
|
|
||||||
if role == "editor":
|
if role == "editor":
|
||||||
return {
|
return {
|
||||||
"melodies": full,
|
"melodies": {"view": True, "add": True, "delete": True, "safe_edit": True, "full_edit": True, "archetype_access": True, "settings_access": True, "compose_access": True},
|
||||||
"devices": full,
|
"devices": {"view": True, "add": True, "delete": True, "safe_edit": True, "edit_bells": True, "edit_clock": True, "edit_warranty": True, "full_edit": True, "control": True},
|
||||||
"app_users": full,
|
"app_users": {"view": True, "add": True, "delete": True, "safe_edit": True, "full_edit": True},
|
||||||
"equipment": full,
|
"issues_notes": {"view": True, "add": True, "delete": True, "edit": True},
|
||||||
"manufacturing": view_only,
|
"mail": {"view": True, "compose": True, "reply": True},
|
||||||
"mqtt": True,
|
"crm": {"activity_log": True},
|
||||||
|
"crm_customers": {"full_access": True, "overview": True, "orders_view": True, "orders_edit": True, "quotations_view": True, "quotations_edit": True, "comms_view": True, "comms_log": True, "comms_edit": True, "comms_compose": True, "add": True, "delete": True, "files_view": True, "files_edit": True, "devices_view": True, "devices_edit": True},
|
||||||
|
"crm_products": {"view": True, "add": True, "edit": True},
|
||||||
|
"mfg": {"view_inventory": True, "edit": True, "provision": True, "firmware_view": True, "firmware_edit": True},
|
||||||
|
"api_reference": {"access": True},
|
||||||
|
"mqtt": {"access": True},
|
||||||
}
|
}
|
||||||
|
|
||||||
# user role - view only
|
# user role - view only
|
||||||
return {
|
return {
|
||||||
"melodies": view_only,
|
"melodies": {"view": True, "add": False, "delete": False, "safe_edit": False, "full_edit": False, "archetype_access": False, "settings_access": False, "compose_access": False},
|
||||||
"devices": view_only,
|
"devices": {"view": True, "add": False, "delete": False, "safe_edit": False, "edit_bells": False, "edit_clock": False, "edit_warranty": False, "full_edit": False, "control": False},
|
||||||
"app_users": view_only,
|
"app_users": {"view": True, "add": False, "delete": False, "safe_edit": False, "full_edit": False},
|
||||||
"equipment": view_only,
|
"issues_notes": {"view": True, "add": False, "delete": False, "edit": False},
|
||||||
"manufacturing": {"view": False, "add": False, "edit": False, "delete": False},
|
"mail": {"view": True, "compose": False, "reply": False},
|
||||||
"mqtt": False,
|
"crm": {"activity_log": False},
|
||||||
|
"crm_customers": {"full_access": False, "overview": True, "orders_view": True, "orders_edit": False, "quotations_view": True, "quotations_edit": False, "comms_view": True, "comms_log": False, "comms_edit": False, "comms_compose": False, "add": False, "delete": False, "files_view": True, "files_edit": False, "devices_view": True, "devices_edit": False},
|
||||||
|
"crm_products": {"view": True, "add": False, "edit": False},
|
||||||
|
"mfg": {"view_inventory": True, "edit": False, "provision": False, "firmware_view": True, "firmware_edit": False},
|
||||||
|
"api_reference": {"access": False},
|
||||||
|
"mqtt": {"access": False},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
from typing import List
|
from typing import List, Dict, Any
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ class Settings(BaseSettings):
|
|||||||
mqtt_admin_password: str = ""
|
mqtt_admin_password: str = ""
|
||||||
mqtt_secret: str = "change-me-in-production"
|
mqtt_secret: str = "change-me-in-production"
|
||||||
mosquitto_password_file: str = "/etc/mosquitto/passwd"
|
mosquitto_password_file: str = "/etc/mosquitto/passwd"
|
||||||
|
mqtt_client_id: str = "bellsystems-admin-panel"
|
||||||
|
|
||||||
# SQLite (MQTT data storage)
|
# SQLite (MQTT data storage)
|
||||||
sqlite_db_path: str = "./mqtt_data.db"
|
sqlite_db_path: str = "./mqtt_data.db"
|
||||||
@@ -37,6 +38,30 @@ class Settings(BaseSettings):
|
|||||||
backend_cors_origins: str = '["http://localhost:5173"]'
|
backend_cors_origins: str = '["http://localhost:5173"]'
|
||||||
debug: bool = True
|
debug: bool = True
|
||||||
|
|
||||||
|
# Nextcloud WebDAV
|
||||||
|
nextcloud_url: str = ""
|
||||||
|
nextcloud_username: str = "" # WebDAV login & URL path username
|
||||||
|
nextcloud_password: str = "" # Use an app password for better security
|
||||||
|
nextcloud_dav_user: str = "" # Override URL path username if different from login
|
||||||
|
nextcloud_base_path: str = "BellSystems"
|
||||||
|
|
||||||
|
# IMAP/SMTP Email
|
||||||
|
imap_host: str = ""
|
||||||
|
imap_port: int = 993
|
||||||
|
imap_username: str = ""
|
||||||
|
imap_password: str = ""
|
||||||
|
imap_use_ssl: bool = True
|
||||||
|
smtp_host: str = ""
|
||||||
|
smtp_port: int = 587
|
||||||
|
smtp_username: str = ""
|
||||||
|
smtp_password: str = ""
|
||||||
|
smtp_use_tls: bool = True
|
||||||
|
email_sync_interval_minutes: int = 15
|
||||||
|
# Multi-mailbox config (JSON array). If empty, legacy single-account IMAP/SMTP is used.
|
||||||
|
# Example item:
|
||||||
|
# {"key":"sales","label":"Sales","email":"sales@bellsystems.gr","imap_host":"...","imap_username":"...","imap_password":"...","smtp_host":"...","smtp_username":"...","smtp_password":"...","sync_inbound":true,"allow_send":true}
|
||||||
|
mail_accounts_json: str = "[]"
|
||||||
|
|
||||||
# Auto-deploy (Gitea webhook)
|
# Auto-deploy (Gitea webhook)
|
||||||
deploy_secret: str = ""
|
deploy_secret: str = ""
|
||||||
deploy_project_path: str = "/app"
|
deploy_project_path: str = "/app"
|
||||||
@@ -45,6 +70,14 @@ class Settings(BaseSettings):
|
|||||||
def cors_origins(self) -> List[str]:
|
def cors_origins(self) -> List[str]:
|
||||||
return json.loads(self.backend_cors_origins)
|
return json.loads(self.backend_cors_origins)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mail_accounts(self) -> List[Dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
raw = json.loads(self.mail_accounts_json or "[]")
|
||||||
|
return raw if isinstance(raw, list) else []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
model_config = {"env_file": ".env", "extra": "ignore"}
|
model_config = {"env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
0
backend/crm/__init__.py
Normal file
417
backend/crm/comms_router.py
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Form, File, UploadFile
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from auth.models import TokenPayload
|
||||||
|
from auth.dependencies import require_permission
|
||||||
|
from config import settings
|
||||||
|
from crm.models import CommCreate, CommUpdate, CommInDB, CommListResponse, MediaCreate, MediaDirection
|
||||||
|
from crm import service
|
||||||
|
from crm import email_sync
|
||||||
|
from crm.mail_accounts import get_mail_accounts
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/crm/comms", tags=["crm-comms"])
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSendResponse(BaseModel):
|
||||||
|
entry: dict
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSyncResponse(BaseModel):
|
||||||
|
new_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class MailListResponse(BaseModel):
|
||||||
|
entries: list
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/all", response_model=CommListResponse)
|
||||||
|
async def list_all_comms(
|
||||||
|
type: Optional[str] = Query(None),
|
||||||
|
direction: Optional[str] = Query(None),
|
||||||
|
limit: int = Query(200, le=500),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
entries = await service.list_all_comms(type=type, direction=direction, limit=limit)
|
||||||
|
return CommListResponse(entries=entries, total=len(entries))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=CommListResponse)
|
||||||
|
async def list_comms(
|
||||||
|
customer_id: str = Query(...),
|
||||||
|
type: Optional[str] = Query(None),
|
||||||
|
direction: Optional[str] = Query(None),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
entries = await service.list_comms(customer_id=customer_id, type=type, direction=direction)
|
||||||
|
return CommListResponse(entries=entries, total=len(entries))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=CommInDB, status_code=201)
|
||||||
|
async def create_comm(
|
||||||
|
body: CommCreate,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return await service.create_comm(body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/email/all", response_model=MailListResponse)
|
||||||
|
async def list_all_emails(
|
||||||
|
direction: Optional[str] = Query(None),
|
||||||
|
customers_only: bool = Query(False),
|
||||||
|
mailbox: Optional[str] = Query(None, description="sales|support|both|all or account key"),
|
||||||
|
limit: int = Query(500, le=1000),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
"""Return all email comms (all senders + unmatched), for the Mail page."""
|
||||||
|
selected_accounts = None
|
||||||
|
if mailbox and mailbox not in {"all", "both"}:
|
||||||
|
if mailbox == "sales":
|
||||||
|
selected_accounts = ["sales"]
|
||||||
|
elif mailbox == "support":
|
||||||
|
selected_accounts = ["support"]
|
||||||
|
else:
|
||||||
|
selected_accounts = [mailbox]
|
||||||
|
entries = await service.list_all_emails(
|
||||||
|
direction=direction,
|
||||||
|
customers_only=customers_only,
|
||||||
|
mail_accounts=selected_accounts,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return MailListResponse(entries=entries, total=len(entries))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/email/accounts")
|
||||||
|
async def list_mail_accounts(
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
accounts = get_mail_accounts()
|
||||||
|
return {
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"key": a["key"],
|
||||||
|
"label": a["label"],
|
||||||
|
"email": a["email"],
|
||||||
|
"sync_inbound": bool(a.get("sync_inbound")),
|
||||||
|
"allow_send": bool(a.get("allow_send")),
|
||||||
|
}
|
||||||
|
for a in accounts
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/email/check")
|
||||||
|
async def check_new_emails(
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
"""Lightweight check: returns how many emails are on the server vs. stored locally."""
|
||||||
|
return await email_sync.check_new_emails()
|
||||||
|
|
||||||
|
|
||||||
|
# Email endpoints — must be before /{comm_id} wildcard routes
|
||||||
|
@router.post("/email/send", response_model=EmailSendResponse)
|
||||||
|
async def send_email_endpoint(
|
||||||
|
customer_id: Optional[str] = Form(None),
|
||||||
|
from_account: Optional[str] = Form(None),
|
||||||
|
to: str = Form(...),
|
||||||
|
subject: str = Form(...),
|
||||||
|
body: str = Form(...),
|
||||||
|
body_html: str = Form(""),
|
||||||
|
cc: str = Form("[]"), # JSON-encoded list of strings
|
||||||
|
files: List[UploadFile] = File(default=[]),
|
||||||
|
user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
if not get_mail_accounts():
|
||||||
|
raise HTTPException(status_code=503, detail="SMTP not configured")
|
||||||
|
try:
|
||||||
|
cc_list: List[str] = json.loads(cc) if cc else []
|
||||||
|
except Exception:
|
||||||
|
cc_list = []
|
||||||
|
|
||||||
|
# Read all uploaded files into memory
|
||||||
|
file_attachments = []
|
||||||
|
for f in files:
|
||||||
|
content = await f.read()
|
||||||
|
mime_type = f.content_type or "application/octet-stream"
|
||||||
|
file_attachments.append((f.filename, content, mime_type))
|
||||||
|
|
||||||
|
from crm.email_sync import send_email
|
||||||
|
try:
|
||||||
|
entry = await send_email(
|
||||||
|
customer_id=customer_id or None,
|
||||||
|
from_account=from_account,
|
||||||
|
to=to,
|
||||||
|
subject=subject,
|
||||||
|
body=body,
|
||||||
|
body_html=body_html,
|
||||||
|
cc=cc_list,
|
||||||
|
sent_by=user.name or user.sub,
|
||||||
|
file_attachments=file_attachments if file_attachments else None,
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
return EmailSendResponse(entry=entry)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/email/sync", response_model=EmailSyncResponse)
|
||||||
|
async def sync_email_endpoint(
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
if not get_mail_accounts():
|
||||||
|
raise HTTPException(status_code=503, detail="IMAP not configured")
|
||||||
|
from crm.email_sync import sync_emails
|
||||||
|
new_count = await sync_emails()
|
||||||
|
return EmailSyncResponse(new_count=new_count)
|
||||||
|
|
||||||
|
|
||||||
|
class SaveInlineRequest(BaseModel):
|
||||||
|
data_uri: str
|
||||||
|
filename: str
|
||||||
|
subfolder: str = "received_media"
|
||||||
|
mime_type: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_customer_folder(customer_id: str) -> str:
|
||||||
|
"""Return the Nextcloud folder_id for a customer (falls back to customer_id)."""
|
||||||
|
from shared.firebase import get_db as get_firestore
|
||||||
|
firestore_db = get_firestore()
|
||||||
|
doc = firestore_db.collection("crm_customers").document(customer_id).get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
data = doc.to_dict()
|
||||||
|
return data.get("folder_id") or customer_id
|
||||||
|
|
||||||
|
|
||||||
|
async def _upload_to_nc(folder_id: str, subfolder: str, filename: str,
|
||||||
|
content: bytes, mime_type: str, customer_id: str,
|
||||||
|
uploaded_by: str, tags: list[str]) -> dict:
|
||||||
|
from crm import nextcloud
|
||||||
|
target_folder = f"customers/{folder_id}/{subfolder}"
|
||||||
|
file_path = f"{target_folder}/{filename}"
|
||||||
|
await nextcloud.ensure_folder(target_folder)
|
||||||
|
await nextcloud.upload_file(file_path, content, mime_type)
|
||||||
|
media = await service.create_media(MediaCreate(
|
||||||
|
customer_id=customer_id,
|
||||||
|
filename=filename,
|
||||||
|
nextcloud_path=file_path,
|
||||||
|
mime_type=mime_type,
|
||||||
|
direction=MediaDirection.received,
|
||||||
|
tags=tags,
|
||||||
|
uploaded_by=uploaded_by,
|
||||||
|
))
|
||||||
|
return {"ok": True, "media_id": media.id, "nextcloud_path": file_path}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/email/{comm_id}/save-inline")
|
||||||
|
async def save_email_inline_image(
|
||||||
|
comm_id: str,
|
||||||
|
body: SaveInlineRequest,
|
||||||
|
user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
"""Save an inline image (data-URI from email HTML body) to Nextcloud."""
|
||||||
|
comm = await service.get_comm(comm_id)
|
||||||
|
customer_id = comm.customer_id
|
||||||
|
if not customer_id:
|
||||||
|
raise HTTPException(status_code=400, detail="This email is not linked to a customer")
|
||||||
|
|
||||||
|
folder_id = await _resolve_customer_folder(customer_id)
|
||||||
|
|
||||||
|
# Parse data URI
|
||||||
|
data_uri = body.data_uri
|
||||||
|
mime_type = body.mime_type or "image/png"
|
||||||
|
if "," in data_uri:
|
||||||
|
header, encoded = data_uri.split(",", 1)
|
||||||
|
try:
|
||||||
|
mime_type = header.split(":")[1].split(";")[0]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
encoded = data_uri
|
||||||
|
try:
|
||||||
|
content = base64.b64decode(encoded)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid base64 data")
|
||||||
|
|
||||||
|
return await _upload_to_nc(
|
||||||
|
folder_id, body.subfolder, body.filename,
|
||||||
|
content, mime_type, customer_id,
|
||||||
|
user.name or user.sub, ["email-inline-image"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/email/{comm_id}/save-attachment/{attachment_index}")
|
||||||
|
async def save_email_attachment(
|
||||||
|
comm_id: str,
|
||||||
|
attachment_index: int,
|
||||||
|
filename: str = Form(...),
|
||||||
|
subfolder: str = Form("received_media"),
|
||||||
|
user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Re-fetch a specific attachment from IMAP (by index in the email's attachment list)
|
||||||
|
and save it to the customer's Nextcloud media folder.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
comm = await service.get_comm(comm_id)
|
||||||
|
customer_id = comm.customer_id
|
||||||
|
if not customer_id:
|
||||||
|
raise HTTPException(status_code=400, detail="This email is not linked to a customer")
|
||||||
|
|
||||||
|
ext_message_id = comm.ext_message_id
|
||||||
|
if not ext_message_id:
|
||||||
|
raise HTTPException(status_code=400, detail="No message ID stored for this email")
|
||||||
|
|
||||||
|
attachments_meta = comm.attachments or []
|
||||||
|
if attachment_index < 0 or attachment_index >= len(attachments_meta):
|
||||||
|
raise HTTPException(status_code=400, detail="Attachment index out of range")
|
||||||
|
|
||||||
|
att_meta = attachments_meta[attachment_index]
|
||||||
|
mime_type = att_meta.content_type or "application/octet-stream"
|
||||||
|
from crm.mail_accounts import account_by_key, account_by_email
|
||||||
|
account = account_by_key(comm.mail_account) or account_by_email(comm.from_addr)
|
||||||
|
if not account:
|
||||||
|
raise HTTPException(status_code=400, detail="Email account config not found for this message")
|
||||||
|
|
||||||
|
# Re-fetch from IMAP in executor
|
||||||
|
def _fetch_attachment():
|
||||||
|
import imaplib, email as _email
|
||||||
|
if account.get("imap_use_ssl"):
|
||||||
|
imap = imaplib.IMAP4_SSL(account["imap_host"], int(account["imap_port"]))
|
||||||
|
else:
|
||||||
|
imap = imaplib.IMAP4(account["imap_host"], int(account["imap_port"]))
|
||||||
|
imap.login(account["imap_username"], account["imap_password"])
|
||||||
|
imap.select(account.get("imap_inbox", "INBOX"))
|
||||||
|
|
||||||
|
# Search by Message-ID header
|
||||||
|
_, data = imap.search(None, f'HEADER Message-ID "{ext_message_id}"')
|
||||||
|
uids = data[0].split() if data[0] else []
|
||||||
|
if not uids:
|
||||||
|
raise ValueError(f"Message not found on IMAP server: {ext_message_id}")
|
||||||
|
|
||||||
|
_, msg_data = imap.fetch(uids[0], "(RFC822)")
|
||||||
|
raw = msg_data[0][1]
|
||||||
|
msg = _email.message_from_bytes(raw)
|
||||||
|
imap.logout()
|
||||||
|
|
||||||
|
# Walk attachments in order — find the one at attachment_index
|
||||||
|
found_idx = 0
|
||||||
|
for part in msg.walk():
|
||||||
|
cd = str(part.get("Content-Disposition", ""))
|
||||||
|
if "attachment" not in cd:
|
||||||
|
continue
|
||||||
|
if found_idx == attachment_index:
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if payload is None:
|
||||||
|
raise ValueError("Attachment payload is empty")
|
||||||
|
return payload
|
||||||
|
found_idx += 1
|
||||||
|
|
||||||
|
raise ValueError(f"Attachment index {attachment_index} not found in message")
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
content = await loop.run_in_executor(None, _fetch_attachment)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"IMAP fetch failed: {e}")
|
||||||
|
|
||||||
|
folder_id = await _resolve_customer_folder(customer_id)
|
||||||
|
return await _upload_to_nc(
|
||||||
|
folder_id, subfolder, filename,
|
||||||
|
content, mime_type, customer_id,
|
||||||
|
user.name or user.sub, ["email-attachment"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkDeleteRequest(BaseModel):
|
||||||
|
ids: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
class ToggleImportantRequest(BaseModel):
|
||||||
|
important: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ToggleReadRequest(BaseModel):
|
||||||
|
read: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bulk-delete", status_code=200)
|
||||||
|
async def bulk_delete_comms(
|
||||||
|
body: BulkDeleteRequest,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
# Try remote IMAP delete for email rows first (best-effort), then local delete.
|
||||||
|
for comm_id in body.ids:
|
||||||
|
try:
|
||||||
|
comm = await service.get_comm(comm_id)
|
||||||
|
if comm.type == "email" and comm.ext_message_id:
|
||||||
|
await email_sync.delete_remote_email(
|
||||||
|
comm.ext_message_id,
|
||||||
|
comm.mail_account,
|
||||||
|
comm.from_addr,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Keep delete resilient; local delete still proceeds.
|
||||||
|
pass
|
||||||
|
count = await service.delete_comms_bulk(body.ids)
|
||||||
|
return {"deleted": count}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{comm_id}/important", response_model=CommInDB)
|
||||||
|
async def set_comm_important(
|
||||||
|
comm_id: str,
|
||||||
|
body: ToggleImportantRequest,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return await service.set_comm_important(comm_id, body.important)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{comm_id}/read", response_model=CommInDB)
|
||||||
|
async def set_comm_read(
|
||||||
|
comm_id: str,
|
||||||
|
body: ToggleReadRequest,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
comm = await service.get_comm(comm_id)
|
||||||
|
if comm.type == "email" and comm.ext_message_id:
|
||||||
|
await email_sync.set_remote_read(
|
||||||
|
comm.ext_message_id,
|
||||||
|
comm.mail_account,
|
||||||
|
comm.from_addr,
|
||||||
|
body.read,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return await service.set_comm_read(comm_id, body.read)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{comm_id}", response_model=CommInDB)
|
||||||
|
async def update_comm(
|
||||||
|
comm_id: str,
|
||||||
|
body: CommUpdate,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return await service.update_comm(comm_id, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{comm_id}", status_code=204)
|
||||||
|
async def delete_comm(
|
||||||
|
comm_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
comm = await service.get_comm(comm_id)
|
||||||
|
if comm.type == "email" and comm.ext_message_id:
|
||||||
|
await email_sync.delete_remote_email(
|
||||||
|
comm.ext_message_id,
|
||||||
|
comm.mail_account,
|
||||||
|
comm.from_addr,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await service.delete_comm(comm_id)
|
||||||
71
backend/crm/customers_router.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, Depends, Query, BackgroundTasks
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from auth.models import TokenPayload
|
||||||
|
from auth.dependencies import require_permission
|
||||||
|
from crm.models import CustomerCreate, CustomerUpdate, CustomerInDB, CustomerListResponse
|
||||||
|
from crm import service, nextcloud
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/crm/customers", tags=["crm-customers"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=CustomerListResponse)
|
||||||
|
def list_customers(
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
|
tag: Optional[str] = Query(None),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
customers = service.list_customers(search=search, tag=tag)
|
||||||
|
return CustomerListResponse(customers=customers, total=len(customers))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{customer_id}", response_model=CustomerInDB)
|
||||||
|
def get_customer(
|
||||||
|
customer_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
return service.get_customer(customer_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=CustomerInDB, status_code=201)
|
||||||
|
async def create_customer(
|
||||||
|
body: CustomerCreate,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
customer = service.create_customer(body)
|
||||||
|
if settings.nextcloud_url:
|
||||||
|
background_tasks.add_task(_init_nextcloud_folder, customer)
|
||||||
|
return customer
|
||||||
|
|
||||||
|
|
||||||
|
async def _init_nextcloud_folder(customer) -> None:
|
||||||
|
try:
|
||||||
|
nc_path = service.get_customer_nc_path(customer)
|
||||||
|
base = f"customers/{nc_path}"
|
||||||
|
for sub in ("media", "documents", "sent", "received"):
|
||||||
|
await nextcloud.ensure_folder(f"{base}/{sub}")
|
||||||
|
await nextcloud.write_info_file(base, customer.name, customer.id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Nextcloud folder init failed for customer %s: %s", customer.id, e)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{customer_id}", response_model=CustomerInDB)
|
||||||
|
def update_customer(
|
||||||
|
customer_id: str,
|
||||||
|
body: CustomerUpdate,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return service.update_customer(customer_id, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{customer_id}", status_code=204)
|
||||||
|
def delete_customer(
|
||||||
|
customer_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
service.delete_customer(customer_id)
|
||||||
837
backend/crm/email_sync.py
Normal file
@@ -0,0 +1,837 @@
|
|||||||
|
"""
|
||||||
|
IMAP email sync and SMTP email send for CRM.
|
||||||
|
Uses only stdlib imaplib/smtplib — no extra dependencies.
|
||||||
|
Sync is run in an executor to avoid blocking the event loop.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import email
|
||||||
|
import email.header
|
||||||
|
import email.utils
|
||||||
|
import html.parser
|
||||||
|
import imaplib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import smtplib
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from email.mime.base import MIMEBase
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email import encoders
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from mqtt 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")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _decode_header(raw: str) -> str:
|
||||||
|
"""Decode an RFC2047-encoded email header value."""
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
parts = email.header.decode_header(raw)
|
||||||
|
decoded = []
|
||||||
|
for part, enc in parts:
|
||||||
|
if isinstance(part, bytes):
|
||||||
|
decoded.append(part.decode(enc or "utf-8", errors="replace"))
|
||||||
|
else:
|
||||||
|
decoded.append(part)
|
||||||
|
return " ".join(decoded)
|
||||||
|
|
||||||
|
|
||||||
|
class _HTMLStripper(html.parser.HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._text = []
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
self._text.append(data)
|
||||||
|
|
||||||
|
def get_text(self):
|
||||||
|
return " ".join(self._text)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_html(html_str: str) -> str:
|
||||||
|
s = _HTMLStripper()
|
||||||
|
s.feed(html_str)
|
||||||
|
return s.get_text()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_inline_data_images(html_body: str) -> tuple[str, list[tuple[str, bytes, str]]]:
|
||||||
|
"""Replace data-URI images in HTML with cid: references and return inline parts.
|
||||||
|
Returns: (new_html, [(cid, image_bytes, mime_type), ...])
|
||||||
|
"""
|
||||||
|
if not html_body:
|
||||||
|
return "", []
|
||||||
|
|
||||||
|
inline_parts: list[tuple[str, bytes, str]] = []
|
||||||
|
seen: dict[str, str] = {} # data-uri -> cid
|
||||||
|
|
||||||
|
src_pattern = re.compile(r"""src=(['"])(data:image/[^'"]+)\1""", re.IGNORECASE)
|
||||||
|
data_pattern = re.compile(r"^data:(image/[a-zA-Z0-9.+-]+);base64,(.+)$", re.IGNORECASE | re.DOTALL)
|
||||||
|
|
||||||
|
def _replace(match: re.Match) -> str:
|
||||||
|
quote = match.group(1)
|
||||||
|
data_uri = match.group(2)
|
||||||
|
|
||||||
|
if data_uri in seen:
|
||||||
|
cid = seen[data_uri]
|
||||||
|
return f"src={quote}cid:{cid}{quote}"
|
||||||
|
|
||||||
|
parsed = data_pattern.match(data_uri)
|
||||||
|
if not parsed:
|
||||||
|
return match.group(0)
|
||||||
|
|
||||||
|
mime_type = parsed.group(1).lower()
|
||||||
|
b64_data = parsed.group(2).strip()
|
||||||
|
try:
|
||||||
|
payload = base64.b64decode(b64_data, validate=False)
|
||||||
|
except Exception:
|
||||||
|
return match.group(0)
|
||||||
|
|
||||||
|
cid = f"inline-{uuid.uuid4().hex}"
|
||||||
|
seen[data_uri] = cid
|
||||||
|
inline_parts.append((cid, payload, mime_type))
|
||||||
|
return f"src={quote}cid:{cid}{quote}"
|
||||||
|
|
||||||
|
return src_pattern.sub(_replace, html_body), inline_parts
|
||||||
|
|
||||||
|
|
||||||
|
def _load_customer_email_map() -> dict[str, str]:
|
||||||
|
"""Build a lookup of customer email -> customer_id from Firestore."""
|
||||||
|
from shared.firebase import get_db as get_firestore
|
||||||
|
firestore_db = get_firestore()
|
||||||
|
addr_to_customer: dict[str, str] = {}
|
||||||
|
for doc in firestore_db.collection("crm_customers").stream():
|
||||||
|
data = doc.to_dict() or {}
|
||||||
|
for contact in (data.get("contacts") or []):
|
||||||
|
if contact.get("type") == "email" and contact.get("value"):
|
||||||
|
addr_to_customer[str(contact["value"]).strip().lower()] = doc.id
|
||||||
|
return addr_to_customer
|
||||||
|
|
||||||
|
|
||||||
|
def _get_body(msg: email.message.Message) -> tuple[str, str]:
|
||||||
|
"""Extract (plain_text, html_body) from an email message.
|
||||||
|
Inline images (cid: references) are substituted with data-URIs so they
|
||||||
|
render correctly in a sandboxed iframe without external requests.
|
||||||
|
"""
|
||||||
|
import base64 as _b64
|
||||||
|
plain = None
|
||||||
|
html_body = None
|
||||||
|
# Map Content-ID → data-URI for inline images
|
||||||
|
cid_map: dict[str, str] = {}
|
||||||
|
|
||||||
|
if msg.is_multipart():
|
||||||
|
for part in msg.walk():
|
||||||
|
ct = part.get_content_type()
|
||||||
|
cd = str(part.get("Content-Disposition", ""))
|
||||||
|
cid = part.get("Content-ID", "").strip().strip("<>")
|
||||||
|
|
||||||
|
if "attachment" in cd:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ct == "text/plain" and plain is None:
|
||||||
|
raw = part.get_payload(decode=True)
|
||||||
|
charset = part.get_content_charset() or "utf-8"
|
||||||
|
plain = raw.decode(charset, errors="replace")
|
||||||
|
elif ct == "text/html" and html_body is None:
|
||||||
|
raw = part.get_payload(decode=True)
|
||||||
|
charset = part.get_content_charset() or "utf-8"
|
||||||
|
html_body = raw.decode(charset, errors="replace")
|
||||||
|
elif ct.startswith("image/") and cid:
|
||||||
|
raw = part.get_payload(decode=True)
|
||||||
|
if raw:
|
||||||
|
b64 = _b64.b64encode(raw).decode("ascii")
|
||||||
|
cid_map[cid] = f"data:{ct};base64,{b64}"
|
||||||
|
else:
|
||||||
|
ct = msg.get_content_type()
|
||||||
|
payload = msg.get_payload(decode=True)
|
||||||
|
charset = msg.get_content_charset() or "utf-8"
|
||||||
|
if payload:
|
||||||
|
text = payload.decode(charset, errors="replace")
|
||||||
|
if ct == "text/plain":
|
||||||
|
plain = text
|
||||||
|
elif ct == "text/html":
|
||||||
|
html_body = text
|
||||||
|
|
||||||
|
# Substitute cid: references with data-URIs
|
||||||
|
if html_body and cid_map:
|
||||||
|
for cid, data_uri in cid_map.items():
|
||||||
|
html_body = html_body.replace(f"cid:{cid}", data_uri)
|
||||||
|
|
||||||
|
plain_text = (plain or (html_body and _strip_html(html_body)) or "").strip()
|
||||||
|
return plain_text, (html_body or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_attachments(msg: email.message.Message) -> list[dict]:
|
||||||
|
"""Extract attachment info (filename, content_type, size) without storing content."""
|
||||||
|
attachments = []
|
||||||
|
if msg.is_multipart():
|
||||||
|
for part in msg.walk():
|
||||||
|
cd = str(part.get("Content-Disposition", ""))
|
||||||
|
if "attachment" in cd:
|
||||||
|
filename = part.get_filename() or "attachment"
|
||||||
|
filename = _decode_header(filename)
|
||||||
|
ct = part.get_content_type() or "application/octet-stream"
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
size = len(payload) if payload else 0
|
||||||
|
attachments.append({"filename": filename, "content_type": ct, "size": size})
|
||||||
|
return attachments
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# IMAP sync (synchronous — called via run_in_executor)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _sync_account_emails_sync(account: dict) -> tuple[list[dict], bool]:
|
||||||
|
if not account.get("imap_host") or not account.get("imap_username") or not account.get("imap_password"):
|
||||||
|
return [], False
|
||||||
|
if account.get("imap_use_ssl"):
|
||||||
|
imap = imaplib.IMAP4_SSL(account["imap_host"], int(account["imap_port"]))
|
||||||
|
else:
|
||||||
|
imap = imaplib.IMAP4(account["imap_host"], int(account["imap_port"]))
|
||||||
|
imap.login(account["imap_username"], account["imap_password"])
|
||||||
|
# readonly=True prevents marking messages as \Seen while syncing.
|
||||||
|
imap.select(account.get("imap_inbox", "INBOX"), readonly=True)
|
||||||
|
_, data = imap.search(None, "ALL")
|
||||||
|
uids = data[0].split() if data[0] else []
|
||||||
|
|
||||||
|
results = []
|
||||||
|
complete = True
|
||||||
|
for uid in uids:
|
||||||
|
try:
|
||||||
|
_, msg_data = imap.fetch(uid, "(FLAGS RFC822)")
|
||||||
|
meta = msg_data[0][0] if msg_data and isinstance(msg_data[0], tuple) else b""
|
||||||
|
raw = msg_data[0][1]
|
||||||
|
msg = email.message_from_bytes(raw)
|
||||||
|
message_id = msg.get("Message-ID", "").strip()
|
||||||
|
from_addr = email.utils.parseaddr(msg.get("From", ""))[1]
|
||||||
|
to_addrs_raw = msg.get("To", "")
|
||||||
|
to_addrs = [a for _, a in email.utils.getaddresses([to_addrs_raw])]
|
||||||
|
subject = _decode_header(msg.get("Subject", ""))
|
||||||
|
date_str = msg.get("Date", "")
|
||||||
|
try:
|
||||||
|
occurred_at = email.utils.parsedate_to_datetime(date_str).isoformat()
|
||||||
|
except Exception:
|
||||||
|
occurred_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
is_read = b"\\Seen" in (meta or b"")
|
||||||
|
try:
|
||||||
|
body, body_html = _get_body(msg)
|
||||||
|
except Exception:
|
||||||
|
body, body_html = "", ""
|
||||||
|
try:
|
||||||
|
file_attachments = _get_attachments(msg)
|
||||||
|
except Exception:
|
||||||
|
file_attachments = []
|
||||||
|
results.append({
|
||||||
|
"mail_account": account["key"],
|
||||||
|
"message_id": message_id,
|
||||||
|
"from_addr": from_addr,
|
||||||
|
"to_addrs": to_addrs,
|
||||||
|
"subject": subject,
|
||||||
|
"body": body,
|
||||||
|
"body_html": body_html,
|
||||||
|
"attachments": file_attachments,
|
||||||
|
"occurred_at": occurred_at,
|
||||||
|
"is_read": bool(is_read),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
complete = False
|
||||||
|
logger.warning(f"[EMAIL SYNC] Failed to parse message uid={uid} account={account['key']}: {e}")
|
||||||
|
imap.logout()
|
||||||
|
return results, complete
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_emails_sync() -> tuple[list[dict], bool]:
|
||||||
|
all_msgs: list[dict] = []
|
||||||
|
all_complete = True
|
||||||
|
# Deduplicate by physical inbox source. Aliases often share the same mailbox.
|
||||||
|
seen_sources: set[tuple] = set()
|
||||||
|
for acc in get_mail_accounts():
|
||||||
|
if not acc.get("sync_inbound"):
|
||||||
|
continue
|
||||||
|
source = (
|
||||||
|
(acc.get("imap_host") or "").lower(),
|
||||||
|
int(acc.get("imap_port") or 0),
|
||||||
|
(acc.get("imap_username") or "").lower(),
|
||||||
|
(acc.get("imap_inbox") or "INBOX").upper(),
|
||||||
|
)
|
||||||
|
if source in seen_sources:
|
||||||
|
continue
|
||||||
|
seen_sources.add(source)
|
||||||
|
msgs, complete = _sync_account_emails_sync(acc)
|
||||||
|
all_msgs.extend(msgs)
|
||||||
|
all_complete = all_complete and complete
|
||||||
|
return all_msgs, all_complete
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_emails() -> int:
|
||||||
|
"""
|
||||||
|
Pull emails from IMAP, match against CRM customers, store new ones.
|
||||||
|
Returns count of new entries created.
|
||||||
|
"""
|
||||||
|
if not get_mail_accounts():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
messages, fetch_complete = await loop.run_in_executor(None, _sync_emails_sync)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[EMAIL SYNC] IMAP connect/fetch failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
|
||||||
|
# Load all customer email contacts into a flat lookup: email -> customer_id
|
||||||
|
addr_to_customer = _load_customer_email_map()
|
||||||
|
|
||||||
|
# Load already-synced message-ids from DB
|
||||||
|
rows = await db.execute_fetchall(
|
||||||
|
"SELECT id, ext_message_id, COALESCE(mail_account, '') as mail_account, direction, is_read, customer_id "
|
||||||
|
"FROM crm_comms_log WHERE type='email' AND ext_message_id IS NOT NULL"
|
||||||
|
)
|
||||||
|
known_map = {
|
||||||
|
(r[1], r[2] or ""): {
|
||||||
|
"id": r[0],
|
||||||
|
"direction": r[3],
|
||||||
|
"is_read": int(r[4] or 0),
|
||||||
|
"customer_id": r[5],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
}
|
||||||
|
|
||||||
|
new_count = 0
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
server_ids_by_account: dict[str, set[str]] = {}
|
||||||
|
# Global inbound IDs from server snapshot, used to avoid account-classification delete oscillation.
|
||||||
|
inbound_server_ids: set[str] = set()
|
||||||
|
accounts = get_mail_accounts()
|
||||||
|
accounts_by_email = {a["email"].lower(): a for a in accounts}
|
||||||
|
# Initialize tracked inbound accounts even if inbox is empty.
|
||||||
|
for a in accounts:
|
||||||
|
if a.get("sync_inbound"):
|
||||||
|
server_ids_by_account[a["key"]] = set()
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
mid = msg["message_id"]
|
||||||
|
fetch_account_key = (msg.get("mail_account") or "").strip().lower()
|
||||||
|
from_addr = msg["from_addr"].lower()
|
||||||
|
to_addrs = [a.lower() for a in msg["to_addrs"]]
|
||||||
|
|
||||||
|
sender_acc = accounts_by_email.get(from_addr)
|
||||||
|
if sender_acc:
|
||||||
|
direction = "outbound"
|
||||||
|
resolved_account_key = sender_acc["key"]
|
||||||
|
customer_addrs = to_addrs
|
||||||
|
else:
|
||||||
|
direction = "inbound"
|
||||||
|
target_acc = None
|
||||||
|
for addr in to_addrs:
|
||||||
|
if addr in accounts_by_email:
|
||||||
|
target_acc = accounts_by_email[addr]
|
||||||
|
break
|
||||||
|
resolved_account_key = (target_acc["key"] if target_acc else fetch_account_key)
|
||||||
|
customer_addrs = [from_addr]
|
||||||
|
if target_acc and not target_acc.get("sync_inbound"):
|
||||||
|
# Ignore inbound for non-synced aliases (e.g. info/news).
|
||||||
|
continue
|
||||||
|
|
||||||
|
if direction == "inbound" and mid and resolved_account_key in server_ids_by_account:
|
||||||
|
server_ids_by_account[resolved_account_key].add(mid)
|
||||||
|
inbound_server_ids.add(mid)
|
||||||
|
# Find matching customer (may be None - we still store the email)
|
||||||
|
customer_id = None
|
||||||
|
for addr in customer_addrs:
|
||||||
|
if addr in addr_to_customer:
|
||||||
|
customer_id = addr_to_customer[addr]
|
||||||
|
break
|
||||||
|
|
||||||
|
if mid and (mid, resolved_account_key) in known_map:
|
||||||
|
existing = known_map[(mid, resolved_account_key)]
|
||||||
|
# Backfill customer linkage for rows created without customer_id.
|
||||||
|
if customer_id and not existing.get("customer_id"):
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE crm_comms_log SET customer_id=? WHERE id=?",
|
||||||
|
(customer_id, existing["id"]),
|
||||||
|
)
|
||||||
|
# Existing inbound message: sync read/unread state from server.
|
||||||
|
if direction == "inbound":
|
||||||
|
server_read = 1 if msg.get("is_read") else 0
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE crm_comms_log SET is_read=? "
|
||||||
|
"WHERE type='email' AND direction='inbound' AND ext_message_id=? AND mail_account=?",
|
||||||
|
(server_read, mid, resolved_account_key),
|
||||||
|
)
|
||||||
|
continue # already stored
|
||||||
|
|
||||||
|
attachments_json = json.dumps(msg.get("attachments") or [])
|
||||||
|
to_addrs_json = json.dumps(to_addrs)
|
||||||
|
|
||||||
|
entry_id = str(uuid.uuid4())
|
||||||
|
await db.execute(
|
||||||
|
"""INSERT INTO crm_comms_log
|
||||||
|
(id, customer_id, type, mail_account, direction, subject, body, body_html, attachments,
|
||||||
|
ext_message_id, from_addr, to_addrs, logged_by, occurred_at, created_at, is_read)
|
||||||
|
VALUES (?, ?, 'email', ?, ?, ?, ?, ?, ?, ?, ?, ?, 'system', ?, ?, ?)""",
|
||||||
|
(entry_id, customer_id, resolved_account_key, direction, msg["subject"], msg["body"],
|
||||||
|
msg.get("body_html", ""), attachments_json,
|
||||||
|
mid, from_addr, to_addrs_json, msg["occurred_at"], now, 1 if msg.get("is_read") else 0),
|
||||||
|
)
|
||||||
|
new_count += 1
|
||||||
|
|
||||||
|
# Mirror remote deletes based on global inbound message-id snapshot.
|
||||||
|
# To avoid transient IMAP inconsistency causing add/remove oscillation,
|
||||||
|
# require two consecutive "missing" syncs before local deletion.
|
||||||
|
sync_keys = [a["key"] for a in accounts if a.get("sync_inbound")]
|
||||||
|
if sync_keys and fetch_complete:
|
||||||
|
placeholders = ",".join("?" for _ in sync_keys)
|
||||||
|
local_rows = await db.execute_fetchall(
|
||||||
|
f"SELECT id, ext_message_id, mail_account FROM crm_comms_log "
|
||||||
|
f"WHERE type='email' AND direction='inbound' AND mail_account IN ({placeholders}) "
|
||||||
|
"AND ext_message_id IS NOT NULL",
|
||||||
|
sync_keys,
|
||||||
|
)
|
||||||
|
to_delete: list[str] = []
|
||||||
|
for row in local_rows:
|
||||||
|
row_id, ext_id, acc_key = row[0], row[1], row[2]
|
||||||
|
if not ext_id:
|
||||||
|
continue
|
||||||
|
state_key = f"missing_email::{acc_key}::{ext_id}"
|
||||||
|
if ext_id in inbound_server_ids:
|
||||||
|
await db.execute("DELETE FROM crm_sync_state WHERE key = ?", (state_key,))
|
||||||
|
continue
|
||||||
|
prev = await db.execute_fetchall("SELECT value FROM crm_sync_state WHERE key = ?", (state_key,))
|
||||||
|
prev_count = int(prev[0][0]) if prev and (prev[0][0] or "").isdigit() else 0
|
||||||
|
new_count = prev_count + 1
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO crm_sync_state (key, value) VALUES (?, ?) "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||||
|
(state_key, str(new_count)),
|
||||||
|
)
|
||||||
|
if new_count >= 2:
|
||||||
|
to_delete.append(row_id)
|
||||||
|
await db.execute("DELETE FROM crm_sync_state WHERE key = ?", (state_key,))
|
||||||
|
if to_delete:
|
||||||
|
del_ph = ",".join("?" for _ in to_delete)
|
||||||
|
await db.execute(f"DELETE FROM crm_comms_log WHERE id IN ({del_ph})", to_delete)
|
||||||
|
|
||||||
|
if new_count or server_ids_by_account:
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Update last sync time
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO crm_sync_state (key, value) VALUES ('last_email_sync', ?) "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||||
|
(now,),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info(f"[EMAIL SYNC] Done — {new_count} new emails stored")
|
||||||
|
return new_count
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lightweight new-mail check (synchronous — called via run_in_executor)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _check_server_count_sync() -> int:
|
||||||
|
# Keep this for backward compatibility; no longer used by check_new_emails().
|
||||||
|
total = 0
|
||||||
|
seen_sources: set[tuple] = set()
|
||||||
|
for acc in get_mail_accounts():
|
||||||
|
if not acc.get("sync_inbound"):
|
||||||
|
continue
|
||||||
|
source = (
|
||||||
|
(acc.get("imap_host") or "").lower(),
|
||||||
|
int(acc.get("imap_port") or 0),
|
||||||
|
(acc.get("imap_username") or "").lower(),
|
||||||
|
(acc.get("imap_inbox") or "INBOX").upper(),
|
||||||
|
)
|
||||||
|
if source in seen_sources:
|
||||||
|
continue
|
||||||
|
seen_sources.add(source)
|
||||||
|
if acc.get("imap_use_ssl"):
|
||||||
|
imap = imaplib.IMAP4_SSL(acc["imap_host"], int(acc["imap_port"]))
|
||||||
|
else:
|
||||||
|
imap = imaplib.IMAP4(acc["imap_host"], int(acc["imap_port"]))
|
||||||
|
imap.login(acc["imap_username"], acc["imap_password"])
|
||||||
|
imap.select(acc.get("imap_inbox", "INBOX"), readonly=True)
|
||||||
|
_, data = imap.search(None, "ALL")
|
||||||
|
total += len(data[0].split()) if data[0] else 0
|
||||||
|
imap.logout()
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
async def check_new_emails() -> dict:
|
||||||
|
"""
|
||||||
|
Compare server message count vs. locally stored count.
|
||||||
|
Returns {"new_count": int} — does NOT download or store anything.
|
||||||
|
"""
|
||||||
|
if not get_mail_accounts():
|
||||||
|
return {"new_count": 0}
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
# Reuse same account-resolution logic as sync to avoid false positives.
|
||||||
|
messages, _ = await loop.run_in_executor(None, _sync_emails_sync)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[EMAIL CHECK] IMAP check failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
accounts = get_mail_accounts()
|
||||||
|
accounts_by_email = {a["email"].lower(): a for a in accounts}
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
rows = await db.execute_fetchall(
|
||||||
|
"SELECT ext_message_id, COALESCE(mail_account, '') as mail_account FROM crm_comms_log "
|
||||||
|
"WHERE type='email' AND ext_message_id IS NOT NULL"
|
||||||
|
)
|
||||||
|
known_ids = {(r[0], r[1] or "") for r in rows}
|
||||||
|
|
||||||
|
new_count = 0
|
||||||
|
for msg in messages:
|
||||||
|
mid = (msg.get("message_id") or "").strip()
|
||||||
|
if not mid:
|
||||||
|
continue
|
||||||
|
fetch_account_key = (msg.get("mail_account") or "").strip().lower()
|
||||||
|
from_addr = (msg.get("from_addr") or "").lower()
|
||||||
|
to_addrs = [(a or "").lower() for a in (msg.get("to_addrs") or [])]
|
||||||
|
|
||||||
|
sender_acc = accounts_by_email.get(from_addr)
|
||||||
|
if sender_acc:
|
||||||
|
# Outbound copy in mailbox; not part of "new inbound mail" banner.
|
||||||
|
continue
|
||||||
|
|
||||||
|
target_acc = None
|
||||||
|
for addr in to_addrs:
|
||||||
|
if addr in accounts_by_email:
|
||||||
|
target_acc = accounts_by_email[addr]
|
||||||
|
break
|
||||||
|
resolved_account_key = (target_acc["key"] if target_acc else fetch_account_key)
|
||||||
|
if target_acc and not target_acc.get("sync_inbound"):
|
||||||
|
continue
|
||||||
|
if (mid, resolved_account_key) not in known_ids:
|
||||||
|
new_count += 1
|
||||||
|
|
||||||
|
return {"new_count": new_count}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SMTP send (synchronous — called via run_in_executor)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _append_to_sent_sync(account: dict, raw_message: bytes) -> None:
|
||||||
|
"""Best-effort append of sent MIME message to IMAP Sent folder."""
|
||||||
|
if not raw_message:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if account.get("imap_use_ssl"):
|
||||||
|
imap = imaplib.IMAP4_SSL(account["imap_host"], int(account["imap_port"]))
|
||||||
|
else:
|
||||||
|
imap = imaplib.IMAP4(account["imap_host"], int(account["imap_port"]))
|
||||||
|
imap.login(account["imap_username"], account["imap_password"])
|
||||||
|
|
||||||
|
preferred = str(account.get("imap_sent") or "Sent").strip() or "Sent"
|
||||||
|
candidates = [preferred, "Sent", "INBOX.Sent", "Sent Items", "INBOX.Sent Items"]
|
||||||
|
seen = set()
|
||||||
|
ordered_candidates = []
|
||||||
|
for name in candidates:
|
||||||
|
key = name.lower()
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
ordered_candidates.append(name)
|
||||||
|
|
||||||
|
appended = False
|
||||||
|
for mailbox in ordered_candidates:
|
||||||
|
try:
|
||||||
|
status, _ = imap.append(mailbox, "\\Seen", None, raw_message)
|
||||||
|
if status == "OK":
|
||||||
|
appended = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not appended:
|
||||||
|
logger.warning("[EMAIL SEND] Sent copy append failed for account=%s", account.get("key"))
|
||||||
|
imap.logout()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[EMAIL SEND] IMAP append to Sent failed for account=%s: %s", account.get("key"), e)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_email_sync(
|
||||||
|
account: dict,
|
||||||
|
to: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
body_html: str,
|
||||||
|
cc: List[str],
|
||||||
|
file_attachments: Optional[List[Tuple[str, bytes, str]]] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Send via SMTP. Returns the Message-ID header.
|
||||||
|
file_attachments: list of (filename, content_bytes, mime_type)
|
||||||
|
"""
|
||||||
|
html_with_cids, inline_images = _extract_inline_data_images(body_html or "")
|
||||||
|
|
||||||
|
# Build body tree:
|
||||||
|
# - with inline images: related(alternative(text/plain, text/html), image parts)
|
||||||
|
# - without inline images: alternative(text/plain, text/html)
|
||||||
|
if inline_images:
|
||||||
|
body_part = MIMEMultipart("related")
|
||||||
|
alt_part = MIMEMultipart("alternative")
|
||||||
|
alt_part.attach(MIMEText(body, "plain", "utf-8"))
|
||||||
|
if html_with_cids:
|
||||||
|
alt_part.attach(MIMEText(html_with_cids, "html", "utf-8"))
|
||||||
|
body_part.attach(alt_part)
|
||||||
|
|
||||||
|
for idx, (cid, content, mime_type) in enumerate(inline_images, start=1):
|
||||||
|
maintype, _, subtype = mime_type.partition("/")
|
||||||
|
img_part = MIMEBase(maintype or "image", subtype or "png")
|
||||||
|
img_part.set_payload(content)
|
||||||
|
encoders.encode_base64(img_part)
|
||||||
|
img_part.add_header("Content-ID", f"<{cid}>")
|
||||||
|
img_part.add_header("Content-Disposition", "inline", filename=f"inline-{idx}.{subtype or 'png'}")
|
||||||
|
body_part.attach(img_part)
|
||||||
|
else:
|
||||||
|
body_part = MIMEMultipart("alternative")
|
||||||
|
body_part.attach(MIMEText(body, "plain", "utf-8"))
|
||||||
|
if body_html:
|
||||||
|
body_part.attach(MIMEText(body_html, "html", "utf-8"))
|
||||||
|
|
||||||
|
# Wrap with mixed only when classic file attachments exist.
|
||||||
|
if file_attachments:
|
||||||
|
msg = MIMEMultipart("mixed")
|
||||||
|
msg.attach(body_part)
|
||||||
|
else:
|
||||||
|
msg = body_part
|
||||||
|
|
||||||
|
from_addr = account["email"]
|
||||||
|
msg["From"] = from_addr
|
||||||
|
msg["To"] = to
|
||||||
|
msg["Subject"] = subject
|
||||||
|
if cc:
|
||||||
|
msg["Cc"] = ", ".join(cc)
|
||||||
|
|
||||||
|
msg_id = f"<{uuid.uuid4()}@bellsystems>"
|
||||||
|
msg["Message-ID"] = msg_id
|
||||||
|
|
||||||
|
# Attach files
|
||||||
|
for filename, content, mime_type in (file_attachments or []):
|
||||||
|
maintype, _, subtype = mime_type.partition("/")
|
||||||
|
part = MIMEBase(maintype or "application", subtype or "octet-stream")
|
||||||
|
part.set_payload(content)
|
||||||
|
encoders.encode_base64(part)
|
||||||
|
part.add_header("Content-Disposition", "attachment", filename=filename)
|
||||||
|
msg.attach(part)
|
||||||
|
|
||||||
|
recipients = [to] + cc
|
||||||
|
raw_for_append = msg.as_bytes()
|
||||||
|
if account.get("smtp_use_tls"):
|
||||||
|
server = smtplib.SMTP(account["smtp_host"], int(account["smtp_port"]))
|
||||||
|
server.starttls()
|
||||||
|
else:
|
||||||
|
server = smtplib.SMTP_SSL(account["smtp_host"], int(account["smtp_port"]))
|
||||||
|
|
||||||
|
server.login(account["smtp_username"], account["smtp_password"])
|
||||||
|
server.sendmail(from_addr, recipients, msg.as_string())
|
||||||
|
server.quit()
|
||||||
|
_append_to_sent_sync(account, raw_for_append)
|
||||||
|
|
||||||
|
return msg_id
|
||||||
|
|
||||||
|
|
||||||
|
async def send_email(
|
||||||
|
customer_id: str | None,
|
||||||
|
from_account: str | None,
|
||||||
|
to: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
body_html: str,
|
||||||
|
cc: List[str],
|
||||||
|
sent_by: str,
|
||||||
|
file_attachments: Optional[List[Tuple[str, bytes, str]]] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Send an email and record it in crm_comms_log. Returns the new log entry.
|
||||||
|
file_attachments: list of (filename, content_bytes, mime_type)
|
||||||
|
"""
|
||||||
|
accounts = get_mail_accounts()
|
||||||
|
if not accounts:
|
||||||
|
raise RuntimeError("SMTP not configured")
|
||||||
|
account = account_by_key(from_account) if from_account else None
|
||||||
|
if not account:
|
||||||
|
raise RuntimeError("Please select a valid sender account")
|
||||||
|
if not account.get("allow_send"):
|
||||||
|
raise RuntimeError("Selected account is not allowed to send")
|
||||||
|
if not account.get("smtp_host") or not account.get("smtp_username") or not account.get("smtp_password"):
|
||||||
|
raise RuntimeError("SMTP not configured for selected account")
|
||||||
|
|
||||||
|
# If the caller did not provide a customer_id (e.g. compose from Mail page),
|
||||||
|
# auto-link by matching recipient addresses against CRM customer emails.
|
||||||
|
resolved_customer_id = customer_id
|
||||||
|
if not resolved_customer_id:
|
||||||
|
addr_to_customer = _load_customer_email_map()
|
||||||
|
rcpts = [to, *cc]
|
||||||
|
parsed_rcpts = [addr for _, addr in email.utils.getaddresses(rcpts) if addr]
|
||||||
|
for addr in parsed_rcpts:
|
||||||
|
key = (addr or "").strip().lower()
|
||||||
|
if key in addr_to_customer:
|
||||||
|
resolved_customer_id = addr_to_customer[key]
|
||||||
|
break
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
import functools
|
||||||
|
msg_id = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
functools.partial(_send_email_sync, account, to, subject, body, body_html, cc, file_attachments or []),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upload attachments to Nextcloud and register in crm_media
|
||||||
|
comm_attachments = []
|
||||||
|
if file_attachments and resolved_customer_id:
|
||||||
|
from crm import nextcloud, service
|
||||||
|
from crm.models import MediaCreate, MediaDirection
|
||||||
|
from shared.firebase import get_db as get_firestore
|
||||||
|
firestore_db = get_firestore()
|
||||||
|
doc = firestore_db.collection("crm_customers").document(resolved_customer_id).get()
|
||||||
|
if doc.exists:
|
||||||
|
data = doc.to_dict()
|
||||||
|
# Build a minimal CustomerInDB-like object for get_customer_nc_path
|
||||||
|
folder_id = data.get("folder_id") or resolved_customer_id
|
||||||
|
nc_path = folder_id
|
||||||
|
|
||||||
|
for filename, content, mime_type in file_attachments:
|
||||||
|
# images/video → sent_media, everything else → documents
|
||||||
|
if mime_type.startswith("image/") or mime_type.startswith("video/"):
|
||||||
|
subfolder = "sent_media"
|
||||||
|
else:
|
||||||
|
subfolder = "documents"
|
||||||
|
target_folder = f"customers/{nc_path}/{subfolder}"
|
||||||
|
file_path = f"{target_folder}/{filename}"
|
||||||
|
try:
|
||||||
|
await nextcloud.ensure_folder(target_folder)
|
||||||
|
await nextcloud.upload_file(file_path, content, mime_type)
|
||||||
|
await service.create_media(MediaCreate(
|
||||||
|
customer_id=resolved_customer_id,
|
||||||
|
filename=filename,
|
||||||
|
nextcloud_path=file_path,
|
||||||
|
mime_type=mime_type,
|
||||||
|
direction=MediaDirection.sent,
|
||||||
|
tags=["email-attachment"],
|
||||||
|
uploaded_by=sent_by,
|
||||||
|
))
|
||||||
|
comm_attachments.append({"filename": filename, "nextcloud_path": file_path})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[EMAIL SEND] Failed to upload attachment {filename}: {e}")
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
entry_id = str(uuid.uuid4())
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
our_addr = account["email"].lower()
|
||||||
|
to_addrs_json = json.dumps([to] + cc)
|
||||||
|
attachments_json = json.dumps(comm_attachments)
|
||||||
|
await db.execute(
|
||||||
|
"""INSERT INTO crm_comms_log
|
||||||
|
(id, customer_id, type, mail_account, direction, subject, body, body_html, attachments,
|
||||||
|
ext_message_id, from_addr, to_addrs, logged_by, occurred_at, created_at)
|
||||||
|
VALUES (?, ?, 'email', ?, 'outbound', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(entry_id, resolved_customer_id, account["key"], subject, body, body_html, attachments_json, msg_id,
|
||||||
|
our_addr, to_addrs_json, sent_by, now, now),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": entry_id,
|
||||||
|
"customer_id": resolved_customer_id,
|
||||||
|
"type": "email",
|
||||||
|
"mail_account": account["key"],
|
||||||
|
"direction": "outbound",
|
||||||
|
"subject": subject,
|
||||||
|
"body": body,
|
||||||
|
"body_html": body_html,
|
||||||
|
"attachments": comm_attachments,
|
||||||
|
"ext_message_id": msg_id,
|
||||||
|
"from_addr": our_addr,
|
||||||
|
"to_addrs": [to] + cc,
|
||||||
|
"logged_by": sent_by,
|
||||||
|
"occurred_at": now,
|
||||||
|
"created_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_remote_email_sync(account: dict, ext_message_id: str) -> bool:
|
||||||
|
if not ext_message_id:
|
||||||
|
return False
|
||||||
|
if account.get("imap_use_ssl"):
|
||||||
|
imap = imaplib.IMAP4_SSL(account["imap_host"], int(account["imap_port"]))
|
||||||
|
else:
|
||||||
|
imap = imaplib.IMAP4(account["imap_host"], int(account["imap_port"]))
|
||||||
|
imap.login(account["imap_username"], account["imap_password"])
|
||||||
|
imap.select(account.get("imap_inbox", "INBOX"))
|
||||||
|
_, data = imap.search(None, f'HEADER Message-ID "{ext_message_id}"')
|
||||||
|
uids = data[0].split() if data and data[0] else []
|
||||||
|
if not uids:
|
||||||
|
imap.logout()
|
||||||
|
return False
|
||||||
|
for uid in uids:
|
||||||
|
imap.store(uid, "+FLAGS", "\\Deleted")
|
||||||
|
imap.expunge()
|
||||||
|
imap.logout()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_remote_email(ext_message_id: str, mail_account: str | None, from_addr: str | None = None) -> bool:
|
||||||
|
account = account_by_key(mail_account) if mail_account else None
|
||||||
|
if not account:
|
||||||
|
account = account_by_email(from_addr)
|
||||||
|
if not account or not account.get("imap_host"):
|
||||||
|
return False
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
return await loop.run_in_executor(None, lambda: _delete_remote_email_sync(account, ext_message_id))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[EMAIL DELETE] Failed remote delete for {ext_message_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _set_remote_read_sync(account: dict, ext_message_id: str, read: bool) -> bool:
|
||||||
|
if not ext_message_id:
|
||||||
|
return False
|
||||||
|
if account.get("imap_use_ssl"):
|
||||||
|
imap = imaplib.IMAP4_SSL(account["imap_host"], int(account["imap_port"]))
|
||||||
|
else:
|
||||||
|
imap = imaplib.IMAP4(account["imap_host"], int(account["imap_port"]))
|
||||||
|
imap.login(account["imap_username"], account["imap_password"])
|
||||||
|
imap.select(account.get("imap_inbox", "INBOX"))
|
||||||
|
_, data = imap.search(None, f'HEADER Message-ID "{ext_message_id}"')
|
||||||
|
uids = data[0].split() if data and data[0] else []
|
||||||
|
if not uids:
|
||||||
|
imap.logout()
|
||||||
|
return False
|
||||||
|
flag_op = "+FLAGS" if read else "-FLAGS"
|
||||||
|
for uid in uids:
|
||||||
|
imap.store(uid, flag_op, "\\Seen")
|
||||||
|
imap.logout()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def set_remote_read(ext_message_id: str, mail_account: str | None, from_addr: str | None, read: bool) -> bool:
|
||||||
|
account = account_by_key(mail_account) if mail_account else None
|
||||||
|
if not account:
|
||||||
|
account = account_by_email(from_addr)
|
||||||
|
if not account or not account.get("imap_host"):
|
||||||
|
return False
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
return await loop.run_in_executor(None, lambda: _set_remote_read_sync(account, ext_message_id, read))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[EMAIL READ] Failed remote read update for {ext_message_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
104
backend/crm/mail_accounts.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(v: Any, default: bool) -> bool:
|
||||||
|
if isinstance(v, bool):
|
||||||
|
return v
|
||||||
|
if isinstance(v, str):
|
||||||
|
return v.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
if v is None:
|
||||||
|
return default
|
||||||
|
return bool(v)
|
||||||
|
|
||||||
|
|
||||||
|
def get_mail_accounts() -> list[dict]:
|
||||||
|
"""
|
||||||
|
Returns normalized account dictionaries.
|
||||||
|
Falls back to legacy single-account config if MAIL_ACCOUNTS_JSON is empty.
|
||||||
|
"""
|
||||||
|
configured = settings.mail_accounts
|
||||||
|
normalized: list[dict] = []
|
||||||
|
|
||||||
|
for idx, raw in enumerate(configured):
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
key = str(raw.get("key") or "").strip().lower()
|
||||||
|
email = str(raw.get("email") or "").strip().lower()
|
||||||
|
if not key or not email:
|
||||||
|
continue
|
||||||
|
normalized.append(
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"label": str(raw.get("label") or key.title()),
|
||||||
|
"email": email,
|
||||||
|
"imap_host": raw.get("imap_host") or settings.imap_host,
|
||||||
|
"imap_port": int(raw.get("imap_port") or settings.imap_port or 993),
|
||||||
|
"imap_username": raw.get("imap_username") or email,
|
||||||
|
"imap_password": raw.get("imap_password") or settings.imap_password,
|
||||||
|
"imap_use_ssl": _bool(raw.get("imap_use_ssl"), settings.imap_use_ssl),
|
||||||
|
"imap_inbox": str(raw.get("imap_inbox") or "INBOX"),
|
||||||
|
"imap_sent": str(raw.get("imap_sent") or "Sent"),
|
||||||
|
"smtp_host": raw.get("smtp_host") or settings.smtp_host,
|
||||||
|
"smtp_port": int(raw.get("smtp_port") or settings.smtp_port or 587),
|
||||||
|
"smtp_username": raw.get("smtp_username") or email,
|
||||||
|
"smtp_password": raw.get("smtp_password") or settings.smtp_password,
|
||||||
|
"smtp_use_tls": _bool(raw.get("smtp_use_tls"), settings.smtp_use_tls),
|
||||||
|
"sync_inbound": _bool(raw.get("sync_inbound"), True),
|
||||||
|
"allow_send": _bool(raw.get("allow_send"), True),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized:
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
# Legacy single-account fallback
|
||||||
|
if settings.imap_host or settings.smtp_host:
|
||||||
|
legacy_email = (settings.smtp_username or settings.imap_username or "").strip().lower()
|
||||||
|
if legacy_email:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"key": "default",
|
||||||
|
"label": "Default",
|
||||||
|
"email": legacy_email,
|
||||||
|
"imap_host": settings.imap_host,
|
||||||
|
"imap_port": settings.imap_port,
|
||||||
|
"imap_username": settings.imap_username,
|
||||||
|
"imap_password": settings.imap_password,
|
||||||
|
"imap_use_ssl": settings.imap_use_ssl,
|
||||||
|
"imap_inbox": "INBOX",
|
||||||
|
"imap_sent": "Sent",
|
||||||
|
"smtp_host": settings.smtp_host,
|
||||||
|
"smtp_port": settings.smtp_port,
|
||||||
|
"smtp_username": settings.smtp_username,
|
||||||
|
"smtp_password": settings.smtp_password,
|
||||||
|
"smtp_use_tls": settings.smtp_use_tls,
|
||||||
|
"sync_inbound": True,
|
||||||
|
"allow_send": True,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def account_by_key(key: str | None) -> dict | None:
|
||||||
|
k = (key or "").strip().lower()
|
||||||
|
if not k:
|
||||||
|
return None
|
||||||
|
for acc in get_mail_accounts():
|
||||||
|
if acc["key"] == k:
|
||||||
|
return acc
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def account_by_email(email_addr: str | None) -> dict | None:
|
||||||
|
e = (email_addr or "").strip().lower()
|
||||||
|
if not e:
|
||||||
|
return None
|
||||||
|
for acc in get_mail_accounts():
|
||||||
|
if acc["email"] == e:
|
||||||
|
return acc
|
||||||
|
return None
|
||||||
35
backend/crm/media_router.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from auth.models import TokenPayload
|
||||||
|
from auth.dependencies import require_permission
|
||||||
|
from crm.models import MediaCreate, MediaInDB, MediaListResponse
|
||||||
|
from crm import service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/crm/media", tags=["crm-media"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=MediaListResponse)
|
||||||
|
async def list_media(
|
||||||
|
customer_id: Optional[str] = Query(None),
|
||||||
|
order_id: Optional[str] = Query(None),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
items = await service.list_media(customer_id=customer_id, order_id=order_id)
|
||||||
|
return MediaListResponse(items=items, total=len(items))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=MediaInDB, status_code=201)
|
||||||
|
async def create_media(
|
||||||
|
body: MediaCreate,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return await service.create_media(body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{media_id}", status_code=204)
|
||||||
|
async def delete_media(
|
||||||
|
media_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
await service.delete_media(media_id)
|
||||||
353
backend/crm/models.py
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
from enum import Enum
|
||||||
|
from typing import List, Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCategory(str, Enum):
|
||||||
|
controller = "controller"
|
||||||
|
striker = "striker"
|
||||||
|
clock = "clock"
|
||||||
|
part = "part"
|
||||||
|
repair_service = "repair_service"
|
||||||
|
|
||||||
|
|
||||||
|
class CostLineItem(BaseModel):
|
||||||
|
name: str
|
||||||
|
quantity: float = 1
|
||||||
|
price: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCosts(BaseModel):
|
||||||
|
labor_hours: Optional[float] = None
|
||||||
|
labor_rate: Optional[float] = None
|
||||||
|
items: List[CostLineItem] = []
|
||||||
|
total: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProductStock(BaseModel):
|
||||||
|
on_hand: int = 0
|
||||||
|
reserved: int = 0
|
||||||
|
available: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
sku: Optional[str] = None
|
||||||
|
category: ProductCategory
|
||||||
|
description: Optional[str] = None
|
||||||
|
price: float
|
||||||
|
currency: str = "EUR"
|
||||||
|
costs: Optional[ProductCosts] = None
|
||||||
|
stock: Optional[ProductStock] = None
|
||||||
|
active: bool = True
|
||||||
|
status: str = "active" # active | discontinued | planned
|
||||||
|
photo_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProductUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
sku: Optional[str] = None
|
||||||
|
category: Optional[ProductCategory] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
price: Optional[float] = None
|
||||||
|
currency: Optional[str] = None
|
||||||
|
costs: Optional[ProductCosts] = None
|
||||||
|
stock: Optional[ProductStock] = None
|
||||||
|
active: Optional[bool] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
photo_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProductInDB(ProductCreate):
|
||||||
|
id: str
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class ProductListResponse(BaseModel):
|
||||||
|
products: List[ProductInDB]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
# ── Customers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ContactType(str, Enum):
|
||||||
|
email = "email"
|
||||||
|
phone = "phone"
|
||||||
|
whatsapp = "whatsapp"
|
||||||
|
other = "other"
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerContact(BaseModel):
|
||||||
|
type: ContactType
|
||||||
|
label: str
|
||||||
|
value: str
|
||||||
|
primary: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerNote(BaseModel):
|
||||||
|
text: str
|
||||||
|
by: str
|
||||||
|
at: str
|
||||||
|
|
||||||
|
|
||||||
|
class OwnedItemType(str, Enum):
|
||||||
|
console_device = "console_device"
|
||||||
|
product = "product"
|
||||||
|
freetext = "freetext"
|
||||||
|
|
||||||
|
|
||||||
|
class OwnedItem(BaseModel):
|
||||||
|
type: OwnedItemType
|
||||||
|
# console_device fields
|
||||||
|
device_id: Optional[str] = None
|
||||||
|
label: Optional[str] = None
|
||||||
|
# product fields
|
||||||
|
product_id: Optional[str] = None
|
||||||
|
product_name: Optional[str] = None
|
||||||
|
quantity: Optional[int] = None
|
||||||
|
serial_numbers: Optional[List[str]] = None
|
||||||
|
# freetext fields
|
||||||
|
description: Optional[str] = None
|
||||||
|
serial_number: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerLocation(BaseModel):
|
||||||
|
city: Optional[str] = None
|
||||||
|
country: Optional[str] = None
|
||||||
|
region: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerCreate(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
name: str
|
||||||
|
surname: Optional[str] = None
|
||||||
|
organization: Optional[str] = None
|
||||||
|
contacts: List[CustomerContact] = []
|
||||||
|
notes: List[CustomerNote] = []
|
||||||
|
location: Optional[CustomerLocation] = None
|
||||||
|
language: str = "el"
|
||||||
|
tags: List[str] = []
|
||||||
|
owned_items: List[OwnedItem] = []
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerUpdate(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
name: Optional[str] = None
|
||||||
|
surname: Optional[str] = None
|
||||||
|
organization: Optional[str] = None
|
||||||
|
contacts: Optional[List[CustomerContact]] = None
|
||||||
|
notes: Optional[List[CustomerNote]] = None
|
||||||
|
location: Optional[CustomerLocation] = None
|
||||||
|
language: Optional[str] = None
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
owned_items: Optional[List[OwnedItem]] = None
|
||||||
|
linked_user_ids: Optional[List[str]] = None
|
||||||
|
nextcloud_folder: Optional[str] = None
|
||||||
|
# folder_id intentionally excluded from update — set once at creation
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerInDB(CustomerCreate):
|
||||||
|
id: str
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerListResponse(BaseModel):
|
||||||
|
customers: List[CustomerInDB]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
# ── Orders ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class OrderStatus(str, Enum):
|
||||||
|
draft = "draft"
|
||||||
|
confirmed = "confirmed"
|
||||||
|
in_production = "in_production"
|
||||||
|
shipped = "shipped"
|
||||||
|
delivered = "delivered"
|
||||||
|
cancelled = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentStatus(str, Enum):
|
||||||
|
pending = "pending"
|
||||||
|
partial = "partial"
|
||||||
|
paid = "paid"
|
||||||
|
|
||||||
|
|
||||||
|
class OrderDiscount(BaseModel):
|
||||||
|
type: str # "percentage" | "fixed"
|
||||||
|
value: float = 0
|
||||||
|
reason: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrderShipping(BaseModel):
|
||||||
|
method: Optional[str] = None
|
||||||
|
tracking_number: Optional[str] = None
|
||||||
|
carrier: Optional[str] = None
|
||||||
|
shipped_at: Optional[str] = None
|
||||||
|
delivered_at: Optional[str] = None
|
||||||
|
destination: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrderItem(BaseModel):
|
||||||
|
type: str # console_device | product | freetext
|
||||||
|
product_id: Optional[str] = None
|
||||||
|
product_name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
quantity: int = 1
|
||||||
|
unit_price: float = 0.0
|
||||||
|
serial_numbers: List[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class OrderCreate(BaseModel):
|
||||||
|
customer_id: str
|
||||||
|
order_number: Optional[str] = None
|
||||||
|
status: OrderStatus = OrderStatus.draft
|
||||||
|
items: List[OrderItem] = []
|
||||||
|
subtotal: float = 0
|
||||||
|
discount: Optional[OrderDiscount] = None
|
||||||
|
total_price: float = 0
|
||||||
|
currency: str = "EUR"
|
||||||
|
shipping: Optional[OrderShipping] = None
|
||||||
|
payment_status: PaymentStatus = PaymentStatus.pending
|
||||||
|
invoice_path: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrderUpdate(BaseModel):
|
||||||
|
customer_id: Optional[str] = None
|
||||||
|
order_number: Optional[str] = None
|
||||||
|
status: Optional[OrderStatus] = None
|
||||||
|
items: Optional[List[OrderItem]] = None
|
||||||
|
subtotal: Optional[float] = None
|
||||||
|
discount: Optional[OrderDiscount] = None
|
||||||
|
total_price: Optional[float] = None
|
||||||
|
currency: Optional[str] = None
|
||||||
|
shipping: Optional[OrderShipping] = None
|
||||||
|
payment_status: Optional[PaymentStatus] = None
|
||||||
|
invoice_path: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrderInDB(OrderCreate):
|
||||||
|
id: str
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class OrderListResponse(BaseModel):
|
||||||
|
orders: List[OrderInDB]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
# ── Comms Log ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class CommType(str, Enum):
|
||||||
|
email = "email"
|
||||||
|
whatsapp = "whatsapp"
|
||||||
|
call = "call"
|
||||||
|
sms = "sms"
|
||||||
|
note = "note"
|
||||||
|
in_person = "in_person"
|
||||||
|
|
||||||
|
|
||||||
|
class CommDirection(str, Enum):
|
||||||
|
inbound = "inbound"
|
||||||
|
outbound = "outbound"
|
||||||
|
internal = "internal"
|
||||||
|
|
||||||
|
|
||||||
|
class CommAttachment(BaseModel):
|
||||||
|
filename: str
|
||||||
|
nextcloud_path: Optional[str] = None
|
||||||
|
content_type: Optional[str] = None
|
||||||
|
size: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CommCreate(BaseModel):
|
||||||
|
customer_id: Optional[str] = None
|
||||||
|
type: CommType
|
||||||
|
mail_account: Optional[str] = None
|
||||||
|
direction: CommDirection
|
||||||
|
subject: Optional[str] = None
|
||||||
|
body: Optional[str] = None
|
||||||
|
body_html: Optional[str] = None
|
||||||
|
attachments: List[CommAttachment] = []
|
||||||
|
ext_message_id: Optional[str] = None
|
||||||
|
from_addr: Optional[str] = None
|
||||||
|
to_addrs: Optional[List[str]] = None
|
||||||
|
logged_by: Optional[str] = None
|
||||||
|
occurred_at: Optional[str] = None # defaults to now if not provided
|
||||||
|
|
||||||
|
|
||||||
|
class CommUpdate(BaseModel):
|
||||||
|
subject: Optional[str] = None
|
||||||
|
body: Optional[str] = None
|
||||||
|
occurred_at: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CommInDB(BaseModel):
|
||||||
|
id: str
|
||||||
|
customer_id: Optional[str] = None
|
||||||
|
type: CommType
|
||||||
|
mail_account: Optional[str] = None
|
||||||
|
direction: CommDirection
|
||||||
|
subject: Optional[str] = None
|
||||||
|
body: Optional[str] = None
|
||||||
|
body_html: Optional[str] = None
|
||||||
|
attachments: List[CommAttachment] = []
|
||||||
|
ext_message_id: Optional[str] = None
|
||||||
|
from_addr: Optional[str] = None
|
||||||
|
to_addrs: Optional[List[str]] = None
|
||||||
|
logged_by: Optional[str] = None
|
||||||
|
occurred_at: str
|
||||||
|
created_at: str
|
||||||
|
is_important: bool = False
|
||||||
|
is_read: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class CommListResponse(BaseModel):
|
||||||
|
entries: List[CommInDB]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
# ── Media ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class MediaDirection(str, Enum):
|
||||||
|
received = "received"
|
||||||
|
sent = "sent"
|
||||||
|
internal = "internal"
|
||||||
|
|
||||||
|
|
||||||
|
class MediaCreate(BaseModel):
|
||||||
|
customer_id: Optional[str] = None
|
||||||
|
order_id: Optional[str] = None
|
||||||
|
filename: str
|
||||||
|
nextcloud_path: str
|
||||||
|
mime_type: Optional[str] = None
|
||||||
|
direction: Optional[MediaDirection] = None
|
||||||
|
tags: List[str] = []
|
||||||
|
uploaded_by: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MediaInDB(BaseModel):
|
||||||
|
id: str
|
||||||
|
customer_id: Optional[str] = None
|
||||||
|
order_id: Optional[str] = None
|
||||||
|
filename: str
|
||||||
|
nextcloud_path: str
|
||||||
|
mime_type: Optional[str] = None
|
||||||
|
direction: Optional[MediaDirection] = None
|
||||||
|
tags: List[str] = []
|
||||||
|
uploaded_by: Optional[str] = None
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class MediaListResponse(BaseModel):
|
||||||
|
items: List[MediaInDB]
|
||||||
|
total: int
|
||||||
314
backend/crm/nextcloud.py
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
"""
|
||||||
|
Nextcloud WebDAV client.
|
||||||
|
|
||||||
|
All paths passed to these functions are relative to `settings.nextcloud_base_path`.
|
||||||
|
The full WebDAV URL is:
|
||||||
|
{nextcloud_url}/remote.php/dav/files/{username}/{base_path}/{relative_path}
|
||||||
|
"""
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from typing import List
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
DAV_NS = "DAV:"
|
||||||
|
|
||||||
|
# Default timeout for all Nextcloud WebDAV requests (seconds)
|
||||||
|
_TIMEOUT = 60.0
|
||||||
|
|
||||||
|
# Shared async client — reuses TCP connections across requests so Nextcloud
|
||||||
|
# doesn't see rapid connection bursts that trigger brute-force throttling.
|
||||||
|
_http_client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client() -> httpx.AsyncClient:
|
||||||
|
global _http_client
|
||||||
|
if _http_client is None or _http_client.is_closed:
|
||||||
|
_http_client = httpx.AsyncClient(
|
||||||
|
timeout=_TIMEOUT,
|
||||||
|
follow_redirects=True,
|
||||||
|
headers={"User-Agent": "BellSystems-CP/1.0"},
|
||||||
|
)
|
||||||
|
return _http_client
|
||||||
|
|
||||||
|
|
||||||
|
async def close_client() -> None:
|
||||||
|
"""Close the shared HTTP client. Call this on application shutdown."""
|
||||||
|
global _http_client
|
||||||
|
if _http_client and not _http_client.is_closed:
|
||||||
|
await _http_client.aclose()
|
||||||
|
_http_client = None
|
||||||
|
|
||||||
|
|
||||||
|
async def keepalive_ping() -> None:
|
||||||
|
"""
|
||||||
|
Send a lightweight PROPFIND Depth:0 to the Nextcloud base folder to keep
|
||||||
|
the TCP connection alive. Safe to call even if Nextcloud is not configured.
|
||||||
|
"""
|
||||||
|
if not settings.nextcloud_url:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
url = _base_url()
|
||||||
|
client = _get_client()
|
||||||
|
await client.request(
|
||||||
|
"PROPFIND",
|
||||||
|
url,
|
||||||
|
auth=_auth(),
|
||||||
|
headers={"Depth": "0", "Content-Type": "application/xml"},
|
||||||
|
content=_PROPFIND_BODY,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[NEXTCLOUD KEEPALIVE] ping failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _dav_user() -> str:
|
||||||
|
"""The username used in the WebDAV URL path (may differ from the login username)."""
|
||||||
|
return settings.nextcloud_dav_user or settings.nextcloud_username
|
||||||
|
|
||||||
|
|
||||||
|
def _base_url() -> str:
|
||||||
|
if not settings.nextcloud_url:
|
||||||
|
raise HTTPException(status_code=503, detail="Nextcloud not configured")
|
||||||
|
return (
|
||||||
|
f"{settings.nextcloud_url.rstrip('/')}"
|
||||||
|
f"/remote.php/dav/files/{_dav_user()}"
|
||||||
|
f"/{settings.nextcloud_base_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _auth() -> tuple[str, str]:
|
||||||
|
return (settings.nextcloud_username, settings.nextcloud_password)
|
||||||
|
|
||||||
|
|
||||||
|
def _full_url(relative_path: str) -> str:
|
||||||
|
"""Build full WebDAV URL for a relative path."""
|
||||||
|
path = relative_path.strip("/")
|
||||||
|
base = _base_url()
|
||||||
|
return f"{base}/{path}" if path else base
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_propfind(xml_bytes: bytes, base_path_prefix: str) -> List[dict]:
|
||||||
|
"""
|
||||||
|
Parse a PROPFIND XML response.
|
||||||
|
Returns list of file/folder entries, skipping the root itself.
|
||||||
|
"""
|
||||||
|
root = ET.fromstring(xml_bytes)
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# The prefix we need to strip from D:href to get the relative path back
|
||||||
|
# href looks like: /remote.php/dav/files/user/BellSystems/Console/customers/abc/
|
||||||
|
dav_prefix = (
|
||||||
|
f"/remote.php/dav/files/{_dav_user()}"
|
||||||
|
f"/{settings.nextcloud_base_path}/"
|
||||||
|
)
|
||||||
|
|
||||||
|
for response in root.findall(f"{{{DAV_NS}}}response"):
|
||||||
|
href_el = response.find(f"{{{DAV_NS}}}href")
|
||||||
|
if href_el is None:
|
||||||
|
continue
|
||||||
|
href = unquote(href_el.text or "")
|
||||||
|
|
||||||
|
# Strip DAV prefix to get relative path within base_path
|
||||||
|
if href.startswith(dav_prefix):
|
||||||
|
rel = href[len(dav_prefix):].rstrip("/")
|
||||||
|
else:
|
||||||
|
rel = href
|
||||||
|
|
||||||
|
# Skip the folder itself (the root of the PROPFIND request)
|
||||||
|
if rel == base_path_prefix.strip("/"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
propstat = response.find(f"{{{DAV_NS}}}propstat")
|
||||||
|
if propstat is None:
|
||||||
|
continue
|
||||||
|
prop = propstat.find(f"{{{DAV_NS}}}prop")
|
||||||
|
if prop is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# is_dir: resourcetype contains D:collection
|
||||||
|
resource_type = prop.find(f"{{{DAV_NS}}}resourcetype")
|
||||||
|
is_dir = resource_type is not None and resource_type.find(f"{{{DAV_NS}}}collection") is not None
|
||||||
|
|
||||||
|
content_type_el = prop.find(f"{{{DAV_NS}}}getcontenttype")
|
||||||
|
mime_type = content_type_el.text if content_type_el is not None else (
|
||||||
|
"inode/directory" if is_dir else "application/octet-stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
size_el = prop.find(f"{{{DAV_NS}}}getcontentlength")
|
||||||
|
size = int(size_el.text) if size_el is not None and size_el.text else 0
|
||||||
|
|
||||||
|
modified_el = prop.find(f"{{{DAV_NS}}}getlastmodified")
|
||||||
|
last_modified = modified_el.text if modified_el is not None else None
|
||||||
|
|
||||||
|
filename = rel.split("/")[-1] if rel else ""
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"filename": filename,
|
||||||
|
"path": rel,
|
||||||
|
"mime_type": mime_type,
|
||||||
|
"size": size,
|
||||||
|
"last_modified": last_modified,
|
||||||
|
"is_dir": is_dir,
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_folder(relative_path: str) -> None:
|
||||||
|
"""
|
||||||
|
Create a folder (and all parents) in Nextcloud via MKCOL.
|
||||||
|
Includes the base_path segments so the full hierarchy is created from scratch.
|
||||||
|
Silently succeeds if folders already exist.
|
||||||
|
"""
|
||||||
|
# Build the complete path list: base_path segments + relative_path segments
|
||||||
|
base_parts = settings.nextcloud_base_path.strip("/").split("/")
|
||||||
|
rel_parts = relative_path.strip("/").split("/") if relative_path.strip("/") else []
|
||||||
|
all_parts = base_parts + rel_parts
|
||||||
|
|
||||||
|
dav_root = f"{settings.nextcloud_url.rstrip('/')}/remote.php/dav/files/{_dav_user()}"
|
||||||
|
client = _get_client()
|
||||||
|
built = ""
|
||||||
|
for part in all_parts:
|
||||||
|
built = f"{built}/{part}" if built else part
|
||||||
|
url = f"{dav_root}/{built}"
|
||||||
|
resp = await client.request("MKCOL", url, auth=_auth())
|
||||||
|
# 201 = created, 405/409 = already exists — both are fine
|
||||||
|
if resp.status_code not in (201, 405, 409):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail=f"Failed to create Nextcloud folder '{built}': {resp.status_code}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def write_info_file(customer_folder: str, customer_name: str, customer_id: str) -> None:
|
||||||
|
"""Write a _info.txt stub into a new customer folder for human browsability."""
|
||||||
|
content = f"Customer: {customer_name}\nID: {customer_id}\n"
|
||||||
|
await upload_file(
|
||||||
|
f"{customer_folder}/_info.txt",
|
||||||
|
content.encode("utf-8"),
|
||||||
|
"text/plain",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_PROPFIND_BODY = b"""<?xml version="1.0"?>
|
||||||
|
<D:propfind xmlns:D="DAV:">
|
||||||
|
<D:prop>
|
||||||
|
<D:resourcetype/>
|
||||||
|
<D:getcontenttype/>
|
||||||
|
<D:getcontentlength/>
|
||||||
|
<D:getlastmodified/>
|
||||||
|
</D:prop>
|
||||||
|
</D:propfind>"""
|
||||||
|
|
||||||
|
|
||||||
|
async def list_folder(relative_path: str) -> List[dict]:
|
||||||
|
"""
|
||||||
|
PROPFIND at depth=1 to list a folder's immediate children.
|
||||||
|
relative_path is relative to nextcloud_base_path.
|
||||||
|
"""
|
||||||
|
url = _full_url(relative_path)
|
||||||
|
client = _get_client()
|
||||||
|
resp = await client.request(
|
||||||
|
"PROPFIND",
|
||||||
|
url,
|
||||||
|
auth=_auth(),
|
||||||
|
headers={"Depth": "1", "Content-Type": "application/xml"},
|
||||||
|
content=_PROPFIND_BODY,
|
||||||
|
)
|
||||||
|
if resp.status_code == 404:
|
||||||
|
return []
|
||||||
|
if resp.status_code not in (207, 200):
|
||||||
|
raise HTTPException(status_code=502, detail=f"Nextcloud PROPFIND failed: {resp.status_code}")
|
||||||
|
return _parse_propfind(resp.content, relative_path)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_folder_recursive(relative_path: str) -> List[dict]:
|
||||||
|
"""
|
||||||
|
Recursively list ALL files under a folder (any depth).
|
||||||
|
Tries Depth:infinity first (single call). Falls back to manual recursion
|
||||||
|
via Depth:1 if the server returns 403/400 (some servers disable infinity).
|
||||||
|
Returns only file entries (is_dir=False).
|
||||||
|
"""
|
||||||
|
url = _full_url(relative_path)
|
||||||
|
client = _get_client()
|
||||||
|
resp = await client.request(
|
||||||
|
"PROPFIND",
|
||||||
|
url,
|
||||||
|
auth=_auth(),
|
||||||
|
headers={"Depth": "infinity", "Content-Type": "application/xml"},
|
||||||
|
content=_PROPFIND_BODY,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code in (207, 200):
|
||||||
|
all_items = _parse_propfind(resp.content, relative_path)
|
||||||
|
return [item for item in all_items if not item["is_dir"]]
|
||||||
|
|
||||||
|
# Depth:infinity not supported — fall back to recursive Depth:1
|
||||||
|
if resp.status_code in (403, 400, 412):
|
||||||
|
return await _list_recursive_fallback(relative_path)
|
||||||
|
|
||||||
|
if resp.status_code == 404:
|
||||||
|
return []
|
||||||
|
|
||||||
|
raise HTTPException(status_code=502, detail=f"Nextcloud PROPFIND failed: {resp.status_code}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _list_recursive_fallback(relative_path: str) -> List[dict]:
|
||||||
|
"""Manually recurse via Depth:1 calls when Depth:infinity is blocked."""
|
||||||
|
items = await list_folder(relative_path)
|
||||||
|
files = []
|
||||||
|
dirs = []
|
||||||
|
for item in items:
|
||||||
|
if item["is_dir"]:
|
||||||
|
dirs.append(item["path"])
|
||||||
|
else:
|
||||||
|
files.append(item)
|
||||||
|
for dir_path in dirs:
|
||||||
|
child_files = await _list_recursive_fallback(dir_path)
|
||||||
|
files.extend(child_files)
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_file(relative_path: str, content: bytes, mime_type: str) -> str:
|
||||||
|
"""
|
||||||
|
PUT a file to Nextcloud. Returns the relative_path on success.
|
||||||
|
relative_path includes filename, e.g. "customers/abc123/media/photo.jpg"
|
||||||
|
"""
|
||||||
|
url = _full_url(relative_path)
|
||||||
|
client = _get_client()
|
||||||
|
resp = await client.put(
|
||||||
|
url,
|
||||||
|
auth=_auth(),
|
||||||
|
content=content,
|
||||||
|
headers={"Content-Type": mime_type},
|
||||||
|
)
|
||||||
|
if resp.status_code not in (200, 201, 204):
|
||||||
|
raise HTTPException(status_code=502, detail=f"Nextcloud upload failed: {resp.status_code}")
|
||||||
|
return relative_path
|
||||||
|
|
||||||
|
|
||||||
|
async def download_file(relative_path: str) -> tuple[bytes, str]:
|
||||||
|
"""
|
||||||
|
GET a file from Nextcloud. Returns (bytes, mime_type).
|
||||||
|
"""
|
||||||
|
url = _full_url(relative_path)
|
||||||
|
client = _get_client()
|
||||||
|
resp = await client.get(url, auth=_auth())
|
||||||
|
if resp.status_code == 404:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found in Nextcloud")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Nextcloud download failed: {resp.status_code}")
|
||||||
|
mime = resp.headers.get("content-type", "application/octet-stream").split(";")[0].strip()
|
||||||
|
return resp.content, mime
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_file(relative_path: str) -> None:
|
||||||
|
"""DELETE a file from Nextcloud."""
|
||||||
|
url = _full_url(relative_path)
|
||||||
|
client = _get_client()
|
||||||
|
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}")
|
||||||
305
backend/crm/nextcloud_router.py
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
"""
|
||||||
|
Nextcloud WebDAV proxy endpoints.
|
||||||
|
|
||||||
|
Folder convention (all paths relative to nextcloud_base_path = BellSystems/Console):
|
||||||
|
customers/{folder_id}/media/
|
||||||
|
customers/{folder_id}/documents/
|
||||||
|
customers/{folder_id}/sent/
|
||||||
|
customers/{folder_id}/received/
|
||||||
|
|
||||||
|
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 typing import Optional
|
||||||
|
|
||||||
|
from jose import JWTError
|
||||||
|
from auth.models import TokenPayload
|
||||||
|
from auth.dependencies import require_permission
|
||||||
|
from auth.utils import decode_access_token
|
||||||
|
from crm import nextcloud, service
|
||||||
|
from crm.models import MediaCreate, MediaDirection
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/crm/nextcloud", tags=["crm-nextcloud"])
|
||||||
|
|
||||||
|
DIRECTION_MAP = {
|
||||||
|
"sent": MediaDirection.sent,
|
||||||
|
"received": MediaDirection.received,
|
||||||
|
"internal": MediaDirection.internal,
|
||||||
|
"media": MediaDirection.internal,
|
||||||
|
"documents": MediaDirection.internal,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/browse")
|
||||||
|
async def browse(
|
||||||
|
path: str = Query(..., description="Path relative to nextcloud_base_path"),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
"""List immediate children of a Nextcloud folder."""
|
||||||
|
items = await nextcloud.list_folder(path)
|
||||||
|
return {"path": path, "items": items}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/browse-all")
|
||||||
|
async def browse_all(
|
||||||
|
customer_id: str = Query(..., description="Customer ID"),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Recursively list ALL files for a customer across all subfolders and any depth.
|
||||||
|
Uses Depth:infinity (one WebDAV call) with automatic fallback to recursive Depth:1.
|
||||||
|
Each file item includes a 'subfolder' key derived from its path.
|
||||||
|
"""
|
||||||
|
customer = service.get_customer(customer_id)
|
||||||
|
nc_path = service.get_customer_nc_path(customer)
|
||||||
|
base = f"customers/{nc_path}"
|
||||||
|
|
||||||
|
all_files = await nextcloud.list_folder_recursive(base)
|
||||||
|
|
||||||
|
# Tag each file with the top-level subfolder it lives under
|
||||||
|
for item in all_files:
|
||||||
|
parts = item["path"].split("/")
|
||||||
|
# path looks like: customers/{nc_path}/{subfolder}/[...]/filename
|
||||||
|
# parts[0]=customers, parts[1]={nc_path}, parts[2]={subfolder}
|
||||||
|
item["subfolder"] = parts[2] if len(parts) > 2 else "other"
|
||||||
|
|
||||||
|
return {"items": all_files}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/file")
|
||||||
|
async def proxy_file(
|
||||||
|
request: Request,
|
||||||
|
path: str = Query(..., description="Path relative to nextcloud_base_path"),
|
||||||
|
token: Optional[str] = Query(None, description="JWT token for browser-native requests (img src, video src, a href) that cannot send an Authorization header"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Stream a file from Nextcloud through the backend (proxy).
|
||||||
|
Supports HTTP Range requests so videos can be seeked and start playing immediately.
|
||||||
|
Accepts auth via Authorization: Bearer header OR ?token= query param.
|
||||||
|
"""
|
||||||
|
if token is None:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authenticated")
|
||||||
|
try:
|
||||||
|
decode_access_token(token)
|
||||||
|
except (JWTError, KeyError):
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid token")
|
||||||
|
|
||||||
|
content, mime_type = await nextcloud.download_file(path)
|
||||||
|
total = len(content)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type=mime_type,
|
||||||
|
headers={"Accept-Ranges": "bytes", "Content-Length": str(total)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/file-put")
|
||||||
|
async def put_file(
|
||||||
|
request: Request,
|
||||||
|
path: str = Query(..., description="Path relative to nextcloud_base_path"),
|
||||||
|
token: Optional[str] = Query(None),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Overwrite a file in Nextcloud with a new body (used for TXT in-browser editing).
|
||||||
|
Auth via ?token= query param (same pattern as /file GET).
|
||||||
|
"""
|
||||||
|
if token is None:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authenticated")
|
||||||
|
try:
|
||||||
|
decode_access_token(token)
|
||||||
|
except (JWTError, KeyError):
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid token")
|
||||||
|
|
||||||
|
body = await request.body()
|
||||||
|
content_type = request.headers.get("content-type", "text/plain")
|
||||||
|
await nextcloud.upload_file(path, body, content_type)
|
||||||
|
return {"updated": path}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload")
|
||||||
|
async def upload_file(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
customer_id: str = Form(...),
|
||||||
|
subfolder: str = Form("media"), # "media" | "documents" | "sent" | "received"
|
||||||
|
direction: Optional[str] = Form(None),
|
||||||
|
tags: Optional[str] = Form(None),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Upload a file to the customer's Nextcloud folder and record it in crm_media.
|
||||||
|
Uses the customer's folder_id as the NC path (falls back to UUID for legacy records).
|
||||||
|
"""
|
||||||
|
customer = service.get_customer(customer_id)
|
||||||
|
nc_path = service.get_customer_nc_path(customer)
|
||||||
|
|
||||||
|
target_folder = f"customers/{nc_path}/{subfolder}"
|
||||||
|
file_path = f"{target_folder}/{file.filename}"
|
||||||
|
|
||||||
|
# Ensure the target subfolder exists (idempotent, fast for existing folders)
|
||||||
|
await nextcloud.ensure_folder(target_folder)
|
||||||
|
|
||||||
|
# Read and upload
|
||||||
|
content = await file.read()
|
||||||
|
mime_type = file.content_type or "application/octet-stream"
|
||||||
|
await nextcloud.upload_file(file_path, content, mime_type)
|
||||||
|
|
||||||
|
# Resolve direction
|
||||||
|
resolved_direction = None
|
||||||
|
if direction:
|
||||||
|
try:
|
||||||
|
resolved_direction = MediaDirection(direction)
|
||||||
|
except ValueError:
|
||||||
|
resolved_direction = DIRECTION_MAP.get(subfolder, MediaDirection.internal)
|
||||||
|
else:
|
||||||
|
resolved_direction = DIRECTION_MAP.get(subfolder, MediaDirection.internal)
|
||||||
|
|
||||||
|
# Save metadata record
|
||||||
|
tag_list = [t.strip() for t in tags.split(",")] if tags else []
|
||||||
|
media_record = await service.create_media(MediaCreate(
|
||||||
|
customer_id=customer_id,
|
||||||
|
filename=file.filename,
|
||||||
|
nextcloud_path=file_path,
|
||||||
|
mime_type=mime_type,
|
||||||
|
direction=resolved_direction,
|
||||||
|
tags=tag_list,
|
||||||
|
uploaded_by=_user.name,
|
||||||
|
))
|
||||||
|
|
||||||
|
return media_record
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/file")
|
||||||
|
async def delete_file(
|
||||||
|
path: str = Query(..., description="Path relative to nextcloud_base_path"),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
"""Delete a file from Nextcloud and remove the matching crm_media record if found."""
|
||||||
|
await nextcloud.delete_file(path)
|
||||||
|
|
||||||
|
# Best-effort: delete the DB record if one matches this path
|
||||||
|
media_list = await service.list_media()
|
||||||
|
for m in media_list:
|
||||||
|
if m.nextcloud_path == path:
|
||||||
|
try:
|
||||||
|
await service.delete_media(m.id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
|
||||||
|
return {"deleted": path}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/init-customer-folder")
|
||||||
|
async def init_customer_folder(
|
||||||
|
customer_id: str = Form(...),
|
||||||
|
customer_name: str = Form(...),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create the standard folder structure for a customer in Nextcloud
|
||||||
|
and write an _info.txt stub for human readability.
|
||||||
|
"""
|
||||||
|
customer = service.get_customer(customer_id)
|
||||||
|
nc_path = service.get_customer_nc_path(customer)
|
||||||
|
base = f"customers/{nc_path}"
|
||||||
|
for sub in ("media", "documents", "sent", "received"):
|
||||||
|
await nextcloud.ensure_folder(f"{base}/{sub}")
|
||||||
|
await nextcloud.write_info_file(base, customer_name, customer_id)
|
||||||
|
return {"initialized": base}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync")
|
||||||
|
async def sync_nextcloud_files(
|
||||||
|
customer_id: str = Form(...),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Scan the customer's Nextcloud folder and register any files not yet tracked in the DB.
|
||||||
|
Returns counts of newly synced and skipped (already tracked) files.
|
||||||
|
"""
|
||||||
|
customer = service.get_customer(customer_id)
|
||||||
|
nc_path = service.get_customer_nc_path(customer)
|
||||||
|
base = f"customers/{nc_path}"
|
||||||
|
|
||||||
|
# Collect all NC files recursively (handles nested folders at any depth)
|
||||||
|
all_nc_files = await nextcloud.list_folder_recursive(base)
|
||||||
|
for item in all_nc_files:
|
||||||
|
parts = item["path"].split("/")
|
||||||
|
item["_subfolder"] = parts[2] if len(parts) > 2 else "media"
|
||||||
|
|
||||||
|
# Get existing DB records for this customer
|
||||||
|
existing = await service.list_media(customer_id=customer_id)
|
||||||
|
tracked_paths = {m.nextcloud_path for m in existing}
|
||||||
|
|
||||||
|
synced = 0
|
||||||
|
skipped = 0
|
||||||
|
for f in all_nc_files:
|
||||||
|
if f["path"] in tracked_paths:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
sub = f["_subfolder"]
|
||||||
|
direction = DIRECTION_MAP.get(sub, MediaDirection.internal)
|
||||||
|
await service.create_media(MediaCreate(
|
||||||
|
customer_id=customer_id,
|
||||||
|
filename=f["filename"],
|
||||||
|
nextcloud_path=f["path"],
|
||||||
|
mime_type=f.get("mime_type") or "application/octet-stream",
|
||||||
|
direction=direction,
|
||||||
|
tags=[],
|
||||||
|
uploaded_by="nextcloud-sync",
|
||||||
|
))
|
||||||
|
synced += 1
|
||||||
|
|
||||||
|
return {"synced": synced, "skipped": skipped}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/untrack-deleted")
|
||||||
|
async def untrack_deleted_files(
|
||||||
|
customer_id: str = Form(...),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Remove DB records for files that no longer exist in Nextcloud.
|
||||||
|
Returns count of untracked records.
|
||||||
|
"""
|
||||||
|
customer = service.get_customer(customer_id)
|
||||||
|
nc_path = service.get_customer_nc_path(customer)
|
||||||
|
base = f"customers/{nc_path}"
|
||||||
|
|
||||||
|
# Collect all NC file paths recursively
|
||||||
|
all_nc_files = await nextcloud.list_folder_recursive(base)
|
||||||
|
nc_paths = {item["path"] for item in all_nc_files}
|
||||||
|
|
||||||
|
# Find DB records whose NC path no longer exists
|
||||||
|
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:
|
||||||
|
try:
|
||||||
|
await service.delete_media(m.id)
|
||||||
|
untracked += 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {"untracked": untracked}
|
||||||
57
backend/crm/orders_router.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from auth.models import TokenPayload
|
||||||
|
from auth.dependencies import require_permission
|
||||||
|
from crm.models import OrderCreate, OrderUpdate, OrderInDB, OrderListResponse
|
||||||
|
from crm import service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/crm/orders", tags=["crm-orders"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=OrderListResponse)
|
||||||
|
def list_orders(
|
||||||
|
customer_id: Optional[str] = Query(None),
|
||||||
|
status: Optional[str] = Query(None),
|
||||||
|
payment_status: Optional[str] = Query(None),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
orders = service.list_orders(
|
||||||
|
customer_id=customer_id,
|
||||||
|
status=status,
|
||||||
|
payment_status=payment_status,
|
||||||
|
)
|
||||||
|
return OrderListResponse(orders=orders, total=len(orders))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{order_id}", response_model=OrderInDB)
|
||||||
|
def get_order(
|
||||||
|
order_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
return service.get_order(order_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=OrderInDB, status_code=201)
|
||||||
|
def create_order(
|
||||||
|
body: OrderCreate,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return service.create_order(body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{order_id}", response_model=OrderInDB)
|
||||||
|
def update_order(
|
||||||
|
order_id: str,
|
||||||
|
body: OrderUpdate,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return service.update_order(order_id, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{order_id}", status_code=204)
|
||||||
|
def delete_order(
|
||||||
|
order_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
service.delete_order(order_id)
|
||||||
141
backend/crm/quotation_models.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class QuotationStatus(str, Enum):
|
||||||
|
draft = "draft"
|
||||||
|
sent = "sent"
|
||||||
|
accepted = "accepted"
|
||||||
|
rejected = "rejected"
|
||||||
|
|
||||||
|
|
||||||
|
class QuotationItemCreate(BaseModel):
|
||||||
|
product_id: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
unit_type: str = "pcs" # pcs / kg / m
|
||||||
|
unit_cost: float = 0.0
|
||||||
|
discount_percent: float = 0.0
|
||||||
|
quantity: float = 1.0
|
||||||
|
vat_percent: float = 24.0
|
||||||
|
sort_order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class QuotationItemInDB(QuotationItemCreate):
|
||||||
|
id: str
|
||||||
|
quotation_id: str
|
||||||
|
line_total: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class QuotationCreate(BaseModel):
|
||||||
|
customer_id: str
|
||||||
|
title: Optional[str] = None
|
||||||
|
subtitle: Optional[str] = None
|
||||||
|
language: str = "en" # en / gr
|
||||||
|
order_type: Optional[str] = None
|
||||||
|
shipping_method: Optional[str] = None
|
||||||
|
estimated_shipping_date: Optional[str] = None
|
||||||
|
global_discount_label: Optional[str] = None
|
||||||
|
global_discount_percent: float = 0.0
|
||||||
|
shipping_cost: float = 0.0
|
||||||
|
shipping_cost_discount: float = 0.0
|
||||||
|
install_cost: float = 0.0
|
||||||
|
install_cost_discount: float = 0.0
|
||||||
|
extras_label: Optional[str] = None
|
||||||
|
extras_cost: float = 0.0
|
||||||
|
comments: List[str] = []
|
||||||
|
quick_notes: Optional[Dict[str, Any]] = None
|
||||||
|
items: List[QuotationItemCreate] = []
|
||||||
|
# Client override fields (for this quotation only; customer record is not modified)
|
||||||
|
client_org: Optional[str] = None
|
||||||
|
client_name: Optional[str] = None
|
||||||
|
client_location: Optional[str] = None
|
||||||
|
client_phone: Optional[str] = None
|
||||||
|
client_email: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class QuotationUpdate(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
subtitle: Optional[str] = None
|
||||||
|
language: Optional[str] = None
|
||||||
|
status: Optional[QuotationStatus] = None
|
||||||
|
order_type: Optional[str] = None
|
||||||
|
shipping_method: Optional[str] = None
|
||||||
|
estimated_shipping_date: Optional[str] = None
|
||||||
|
global_discount_label: Optional[str] = None
|
||||||
|
global_discount_percent: Optional[float] = None
|
||||||
|
shipping_cost: Optional[float] = None
|
||||||
|
shipping_cost_discount: Optional[float] = None
|
||||||
|
install_cost: Optional[float] = None
|
||||||
|
install_cost_discount: Optional[float] = None
|
||||||
|
extras_label: Optional[str] = None
|
||||||
|
extras_cost: Optional[float] = None
|
||||||
|
comments: Optional[List[str]] = None
|
||||||
|
quick_notes: Optional[Dict[str, Any]] = None
|
||||||
|
items: Optional[List[QuotationItemCreate]] = None
|
||||||
|
# Client override fields
|
||||||
|
client_org: Optional[str] = None
|
||||||
|
client_name: Optional[str] = None
|
||||||
|
client_location: Optional[str] = None
|
||||||
|
client_phone: Optional[str] = None
|
||||||
|
client_email: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class QuotationInDB(BaseModel):
|
||||||
|
id: str
|
||||||
|
quotation_number: str
|
||||||
|
customer_id: str
|
||||||
|
title: Optional[str] = None
|
||||||
|
subtitle: Optional[str] = None
|
||||||
|
language: str = "en"
|
||||||
|
status: QuotationStatus = QuotationStatus.draft
|
||||||
|
order_type: Optional[str] = None
|
||||||
|
shipping_method: Optional[str] = None
|
||||||
|
estimated_shipping_date: Optional[str] = None
|
||||||
|
global_discount_label: Optional[str] = None
|
||||||
|
global_discount_percent: float = 0.0
|
||||||
|
shipping_cost: float = 0.0
|
||||||
|
shipping_cost_discount: float = 0.0
|
||||||
|
install_cost: float = 0.0
|
||||||
|
install_cost_discount: float = 0.0
|
||||||
|
extras_label: Optional[str] = None
|
||||||
|
extras_cost: float = 0.0
|
||||||
|
comments: List[str] = []
|
||||||
|
quick_notes: Dict[str, Any] = {}
|
||||||
|
subtotal_before_discount: float = 0.0
|
||||||
|
global_discount_amount: float = 0.0
|
||||||
|
new_subtotal: float = 0.0
|
||||||
|
vat_amount: float = 0.0
|
||||||
|
final_total: float = 0.0
|
||||||
|
nextcloud_pdf_path: Optional[str] = None
|
||||||
|
nextcloud_pdf_url: Optional[str] = None
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
items: List[QuotationItemInDB] = []
|
||||||
|
# Client override fields
|
||||||
|
client_org: Optional[str] = None
|
||||||
|
client_name: Optional[str] = None
|
||||||
|
client_location: Optional[str] = None
|
||||||
|
client_phone: Optional[str] = None
|
||||||
|
client_email: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class QuotationListItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
quotation_number: str
|
||||||
|
title: Optional[str] = None
|
||||||
|
customer_id: str
|
||||||
|
status: QuotationStatus
|
||||||
|
final_total: float
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
nextcloud_pdf_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class QuotationListResponse(BaseModel):
|
||||||
|
quotations: List[QuotationListItem]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class NextNumberResponse(BaseModel):
|
||||||
|
next_number: str
|
||||||
101
backend/crm/quotations_router.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from typing import Optional
|
||||||
|
import io
|
||||||
|
|
||||||
|
from auth.dependencies import require_permission
|
||||||
|
from auth.models import TokenPayload
|
||||||
|
from crm.quotation_models import (
|
||||||
|
NextNumberResponse,
|
||||||
|
QuotationCreate,
|
||||||
|
QuotationInDB,
|
||||||
|
QuotationListResponse,
|
||||||
|
QuotationUpdate,
|
||||||
|
)
|
||||||
|
from crm import quotations_service as svc
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/crm/quotations", tags=["crm-quotations"])
|
||||||
|
|
||||||
|
|
||||||
|
# IMPORTANT: Static paths must come BEFORE /{id} to avoid route collision in FastAPI
|
||||||
|
|
||||||
|
@router.get("/next-number", response_model=NextNumberResponse)
|
||||||
|
async def get_next_number(
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
"""Returns the next available quotation number (preview only — does not commit)."""
|
||||||
|
next_num = await svc.get_next_number()
|
||||||
|
return NextNumberResponse(next_number=next_num)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/customer/{customer_id}", response_model=QuotationListResponse)
|
||||||
|
async def list_quotations_for_customer(
|
||||||
|
customer_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
quotations = await svc.list_quotations(customer_id)
|
||||||
|
return QuotationListResponse(quotations=quotations, total=len(quotations))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{quotation_id}/pdf")
|
||||||
|
async def proxy_quotation_pdf(
|
||||||
|
quotation_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
"""Proxy the quotation PDF from Nextcloud to bypass browser cookie restrictions."""
|
||||||
|
pdf_bytes = await svc.get_quotation_pdf_bytes(quotation_id)
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(pdf_bytes),
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": "inline"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{quotation_id}", response_model=QuotationInDB)
|
||||||
|
async def get_quotation(
|
||||||
|
quotation_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
return await svc.get_quotation(quotation_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=QuotationInDB, status_code=201)
|
||||||
|
async def create_quotation(
|
||||||
|
body: QuotationCreate,
|
||||||
|
generate_pdf: bool = Query(False),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a quotation. Pass ?generate_pdf=true to immediately generate and upload the PDF.
|
||||||
|
"""
|
||||||
|
return await svc.create_quotation(body, generate_pdf=generate_pdf)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{quotation_id}", response_model=QuotationInDB)
|
||||||
|
async def update_quotation(
|
||||||
|
quotation_id: str,
|
||||||
|
body: QuotationUpdate,
|
||||||
|
generate_pdf: bool = Query(False),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update a quotation. Pass ?generate_pdf=true to regenerate the PDF.
|
||||||
|
"""
|
||||||
|
return await svc.update_quotation(quotation_id, body, generate_pdf=generate_pdf)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{quotation_id}", status_code=204)
|
||||||
|
async def delete_quotation(
|
||||||
|
quotation_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
await svc.delete_quotation(quotation_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{quotation_id}/regenerate-pdf", response_model=QuotationInDB)
|
||||||
|
async def regenerate_pdf(
|
||||||
|
quotation_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
"""Force PDF regeneration and re-upload to Nextcloud."""
|
||||||
|
return await svc.regenerate_pdf(quotation_id)
|
||||||
494
backend/crm/quotations_service.py
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from crm import nextcloud
|
||||||
|
from crm.quotation_models import (
|
||||||
|
QuotationCreate,
|
||||||
|
QuotationInDB,
|
||||||
|
QuotationItemCreate,
|
||||||
|
QuotationItemInDB,
|
||||||
|
QuotationListItem,
|
||||||
|
QuotationUpdate,
|
||||||
|
)
|
||||||
|
from crm.service import get_customer
|
||||||
|
from mqtt import database as mqtt_db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Path to Jinja2 templates directory (relative to this file)
|
||||||
|
_TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _d(value) -> Decimal:
|
||||||
|
"""Convert to Decimal safely."""
|
||||||
|
return Decimal(str(value if value is not None else 0))
|
||||||
|
|
||||||
|
|
||||||
|
def _float(d: Decimal) -> float:
|
||||||
|
"""Round Decimal to 2dp and return as float for storage."""
|
||||||
|
return float(d.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_totals(
|
||||||
|
items: list,
|
||||||
|
global_discount_percent: float,
|
||||||
|
shipping_cost: float,
|
||||||
|
shipping_cost_discount: float,
|
||||||
|
install_cost: float,
|
||||||
|
install_cost_discount: float,
|
||||||
|
extras_cost: float,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Calculate all monetary totals using Decimal arithmetic (ROUND_HALF_UP).
|
||||||
|
VAT is computed per-item from each item's vat_percent field.
|
||||||
|
Shipping and install costs carry 0% VAT.
|
||||||
|
Returns a dict of floats ready for DB storage.
|
||||||
|
"""
|
||||||
|
# Per-line totals and per-item VAT
|
||||||
|
item_totals = []
|
||||||
|
item_vat = Decimal(0)
|
||||||
|
for item in items:
|
||||||
|
cost = _d(item.get("unit_cost", 0))
|
||||||
|
qty = _d(item.get("quantity", 1))
|
||||||
|
disc = _d(item.get("discount_percent", 0))
|
||||||
|
net = cost * qty * (1 - disc / 100)
|
||||||
|
item_totals.append(net)
|
||||||
|
vat_pct = _d(item.get("vat_percent", 24))
|
||||||
|
item_vat += net * (vat_pct / 100)
|
||||||
|
|
||||||
|
# Shipping net (VAT = 0%)
|
||||||
|
ship_gross = _d(shipping_cost)
|
||||||
|
ship_disc = _d(shipping_cost_discount)
|
||||||
|
ship_net = ship_gross * (1 - ship_disc / 100)
|
||||||
|
|
||||||
|
# Install net (VAT = 0%)
|
||||||
|
install_gross = _d(install_cost)
|
||||||
|
install_disc = _d(install_cost_discount)
|
||||||
|
install_net = install_gross * (1 - install_disc / 100)
|
||||||
|
|
||||||
|
subtotal = sum(item_totals, Decimal(0)) + ship_net + install_net
|
||||||
|
|
||||||
|
global_disc_pct = _d(global_discount_percent)
|
||||||
|
global_disc_amount = subtotal * (global_disc_pct / 100)
|
||||||
|
new_subtotal = subtotal - global_disc_amount
|
||||||
|
|
||||||
|
# Global discount proportionally reduces VAT too
|
||||||
|
if subtotal > 0:
|
||||||
|
disc_ratio = new_subtotal / subtotal
|
||||||
|
vat_amount = item_vat * disc_ratio
|
||||||
|
else:
|
||||||
|
vat_amount = Decimal(0)
|
||||||
|
|
||||||
|
extras = _d(extras_cost)
|
||||||
|
final_total = new_subtotal + vat_amount + extras
|
||||||
|
|
||||||
|
return {
|
||||||
|
"subtotal_before_discount": _float(subtotal),
|
||||||
|
"global_discount_amount": _float(global_disc_amount),
|
||||||
|
"new_subtotal": _float(new_subtotal),
|
||||||
|
"vat_amount": _float(vat_amount),
|
||||||
|
"final_total": _float(final_total),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_line_total(item) -> float:
|
||||||
|
cost = _d(item.get("unit_cost", 0))
|
||||||
|
qty = _d(item.get("quantity", 1))
|
||||||
|
disc = _d(item.get("discount_percent", 0))
|
||||||
|
return _float(cost * qty * (1 - disc / 100))
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_quotation_number(db) -> str:
|
||||||
|
year = datetime.utcnow().year
|
||||||
|
prefix = f"QT-{year}-"
|
||||||
|
rows = await db.execute_fetchall(
|
||||||
|
"SELECT quotation_number FROM crm_quotations WHERE quotation_number LIKE ? ORDER BY quotation_number DESC LIMIT 1",
|
||||||
|
(f"{prefix}%",),
|
||||||
|
)
|
||||||
|
if rows:
|
||||||
|
last_num = rows[0][0] # e.g. "QT-2026-012"
|
||||||
|
try:
|
||||||
|
seq = int(last_num[len(prefix):]) + 1
|
||||||
|
except ValueError:
|
||||||
|
seq = 1
|
||||||
|
else:
|
||||||
|
seq = 1
|
||||||
|
return f"{prefix}{seq:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_quotation(row: dict, items: list[dict]) -> QuotationInDB:
|
||||||
|
row = dict(row)
|
||||||
|
row["comments"] = json.loads(row.get("comments") or "[]")
|
||||||
|
row["quick_notes"] = json.loads(row.get("quick_notes") or "{}")
|
||||||
|
item_models = [QuotationItemInDB(**{k: v for k, v in i.items() if k in QuotationItemInDB.model_fields}) for i in items]
|
||||||
|
return QuotationInDB(**{k: v for k, v in row.items() if k in QuotationInDB.model_fields}, items=item_models)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_list_item(row: dict) -> QuotationListItem:
|
||||||
|
return QuotationListItem(**{k: v for k, v in dict(row).items() if k in QuotationListItem.model_fields})
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_items(db, quotation_id: str) -> list[dict]:
|
||||||
|
rows = await db.execute_fetchall(
|
||||||
|
"SELECT * FROM crm_quotation_items WHERE quotation_id = ? ORDER BY sort_order ASC",
|
||||||
|
(quotation_id,),
|
||||||
|
)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public API ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_next_number() -> str:
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
return await _generate_quotation_number(db)
|
||||||
|
|
||||||
|
|
||||||
|
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 "
|
||||||
|
"FROM crm_quotations WHERE customer_id = ? ORDER BY created_at DESC",
|
||||||
|
(customer_id,),
|
||||||
|
)
|
||||||
|
return [_row_to_list_item(dict(r)) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_quotation(quotation_id: str) -> QuotationInDB:
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
rows = await db.execute_fetchall(
|
||||||
|
"SELECT * FROM crm_quotations WHERE id = ?", (quotation_id,)
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="Quotation not found")
|
||||||
|
items = await _fetch_items(db, quotation_id)
|
||||||
|
return _row_to_quotation(dict(rows[0]), items)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_quotation(data: QuotationCreate, generate_pdf: bool = False) -> QuotationInDB:
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
qid = str(uuid.uuid4())
|
||||||
|
quotation_number = await _generate_quotation_number(db)
|
||||||
|
|
||||||
|
# Build items list for calculation
|
||||||
|
items_raw = [item.model_dump() for item in data.items]
|
||||||
|
|
||||||
|
# Calculate per-item line totals
|
||||||
|
for item in items_raw:
|
||||||
|
item["line_total"] = _calc_line_total(item)
|
||||||
|
|
||||||
|
totals = _calculate_totals(
|
||||||
|
items_raw,
|
||||||
|
data.global_discount_percent,
|
||||||
|
data.shipping_cost,
|
||||||
|
data.shipping_cost_discount,
|
||||||
|
data.install_cost,
|
||||||
|
data.install_cost_discount,
|
||||||
|
data.extras_cost,
|
||||||
|
)
|
||||||
|
|
||||||
|
comments_json = json.dumps(data.comments)
|
||||||
|
quick_notes_json = json.dumps(data.quick_notes or {})
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""INSERT INTO crm_quotations (
|
||||||
|
id, quotation_number, title, subtitle, customer_id,
|
||||||
|
language, status, order_type, shipping_method, estimated_shipping_date,
|
||||||
|
global_discount_label, global_discount_percent,
|
||||||
|
shipping_cost, shipping_cost_discount, install_cost, install_cost_discount,
|
||||||
|
extras_label, extras_cost, comments, quick_notes,
|
||||||
|
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,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?, ?, ?,
|
||||||
|
?, 'draft', ?, ?, ?,
|
||||||
|
?, ?,
|
||||||
|
?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?, ?,
|
||||||
|
NULL, NULL,
|
||||||
|
?, ?, ?, ?, ?,
|
||||||
|
?, ?
|
||||||
|
)""",
|
||||||
|
(
|
||||||
|
qid, quotation_number, data.title, data.subtitle, data.customer_id,
|
||||||
|
data.language, data.order_type, data.shipping_method, data.estimated_shipping_date,
|
||||||
|
data.global_discount_label, data.global_discount_percent,
|
||||||
|
data.shipping_cost, data.shipping_cost_discount, data.install_cost, data.install_cost_discount,
|
||||||
|
data.extras_label, data.extras_cost, comments_json, quick_notes_json,
|
||||||
|
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,
|
||||||
|
now, now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert items
|
||||||
|
for i, item in enumerate(items_raw):
|
||||||
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
item_id, qid, item.get("product_id"), item.get("description"),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
quotation = await get_quotation(qid)
|
||||||
|
|
||||||
|
if generate_pdf:
|
||||||
|
quotation = await _do_generate_and_upload_pdf(quotation)
|
||||||
|
|
||||||
|
return quotation
|
||||||
|
|
||||||
|
|
||||||
|
async def update_quotation(quotation_id: str, data: QuotationUpdate, generate_pdf: bool = False) -> QuotationInDB:
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
rows = await db.execute_fetchall(
|
||||||
|
"SELECT * FROM crm_quotations WHERE id = ?", (quotation_id,)
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="Quotation not found")
|
||||||
|
|
||||||
|
existing = dict(rows[0])
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
# Merge update into existing values
|
||||||
|
update_fields = data.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
# Build SET clause — handle comments JSON separately
|
||||||
|
set_parts = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
scalar_fields = [
|
||||||
|
"title", "subtitle", "language", "status", "order_type", "shipping_method",
|
||||||
|
"estimated_shipping_date", "global_discount_label", "global_discount_percent",
|
||||||
|
"shipping_cost", "shipping_cost_discount", "install_cost",
|
||||||
|
"install_cost_discount", "extras_label", "extras_cost",
|
||||||
|
"client_org", "client_name", "client_location", "client_phone", "client_email",
|
||||||
|
]
|
||||||
|
|
||||||
|
for field in scalar_fields:
|
||||||
|
if field in update_fields:
|
||||||
|
set_parts.append(f"{field} = ?")
|
||||||
|
params.append(update_fields[field])
|
||||||
|
|
||||||
|
if "comments" in update_fields:
|
||||||
|
set_parts.append("comments = ?")
|
||||||
|
params.append(json.dumps(update_fields["comments"]))
|
||||||
|
|
||||||
|
if "quick_notes" in update_fields:
|
||||||
|
set_parts.append("quick_notes = ?")
|
||||||
|
params.append(json.dumps(update_fields["quick_notes"] or {}))
|
||||||
|
|
||||||
|
# Recalculate totals using merged values
|
||||||
|
merged = {**existing, **{k: update_fields.get(k, existing.get(k)) for k in scalar_fields}}
|
||||||
|
|
||||||
|
# If items are being updated, recalculate with new items; otherwise use existing items
|
||||||
|
if "items" in update_fields:
|
||||||
|
items_raw = [item.model_dump() for item in data.items]
|
||||||
|
for item in items_raw:
|
||||||
|
item["line_total"] = _calc_line_total(item)
|
||||||
|
else:
|
||||||
|
existing_items = await _fetch_items(db, quotation_id)
|
||||||
|
items_raw = existing_items
|
||||||
|
|
||||||
|
totals = _calculate_totals(
|
||||||
|
items_raw,
|
||||||
|
float(merged.get("global_discount_percent", 0)),
|
||||||
|
float(merged.get("shipping_cost", 0)),
|
||||||
|
float(merged.get("shipping_cost_discount", 0)),
|
||||||
|
float(merged.get("install_cost", 0)),
|
||||||
|
float(merged.get("install_cost_discount", 0)),
|
||||||
|
float(merged.get("extras_cost", 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
for field, val in totals.items():
|
||||||
|
set_parts.append(f"{field} = ?")
|
||||||
|
params.append(val)
|
||||||
|
|
||||||
|
set_parts.append("updated_at = ?")
|
||||||
|
params.append(now)
|
||||||
|
params.append(quotation_id)
|
||||||
|
|
||||||
|
if set_parts:
|
||||||
|
await db.execute(
|
||||||
|
f"UPDATE crm_quotations SET {', '.join(set_parts)} WHERE id = ?",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Replace items if provided
|
||||||
|
if "items" in update_fields:
|
||||||
|
await db.execute("DELETE FROM crm_quotation_items WHERE quotation_id = ?", (quotation_id,))
|
||||||
|
for i, item in enumerate(items_raw):
|
||||||
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
item_id, quotation_id, item.get("product_id"), item.get("description"),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
quotation = await get_quotation(quotation_id)
|
||||||
|
|
||||||
|
if generate_pdf:
|
||||||
|
quotation = await _do_generate_and_upload_pdf(quotation)
|
||||||
|
|
||||||
|
return quotation
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_quotation(quotation_id: str) -> None:
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
rows = await db.execute_fetchall(
|
||||||
|
"SELECT nextcloud_pdf_path FROM crm_quotations WHERE id = ?", (quotation_id,)
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="Quotation not found")
|
||||||
|
|
||||||
|
pdf_path = dict(rows[0]).get("nextcloud_pdf_path")
|
||||||
|
|
||||||
|
await db.execute("DELETE FROM crm_quotation_items WHERE quotation_id = ?", (quotation_id,))
|
||||||
|
await db.execute("DELETE FROM crm_quotations WHERE id = ?", (quotation_id,))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Remove PDF from Nextcloud (best-effort)
|
||||||
|
if pdf_path:
|
||||||
|
try:
|
||||||
|
await nextcloud.delete_file(pdf_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to delete PDF from Nextcloud (%s): %s", pdf_path, e)
|
||||||
|
|
||||||
|
|
||||||
|
# ── PDF Generation ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _do_generate_and_upload_pdf(quotation: QuotationInDB) -> QuotationInDB:
|
||||||
|
"""Generate PDF, upload to Nextcloud, update DB record. Returns updated quotation."""
|
||||||
|
try:
|
||||||
|
customer = get_customer(quotation.customer_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Cannot generate PDF — customer not found: %s", e)
|
||||||
|
return quotation
|
||||||
|
|
||||||
|
try:
|
||||||
|
pdf_bytes = await _generate_pdf_bytes(quotation, customer)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("PDF generation failed for quotation %s: %s", quotation.id, e)
|
||||||
|
return quotation
|
||||||
|
|
||||||
|
# Delete old PDF if present
|
||||||
|
if quotation.nextcloud_pdf_path:
|
||||||
|
try:
|
||||||
|
await nextcloud.delete_file(quotation.nextcloud_pdf_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
pdf_path, pdf_url = await _upload_pdf(customer, quotation, pdf_bytes)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("PDF upload failed for quotation %s: %s", quotation.id, e)
|
||||||
|
return quotation
|
||||||
|
|
||||||
|
# Persist paths
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE crm_quotations SET nextcloud_pdf_path = ?, nextcloud_pdf_url = ? WHERE id = ?",
|
||||||
|
(pdf_path, pdf_url, quotation.id),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return await get_quotation(quotation.id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_pdf_bytes(quotation: QuotationInDB, customer) -> bytes:
|
||||||
|
"""Render Jinja2 template and convert to PDF via WeasyPrint."""
|
||||||
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
import weasyprint
|
||||||
|
|
||||||
|
env = Environment(
|
||||||
|
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
|
||||||
|
autoescape=select_autoescape(["html"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def format_money(value):
|
||||||
|
try:
|
||||||
|
f = float(value)
|
||||||
|
# Greek-style: dot thousands separator, comma decimal
|
||||||
|
formatted = f"{f:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
|
||||||
|
return f"{formatted} €"
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "0,00 €"
|
||||||
|
|
||||||
|
env.filters["format_money"] = format_money
|
||||||
|
|
||||||
|
template = env.get_template("quotation.html")
|
||||||
|
|
||||||
|
html_str = template.render(
|
||||||
|
quotation=quotation,
|
||||||
|
customer=customer,
|
||||||
|
lang=quotation.language,
|
||||||
|
)
|
||||||
|
|
||||||
|
pdf = weasyprint.HTML(string=html_str, base_url=str(_TEMPLATES_DIR)).write_pdf()
|
||||||
|
return pdf
|
||||||
|
|
||||||
|
|
||||||
|
async def _upload_pdf(customer, quotation: QuotationInDB, pdf_bytes: bytes) -> tuple[str, str]:
|
||||||
|
"""Upload PDF to Nextcloud, return (relative_path, public_url)."""
|
||||||
|
from crm.service import get_customer_nc_path
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
nc_folder = get_customer_nc_path(customer)
|
||||||
|
date_str = datetime.utcnow().strftime("%Y-%m-%d")
|
||||||
|
filename = f"Quotation-{quotation.quotation_number}-{date_str}.pdf"
|
||||||
|
rel_path = f"customers/{nc_folder}/quotations/{filename}"
|
||||||
|
|
||||||
|
await nextcloud.ensure_folder(f"customers/{nc_folder}/quotations")
|
||||||
|
await nextcloud.upload_file(rel_path, pdf_bytes, "application/pdf")
|
||||||
|
|
||||||
|
# Construct a direct WebDAV download URL
|
||||||
|
from crm.nextcloud import _full_url
|
||||||
|
pdf_url = _full_url(rel_path)
|
||||||
|
|
||||||
|
return rel_path, pdf_url
|
||||||
|
|
||||||
|
|
||||||
|
async def regenerate_pdf(quotation_id: str) -> QuotationInDB:
|
||||||
|
quotation = await get_quotation(quotation_id)
|
||||||
|
return await _do_generate_and_upload_pdf(quotation)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
return pdf_bytes
|
||||||
93
backend/crm/router.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query, UploadFile, File, HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from typing import Optional
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from auth.models import TokenPayload
|
||||||
|
from auth.dependencies import require_permission
|
||||||
|
from crm.models import ProductCreate, ProductUpdate, ProductInDB, ProductListResponse
|
||||||
|
from crm import service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/crm/products", tags=["crm-products"])
|
||||||
|
|
||||||
|
PHOTO_DIR = os.path.join(os.path.dirname(__file__), "..", "storage", "product_images")
|
||||||
|
os.makedirs(PHOTO_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ProductListResponse)
|
||||||
|
def list_products(
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
|
category: Optional[str] = Query(None),
|
||||||
|
active_only: bool = Query(False),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
products = service.list_products(search=search, category=category, active_only=active_only)
|
||||||
|
return ProductListResponse(products=products, total=len(products))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{product_id}", response_model=ProductInDB)
|
||||||
|
def get_product(
|
||||||
|
product_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
return service.get_product(product_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ProductInDB, status_code=201)
|
||||||
|
def create_product(
|
||||||
|
body: ProductCreate,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return service.create_product(body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{product_id}", response_model=ProductInDB)
|
||||||
|
def update_product(
|
||||||
|
product_id: str,
|
||||||
|
body: ProductUpdate,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return service.update_product(product_id, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{product_id}", status_code=204)
|
||||||
|
def delete_product(
|
||||||
|
product_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
service.delete_product(product_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{product_id}/photo", response_model=ProductInDB)
|
||||||
|
async def upload_product_photo(
|
||||||
|
product_id: str,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
"""Upload a product photo. Accepts JPG or PNG, stored on disk."""
|
||||||
|
if file.content_type not in ("image/jpeg", "image/png", "image/webp"):
|
||||||
|
raise HTTPException(status_code=400, detail="Only JPG, PNG, or WebP images are accepted.")
|
||||||
|
ext = {"image/jpeg": "jpg", "image/png": "png", "image/webp": "webp"}.get(file.content_type, "jpg")
|
||||||
|
photo_path = os.path.join(PHOTO_DIR, f"{product_id}.{ext}")
|
||||||
|
# Remove any old photo files for this product
|
||||||
|
for old_ext in ("jpg", "png", "webp"):
|
||||||
|
old_path = os.path.join(PHOTO_DIR, f"{product_id}.{old_ext}")
|
||||||
|
if os.path.exists(old_path) and old_path != photo_path:
|
||||||
|
os.remove(old_path)
|
||||||
|
with open(photo_path, "wb") as f:
|
||||||
|
shutil.copyfileobj(file.file, f)
|
||||||
|
photo_url = f"/crm/products/{product_id}/photo"
|
||||||
|
return service.update_product(product_id, ProductUpdate(photo_url=photo_url))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{product_id}/photo")
|
||||||
|
def get_product_photo(
|
||||||
|
product_id: str,
|
||||||
|
):
|
||||||
|
"""Serve a product photo from disk."""
|
||||||
|
for ext in ("jpg", "png", "webp"):
|
||||||
|
photo_path = os.path.join(PHOTO_DIR, f"{product_id}.{ext}")
|
||||||
|
if os.path.exists(photo_path):
|
||||||
|
return FileResponse(photo_path)
|
||||||
|
raise HTTPException(status_code=404, detail="No photo found for this product.")
|
||||||
619
backend/crm/service.py
Normal file
@@ -0,0 +1,619 @@
|
|||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
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
|
||||||
|
from crm.models import (
|
||||||
|
ProductCreate, ProductUpdate, ProductInDB,
|
||||||
|
CustomerCreate, CustomerUpdate, CustomerInDB,
|
||||||
|
OrderCreate, OrderUpdate, OrderInDB,
|
||||||
|
CommCreate, CommUpdate, CommInDB,
|
||||||
|
MediaCreate, MediaInDB,
|
||||||
|
)
|
||||||
|
|
||||||
|
COLLECTION = "crm_products"
|
||||||
|
|
||||||
|
|
||||||
|
def _doc_to_product(doc) -> ProductInDB:
|
||||||
|
data = doc.to_dict()
|
||||||
|
return ProductInDB(id=doc.id, **data)
|
||||||
|
|
||||||
|
|
||||||
|
def list_products(
|
||||||
|
search: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
active_only: bool = False,
|
||||||
|
) -> list[ProductInDB]:
|
||||||
|
db = get_db()
|
||||||
|
query = db.collection(COLLECTION)
|
||||||
|
|
||||||
|
if active_only:
|
||||||
|
query = query.where("active", "==", True)
|
||||||
|
|
||||||
|
if category:
|
||||||
|
query = query.where("category", "==", category)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for doc in query.stream():
|
||||||
|
product = _doc_to_product(doc)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
s = search.lower()
|
||||||
|
if not (
|
||||||
|
s in (product.name or "").lower()
|
||||||
|
or s in (product.sku or "").lower()
|
||||||
|
or s in (product.description or "").lower()
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
results.append(product)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def get_product(product_id: str) -> ProductInDB:
|
||||||
|
db = get_db()
|
||||||
|
doc = db.collection(COLLECTION).document(product_id).get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Product")
|
||||||
|
return _doc_to_product(doc)
|
||||||
|
|
||||||
|
|
||||||
|
def create_product(data: ProductCreate) -> ProductInDB:
|
||||||
|
db = get_db()
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
product_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
doc_data = data.model_dump()
|
||||||
|
doc_data["created_at"] = now
|
||||||
|
doc_data["updated_at"] = now
|
||||||
|
|
||||||
|
# Serialize nested enums/models
|
||||||
|
if doc_data.get("category"):
|
||||||
|
doc_data["category"] = doc_data["category"].value if hasattr(doc_data["category"], "value") else doc_data["category"]
|
||||||
|
if doc_data.get("costs") and hasattr(doc_data["costs"], "model_dump"):
|
||||||
|
doc_data["costs"] = doc_data["costs"].model_dump()
|
||||||
|
if doc_data.get("stock") and hasattr(doc_data["stock"], "model_dump"):
|
||||||
|
doc_data["stock"] = doc_data["stock"].model_dump()
|
||||||
|
|
||||||
|
db.collection(COLLECTION).document(product_id).set(doc_data)
|
||||||
|
return ProductInDB(id=product_id, **doc_data)
|
||||||
|
|
||||||
|
|
||||||
|
def update_product(product_id: str, data: ProductUpdate) -> ProductInDB:
|
||||||
|
db = get_db()
|
||||||
|
doc_ref = db.collection(COLLECTION).document(product_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Product")
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_none=True)
|
||||||
|
update_data["updated_at"] = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
if "category" in update_data and hasattr(update_data["category"], "value"):
|
||||||
|
update_data["category"] = update_data["category"].value
|
||||||
|
if "costs" in update_data and hasattr(update_data["costs"], "model_dump"):
|
||||||
|
update_data["costs"] = update_data["costs"].model_dump()
|
||||||
|
if "stock" in update_data and hasattr(update_data["stock"], "model_dump"):
|
||||||
|
update_data["stock"] = update_data["stock"].model_dump()
|
||||||
|
|
||||||
|
doc_ref.update(update_data)
|
||||||
|
updated_doc = doc_ref.get()
|
||||||
|
return _doc_to_product(updated_doc)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_product(product_id: str) -> None:
|
||||||
|
db = get_db()
|
||||||
|
doc_ref = db.collection(COLLECTION).document(product_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Product")
|
||||||
|
doc_ref.delete()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Customers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CUSTOMERS_COLLECTION = "crm_customers"
|
||||||
|
|
||||||
|
|
||||||
|
def _doc_to_customer(doc) -> CustomerInDB:
|
||||||
|
data = doc.to_dict()
|
||||||
|
return CustomerInDB(id=doc.id, **data)
|
||||||
|
|
||||||
|
|
||||||
|
def list_customers(
|
||||||
|
search: str | None = None,
|
||||||
|
tag: str | None = None,
|
||||||
|
) -> list[CustomerInDB]:
|
||||||
|
db = get_db()
|
||||||
|
query = db.collection(CUSTOMERS_COLLECTION)
|
||||||
|
|
||||||
|
if tag:
|
||||||
|
query = query.where("tags", "array_contains", tag)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for doc in query.stream():
|
||||||
|
customer = _doc_to_customer(doc)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
s = search.lower()
|
||||||
|
name_match = s in (customer.name or "").lower()
|
||||||
|
surname_match = s in (customer.surname or "").lower()
|
||||||
|
org_match = s in (customer.organization or "").lower()
|
||||||
|
contact_match = any(
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
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):
|
||||||
|
continue
|
||||||
|
|
||||||
|
results.append(customer)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def get_customer(customer_id: str) -> CustomerInDB:
|
||||||
|
db = get_db()
|
||||||
|
doc = db.collection(CUSTOMERS_COLLECTION).document(customer_id).get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Customer")
|
||||||
|
return _doc_to_customer(doc)
|
||||||
|
|
||||||
|
|
||||||
|
def get_customer_nc_path(customer: CustomerInDB) -> str:
|
||||||
|
"""Return the Nextcloud folder slug for a customer. Falls back to UUID for legacy records."""
|
||||||
|
return customer.folder_id if customer.folder_id else customer.id
|
||||||
|
|
||||||
|
|
||||||
|
def create_customer(data: CustomerCreate) -> CustomerInDB:
|
||||||
|
db = get_db()
|
||||||
|
|
||||||
|
# Validate folder_id
|
||||||
|
if not data.folder_id or not data.folder_id.strip():
|
||||||
|
raise HTTPException(status_code=422, detail="Internal Folder ID is required.")
|
||||||
|
folder_id = data.folder_id.strip().lower()
|
||||||
|
if not _re.match(r'^[a-z0-9][a-z0-9\-]*[a-z0-9]$', folder_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="Internal Folder ID must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen.",
|
||||||
|
)
|
||||||
|
# Check uniqueness
|
||||||
|
existing = list(db.collection(CUSTOMERS_COLLECTION).where("folder_id", "==", folder_id).limit(1).stream())
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=409, detail=f"A customer with folder ID '{folder_id}' already exists.")
|
||||||
|
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
customer_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
doc_data = data.model_dump()
|
||||||
|
doc_data["folder_id"] = folder_id
|
||||||
|
doc_data["created_at"] = now
|
||||||
|
doc_data["updated_at"] = now
|
||||||
|
|
||||||
|
db.collection(CUSTOMERS_COLLECTION).document(customer_id).set(doc_data)
|
||||||
|
return CustomerInDB(id=customer_id, **doc_data)
|
||||||
|
|
||||||
|
|
||||||
|
def update_customer(customer_id: str, data: CustomerUpdate) -> CustomerInDB:
|
||||||
|
db = get_db()
|
||||||
|
doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Customer")
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_none=True)
|
||||||
|
update_data["updated_at"] = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
doc_ref.update(update_data)
|
||||||
|
updated_doc = doc_ref.get()
|
||||||
|
return _doc_to_customer(updated_doc)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_customer(customer_id: str) -> None:
|
||||||
|
db = get_db()
|
||||||
|
doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Customer")
|
||||||
|
doc_ref.delete()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Orders ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ORDERS_COLLECTION = "crm_orders"
|
||||||
|
|
||||||
|
|
||||||
|
def _doc_to_order(doc) -> OrderInDB:
|
||||||
|
data = doc.to_dict()
|
||||||
|
return OrderInDB(id=doc.id, **data)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_order_number(db) -> str:
|
||||||
|
year = datetime.utcnow().year
|
||||||
|
prefix = f"ORD-{year}-"
|
||||||
|
max_n = 0
|
||||||
|
for doc in db.collection(ORDERS_COLLECTION).stream():
|
||||||
|
data = doc.to_dict()
|
||||||
|
num = data.get("order_number", "")
|
||||||
|
if num and num.startswith(prefix):
|
||||||
|
try:
|
||||||
|
n = int(num[len(prefix):])
|
||||||
|
if n > max_n:
|
||||||
|
max_n = n
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return f"{prefix}{max_n + 1:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def list_orders(
|
||||||
|
customer_id: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
payment_status: str | None = None,
|
||||||
|
) -> list[OrderInDB]:
|
||||||
|
db = get_db()
|
||||||
|
query = db.collection(ORDERS_COLLECTION)
|
||||||
|
|
||||||
|
if customer_id:
|
||||||
|
query = query.where("customer_id", "==", customer_id)
|
||||||
|
if status:
|
||||||
|
query = query.where("status", "==", status)
|
||||||
|
if payment_status:
|
||||||
|
query = query.where("payment_status", "==", payment_status)
|
||||||
|
|
||||||
|
return [_doc_to_order(doc) for doc in query.stream()]
|
||||||
|
|
||||||
|
|
||||||
|
def get_order(order_id: str) -> OrderInDB:
|
||||||
|
db = get_db()
|
||||||
|
doc = db.collection(ORDERS_COLLECTION).document(order_id).get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Order")
|
||||||
|
return _doc_to_order(doc)
|
||||||
|
|
||||||
|
|
||||||
|
def create_order(data: OrderCreate) -> OrderInDB:
|
||||||
|
db = get_db()
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
order_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
doc_data = data.model_dump()
|
||||||
|
if not doc_data.get("order_number"):
|
||||||
|
doc_data["order_number"] = _generate_order_number(db)
|
||||||
|
doc_data["created_at"] = now
|
||||||
|
doc_data["updated_at"] = now
|
||||||
|
|
||||||
|
db.collection(ORDERS_COLLECTION).document(order_id).set(doc_data)
|
||||||
|
return OrderInDB(id=order_id, **doc_data)
|
||||||
|
|
||||||
|
|
||||||
|
def update_order(order_id: str, data: OrderUpdate) -> OrderInDB:
|
||||||
|
db = get_db()
|
||||||
|
doc_ref = db.collection(ORDERS_COLLECTION).document(order_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Order")
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_none=True)
|
||||||
|
update_data["updated_at"] = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
doc_ref.update(update_data)
|
||||||
|
updated_doc = doc_ref.get()
|
||||||
|
return _doc_to_order(updated_doc)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_order(order_id: str) -> None:
|
||||||
|
db = get_db()
|
||||||
|
doc_ref = db.collection(ORDERS_COLLECTION).document(order_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Order")
|
||||||
|
doc_ref.delete()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Comms Log (SQLite, async) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _row_to_comm(row: dict) -> CommInDB:
|
||||||
|
row = dict(row)
|
||||||
|
raw_attachments = json.loads(row.get("attachments") or "[]")
|
||||||
|
# Normalise attachment dicts — tolerate both synced (content_type/size) and
|
||||||
|
# sent (nextcloud_path) shapes so Pydantic never sees missing required fields.
|
||||||
|
row["attachments"] = [
|
||||||
|
{k: v for k, v in a.items() if k in ("filename", "nextcloud_path", "content_type", "size")}
|
||||||
|
for a in raw_attachments if isinstance(a, dict) and a.get("filename")
|
||||||
|
]
|
||||||
|
if row.get("to_addrs") and isinstance(row["to_addrs"], str):
|
||||||
|
try:
|
||||||
|
row["to_addrs"] = json.loads(row["to_addrs"])
|
||||||
|
except Exception:
|
||||||
|
row["to_addrs"] = []
|
||||||
|
# SQLite stores booleans as integers
|
||||||
|
row["is_important"] = bool(row.get("is_important", 0))
|
||||||
|
row["is_read"] = bool(row.get("is_read", 0))
|
||||||
|
return CommInDB(**{k: v for k, v in row.items() if k in CommInDB.model_fields})
|
||||||
|
|
||||||
|
|
||||||
|
async def list_comms(
|
||||||
|
customer_id: str,
|
||||||
|
type: str | None = None,
|
||||||
|
direction: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[CommInDB]:
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
where = ["customer_id = ?"]
|
||||||
|
params: list = [customer_id]
|
||||||
|
if type:
|
||||||
|
where.append("type = ?")
|
||||||
|
params.append(type)
|
||||||
|
if direction:
|
||||||
|
where.append("direction = ?")
|
||||||
|
params.append(direction)
|
||||||
|
clause = " AND ".join(where)
|
||||||
|
rows = await db.execute_fetchall(
|
||||||
|
f"SELECT * FROM crm_comms_log WHERE {clause} ORDER BY COALESCE(occurred_at, created_at) DESC, created_at DESC LIMIT ?",
|
||||||
|
params + [limit],
|
||||||
|
)
|
||||||
|
entries = [_row_to_comm(dict(r)) for r in rows]
|
||||||
|
|
||||||
|
# Fallback: include unlinked email rows (customer_id NULL) if addresses match this customer.
|
||||||
|
# This covers historical rows created before automatic outbound customer linking.
|
||||||
|
fs = get_db()
|
||||||
|
doc = fs.collection("crm_customers").document(customer_id).get()
|
||||||
|
if doc.exists:
|
||||||
|
data = doc.to_dict() or {}
|
||||||
|
customer_emails = {
|
||||||
|
(c.get("value") or "").strip().lower()
|
||||||
|
for c in (data.get("contacts") or [])
|
||||||
|
if c.get("type") == "email" and c.get("value")
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
customer_emails = set()
|
||||||
|
|
||||||
|
if customer_emails:
|
||||||
|
extra_where = [
|
||||||
|
"type = 'email'",
|
||||||
|
"(customer_id IS NULL OR customer_id = '')",
|
||||||
|
]
|
||||||
|
extra_params: list = []
|
||||||
|
if direction:
|
||||||
|
extra_where.append("direction = ?")
|
||||||
|
extra_params.append(direction)
|
||||||
|
extra_clause = " AND ".join(extra_where)
|
||||||
|
extra_rows = await db.execute_fetchall(
|
||||||
|
f"SELECT * FROM crm_comms_log WHERE {extra_clause} "
|
||||||
|
"ORDER BY COALESCE(occurred_at, created_at) DESC, created_at DESC LIMIT ?",
|
||||||
|
extra_params + [max(limit, 300)],
|
||||||
|
)
|
||||||
|
for r in extra_rows:
|
||||||
|
e = _row_to_comm(dict(r))
|
||||||
|
from_addr = (e.from_addr or "").strip().lower()
|
||||||
|
to_addrs = [(a or "").strip().lower() for a in (e.to_addrs or [])]
|
||||||
|
matched = (from_addr in customer_emails) or any(a in customer_emails for a in to_addrs)
|
||||||
|
if matched:
|
||||||
|
entries.append(e)
|
||||||
|
|
||||||
|
# De-duplicate and sort consistently
|
||||||
|
uniq = {e.id: e for e in entries}
|
||||||
|
sorted_entries = sorted(
|
||||||
|
uniq.values(),
|
||||||
|
key=lambda e: ((e.occurred_at or e.created_at or ""), (e.created_at or ""), (e.id or "")),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
return sorted_entries[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
async def list_all_emails(
|
||||||
|
direction: str | None = None,
|
||||||
|
customers_only: bool = False,
|
||||||
|
mail_accounts: list[str] | None = None,
|
||||||
|
limit: int = 500,
|
||||||
|
) -> list[CommInDB]:
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
where = ["type = 'email'"]
|
||||||
|
params: list = []
|
||||||
|
if direction:
|
||||||
|
where.append("direction = ?")
|
||||||
|
params.append(direction)
|
||||||
|
if customers_only:
|
||||||
|
where.append("customer_id IS NOT NULL")
|
||||||
|
if mail_accounts:
|
||||||
|
placeholders = ",".join("?" for _ in mail_accounts)
|
||||||
|
where.append(f"mail_account IN ({placeholders})")
|
||||||
|
params.extend(mail_accounts)
|
||||||
|
clause = f"WHERE {' AND '.join(where)}"
|
||||||
|
rows = await db.execute_fetchall(
|
||||||
|
f"SELECT * FROM crm_comms_log {clause} ORDER BY COALESCE(occurred_at, created_at) DESC, created_at DESC LIMIT ?",
|
||||||
|
params + [limit],
|
||||||
|
)
|
||||||
|
return [_row_to_comm(dict(r)) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def list_all_comms(
|
||||||
|
type: str | None = None,
|
||||||
|
direction: str | None = None,
|
||||||
|
limit: int = 200,
|
||||||
|
) -> list[CommInDB]:
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
where = []
|
||||||
|
params: list = []
|
||||||
|
if type:
|
||||||
|
where.append("type = ?")
|
||||||
|
params.append(type)
|
||||||
|
if direction:
|
||||||
|
where.append("direction = ?")
|
||||||
|
params.append(direction)
|
||||||
|
clause = f"WHERE {' AND '.join(where)}" if where else ""
|
||||||
|
rows = await db.execute_fetchall(
|
||||||
|
f"SELECT * FROM crm_comms_log {clause} ORDER BY COALESCE(occurred_at, created_at) DESC, created_at DESC LIMIT ?",
|
||||||
|
params + [limit],
|
||||||
|
)
|
||||||
|
return [_row_to_comm(dict(r)) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_comm(comm_id: str) -> CommInDB:
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
rows = await db.execute_fetchall(
|
||||||
|
"SELECT * FROM crm_comms_log WHERE id = ?", (comm_id,)
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="Comm entry not found")
|
||||||
|
return _row_to_comm(dict(rows[0]))
|
||||||
|
|
||||||
|
|
||||||
|
async def create_comm(data: CommCreate) -> CommInDB:
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
comm_id = str(uuid.uuid4())
|
||||||
|
occurred_at = data.occurred_at or now
|
||||||
|
attachments_json = json.dumps([a.model_dump() for a in data.attachments])
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""INSERT INTO crm_comms_log
|
||||||
|
(id, customer_id, type, mail_account, direction, subject, body, attachments,
|
||||||
|
ext_message_id, logged_by, occurred_at, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(comm_id, data.customer_id, data.type.value, data.mail_account, data.direction.value,
|
||||||
|
data.subject, data.body, attachments_json,
|
||||||
|
data.ext_message_id, data.logged_by, occurred_at, now),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return await get_comm(comm_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_comm(comm_id: str, data: CommUpdate) -> CommInDB:
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
rows = await db.execute_fetchall(
|
||||||
|
"SELECT id FROM crm_comms_log WHERE id = ?", (comm_id,)
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="Comm entry not found")
|
||||||
|
|
||||||
|
updates = data.model_dump(exclude_none=True)
|
||||||
|
if not updates:
|
||||||
|
return await get_comm(comm_id)
|
||||||
|
|
||||||
|
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
||||||
|
await db.execute(
|
||||||
|
f"UPDATE crm_comms_log SET {set_clause} WHERE id = ?",
|
||||||
|
list(updates.values()) + [comm_id],
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return await get_comm(comm_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_comm(comm_id: str) -> None:
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
rows = await db.execute_fetchall(
|
||||||
|
"SELECT id FROM crm_comms_log WHERE id = ?", (comm_id,)
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="Comm entry not found")
|
||||||
|
await db.execute("DELETE FROM crm_comms_log WHERE id = ?", (comm_id,))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_comms_bulk(ids: list[str]) -> int:
|
||||||
|
"""Delete multiple comm entries. Returns count deleted."""
|
||||||
|
if not ids:
|
||||||
|
return 0
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
placeholders = ",".join("?" for _ in ids)
|
||||||
|
cursor = await db.execute(
|
||||||
|
f"DELETE FROM crm_comms_log WHERE id IN ({placeholders})", ids
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return cursor.rowcount
|
||||||
|
|
||||||
|
|
||||||
|
async def set_comm_important(comm_id: str, important: bool) -> CommInDB:
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE crm_comms_log SET is_important = ? WHERE id = ?",
|
||||||
|
(1 if important else 0, comm_id),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return await get_comm(comm_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_comm_read(comm_id: str, read: bool) -> CommInDB:
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE crm_comms_log SET is_read = ? WHERE id = ?",
|
||||||
|
(1 if read else 0, comm_id),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return await get_comm(comm_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Media (SQLite, async) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _row_to_media(row: dict) -> MediaInDB:
|
||||||
|
row = dict(row)
|
||||||
|
row["tags"] = json.loads(row.get("tags") or "[]")
|
||||||
|
return MediaInDB(**row)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_media(
|
||||||
|
customer_id: str | None = None,
|
||||||
|
order_id: str | None = None,
|
||||||
|
) -> list[MediaInDB]:
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
where = []
|
||||||
|
params: list = []
|
||||||
|
if customer_id:
|
||||||
|
where.append("customer_id = ?")
|
||||||
|
params.append(customer_id)
|
||||||
|
if order_id:
|
||||||
|
where.append("order_id = ?")
|
||||||
|
params.append(order_id)
|
||||||
|
clause = f"WHERE {' AND '.join(where)}" if where else ""
|
||||||
|
rows = await db.execute_fetchall(
|
||||||
|
f"SELECT * FROM crm_media {clause} ORDER BY created_at DESC",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
return [_row_to_media(dict(r)) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def create_media(data: MediaCreate) -> MediaInDB:
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
media_id = str(uuid.uuid4())
|
||||||
|
tags_json = json.dumps(data.tags)
|
||||||
|
direction = data.direction.value if data.direction else None
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""INSERT INTO crm_media
|
||||||
|
(id, customer_id, order_id, filename, nextcloud_path, mime_type,
|
||||||
|
direction, tags, uploaded_by, 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),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
rows = await db.execute_fetchall(
|
||||||
|
"SELECT * FROM crm_media WHERE id = ?", (media_id,)
|
||||||
|
)
|
||||||
|
return _row_to_media(dict(rows[0]))
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_media(media_id: str) -> None:
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
rows = await db.execute_fetchall(
|
||||||
|
"SELECT id FROM crm_media WHERE id = ?", (media_id,)
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="Media entry not found")
|
||||||
|
await db.execute("DELETE FROM crm_media WHERE id = ?", (media_id,))
|
||||||
|
await db.commit()
|
||||||
@@ -7,6 +7,8 @@ from devices.models import (
|
|||||||
DeviceUsersResponse, DeviceUserInfo,
|
DeviceUsersResponse, DeviceUserInfo,
|
||||||
)
|
)
|
||||||
from devices import service
|
from devices import service
|
||||||
|
from mqtt import database as mqtt_db
|
||||||
|
from mqtt.models import DeviceAlertEntry, DeviceAlertsResponse
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/devices", tags=["devices"])
|
router = APIRouter(prefix="/api/devices", tags=["devices"])
|
||||||
|
|
||||||
@@ -67,3 +69,13 @@ async def delete_device(
|
|||||||
_user: TokenPayload = Depends(require_permission("devices", "delete")),
|
_user: TokenPayload = Depends(require_permission("devices", "delete")),
|
||||||
):
|
):
|
||||||
service.delete_device(device_id)
|
service.delete_device(device_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{device_id}/alerts", response_model=DeviceAlertsResponse)
|
||||||
|
async def get_device_alerts(
|
||||||
|
device_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("devices", "view")),
|
||||||
|
):
|
||||||
|
"""Return the current active alert set for a device. Empty list means fully healthy."""
|
||||||
|
rows = await mqtt_db.get_alerts(device_id)
|
||||||
|
return DeviceAlertsResponse(alerts=[DeviceAlertEntry(**r) for r in rows])
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateType(str, Enum):
|
||||||
|
optional = "optional" # user-initiated only
|
||||||
|
mandatory = "mandatory" # auto-installs on next reboot
|
||||||
|
emergency = "emergency" # auto-installs on reboot + daily check + MQTT push
|
||||||
|
|
||||||
|
|
||||||
class FirmwareVersion(BaseModel):
|
class FirmwareVersion(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
hw_type: str # "vs", "vp", "vx"
|
hw_type: str # e.g. "vesper", "vesper_plus", "vesper_pro"
|
||||||
channel: str # "stable", "beta", "alpha", "testing"
|
channel: str # "stable", "beta", "alpha", "testing"
|
||||||
version: str # semver e.g. "1.4.2"
|
version: str # semver e.g. "1.5"
|
||||||
filename: str
|
filename: str
|
||||||
size_bytes: int
|
size_bytes: int
|
||||||
sha256: str
|
sha256: str
|
||||||
|
update_type: UpdateType = UpdateType.mandatory
|
||||||
|
min_fw_version: Optional[str] = None # minimum fw version required to install this
|
||||||
uploaded_at: str
|
uploaded_at: str
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
is_latest: bool = False
|
is_latest: bool = False
|
||||||
@@ -20,12 +29,19 @@ class FirmwareListResponse(BaseModel):
|
|||||||
total: int
|
total: int
|
||||||
|
|
||||||
|
|
||||||
class FirmwareLatestResponse(BaseModel):
|
class FirmwareMetadataResponse(BaseModel):
|
||||||
|
"""Returned by both /latest and /{version}/info endpoints."""
|
||||||
hw_type: str
|
hw_type: str
|
||||||
channel: str
|
channel: str
|
||||||
version: str
|
version: str
|
||||||
size_bytes: int
|
size_bytes: int
|
||||||
sha256: str
|
sha256: str
|
||||||
|
update_type: UpdateType
|
||||||
|
min_fw_version: Optional[str] = None
|
||||||
download_url: str
|
download_url: str
|
||||||
uploaded_at: str
|
uploaded_at: str
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# Keep backwards-compatible alias
|
||||||
|
FirmwareLatestResponse = FirmwareMetadataResponse
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from auth.models import TokenPayload
|
from auth.models import TokenPayload
|
||||||
from auth.dependencies import require_permission
|
from auth.dependencies import require_permission
|
||||||
from firmware.models import FirmwareVersion, FirmwareListResponse, FirmwareLatestResponse
|
from firmware.models import FirmwareVersion, FirmwareListResponse, FirmwareMetadataResponse, UpdateType
|
||||||
from firmware import service
|
from firmware import service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/firmware", tags=["firmware"])
|
router = APIRouter(prefix="/api/firmware", tags=["firmware"])
|
||||||
@@ -15,6 +15,8 @@ async def upload_firmware(
|
|||||||
hw_type: str = Form(...),
|
hw_type: str = Form(...),
|
||||||
channel: str = Form(...),
|
channel: str = Form(...),
|
||||||
version: str = Form(...),
|
version: str = Form(...),
|
||||||
|
update_type: UpdateType = Form(UpdateType.mandatory),
|
||||||
|
min_fw_version: Optional[str] = Form(None),
|
||||||
notes: Optional[str] = Form(None),
|
notes: Optional[str] = Form(None),
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
|
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
|
||||||
@@ -25,6 +27,8 @@ async def upload_firmware(
|
|||||||
channel=channel,
|
channel=channel,
|
||||||
version=version,
|
version=version,
|
||||||
file_bytes=file_bytes,
|
file_bytes=file_bytes,
|
||||||
|
update_type=update_type,
|
||||||
|
min_fw_version=min_fw_version,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,7 +43,7 @@ def list_firmware(
|
|||||||
return FirmwareListResponse(firmware=items, total=len(items))
|
return FirmwareListResponse(firmware=items, total=len(items))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{hw_type}/{channel}/latest", response_model=FirmwareLatestResponse)
|
@router.get("/{hw_type}/{channel}/latest", response_model=FirmwareMetadataResponse)
|
||||||
def get_latest_firmware(hw_type: str, channel: str):
|
def get_latest_firmware(hw_type: str, channel: str):
|
||||||
"""Returns metadata for the latest firmware for a given hw_type + channel.
|
"""Returns metadata for the latest firmware for a given hw_type + channel.
|
||||||
No auth required — devices call this endpoint to check for updates.
|
No auth required — devices call this endpoint to check for updates.
|
||||||
@@ -47,6 +51,14 @@ def get_latest_firmware(hw_type: str, channel: str):
|
|||||||
return service.get_latest(hw_type, channel)
|
return service.get_latest(hw_type, channel)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{hw_type}/{channel}/{version}/info", response_model=FirmwareMetadataResponse)
|
||||||
|
def get_firmware_info(hw_type: str, channel: str, version: str):
|
||||||
|
"""Returns metadata for a specific firmware version.
|
||||||
|
No auth required — devices call this to resolve upgrade chains.
|
||||||
|
"""
|
||||||
|
return service.get_version_info(hw_type, channel, version)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{hw_type}/{channel}/{version}/firmware.bin")
|
@router.get("/{hw_type}/{channel}/{version}/firmware.bin")
|
||||||
def download_firmware(hw_type: str, channel: str, version: str):
|
def download_firmware(hw_type: str, channel: str, version: str):
|
||||||
"""Download the firmware binary. No auth required — devices call this directly."""
|
"""Download the firmware binary. No auth required — devices call this directly."""
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ from fastapi import HTTPException
|
|||||||
from config import settings
|
from config import settings
|
||||||
from shared.firebase import get_db
|
from shared.firebase import get_db
|
||||||
from shared.exceptions import NotFoundError
|
from shared.exceptions import NotFoundError
|
||||||
from firmware.models import FirmwareVersion, FirmwareLatestResponse
|
from firmware.models import FirmwareVersion, FirmwareMetadataResponse, UpdateType
|
||||||
|
|
||||||
COLLECTION = "firmware_versions"
|
COLLECTION = "firmware_versions"
|
||||||
|
|
||||||
VALID_HW_TYPES = {"vs", "vp", "vx"}
|
VALID_HW_TYPES = {"vesper", "vesper_plus", "vesper_pro", "chronos", "chronos_pro", "agnus", "agnus_mini"}
|
||||||
VALID_CHANNELS = {"stable", "beta", "alpha", "testing"}
|
VALID_CHANNELS = {"stable", "beta", "alpha", "testing"}
|
||||||
|
|
||||||
|
|
||||||
@@ -36,23 +36,43 @@ def _doc_to_firmware_version(doc) -> FirmwareVersion:
|
|||||||
filename=data.get("filename", "firmware.bin"),
|
filename=data.get("filename", "firmware.bin"),
|
||||||
size_bytes=data.get("size_bytes", 0),
|
size_bytes=data.get("size_bytes", 0),
|
||||||
sha256=data.get("sha256", ""),
|
sha256=data.get("sha256", ""),
|
||||||
|
update_type=data.get("update_type", UpdateType.mandatory),
|
||||||
|
min_fw_version=data.get("min_fw_version"),
|
||||||
uploaded_at=uploaded_str,
|
uploaded_at=uploaded_str,
|
||||||
notes=data.get("notes"),
|
notes=data.get("notes"),
|
||||||
is_latest=data.get("is_latest", False),
|
is_latest=data.get("is_latest", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fw_to_metadata_response(fw: FirmwareVersion) -> FirmwareMetadataResponse:
|
||||||
|
download_url = f"/api/firmware/{fw.hw_type}/{fw.channel}/{fw.version}/firmware.bin"
|
||||||
|
return FirmwareMetadataResponse(
|
||||||
|
hw_type=fw.hw_type,
|
||||||
|
channel=fw.channel,
|
||||||
|
version=fw.version,
|
||||||
|
size_bytes=fw.size_bytes,
|
||||||
|
sha256=fw.sha256,
|
||||||
|
update_type=fw.update_type,
|
||||||
|
min_fw_version=fw.min_fw_version,
|
||||||
|
download_url=download_url,
|
||||||
|
uploaded_at=fw.uploaded_at,
|
||||||
|
notes=fw.notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def upload_firmware(
|
def upload_firmware(
|
||||||
hw_type: str,
|
hw_type: str,
|
||||||
channel: str,
|
channel: str,
|
||||||
version: str,
|
version: str,
|
||||||
file_bytes: bytes,
|
file_bytes: bytes,
|
||||||
|
update_type: UpdateType = UpdateType.mandatory,
|
||||||
|
min_fw_version: str | None = None,
|
||||||
notes: str | None = None,
|
notes: str | None = None,
|
||||||
) -> FirmwareVersion:
|
) -> FirmwareVersion:
|
||||||
if hw_type not in VALID_HW_TYPES:
|
if hw_type not in VALID_HW_TYPES:
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid hw_type. Must be one of: {', '.join(VALID_HW_TYPES)}")
|
raise HTTPException(status_code=400, detail=f"Invalid hw_type. Must be one of: {', '.join(sorted(VALID_HW_TYPES))}")
|
||||||
if channel not in VALID_CHANNELS:
|
if channel not in VALID_CHANNELS:
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid channel. Must be one of: {', '.join(VALID_CHANNELS)}")
|
raise HTTPException(status_code=400, detail=f"Invalid channel. Must be one of: {', '.join(sorted(VALID_CHANNELS))}")
|
||||||
|
|
||||||
dest = _storage_path(hw_type, channel, version)
|
dest = _storage_path(hw_type, channel, version)
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -83,6 +103,8 @@ def upload_firmware(
|
|||||||
"filename": "firmware.bin",
|
"filename": "firmware.bin",
|
||||||
"size_bytes": len(file_bytes),
|
"size_bytes": len(file_bytes),
|
||||||
"sha256": sha256,
|
"sha256": sha256,
|
||||||
|
"update_type": update_type.value,
|
||||||
|
"min_fw_version": min_fw_version,
|
||||||
"uploaded_at": now,
|
"uploaded_at": now,
|
||||||
"notes": notes,
|
"notes": notes,
|
||||||
"is_latest": True,
|
"is_latest": True,
|
||||||
@@ -108,7 +130,7 @@ def list_firmware(
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
def get_latest(hw_type: str, channel: str) -> FirmwareLatestResponse:
|
def get_latest(hw_type: str, channel: str) -> FirmwareMetadataResponse:
|
||||||
if hw_type not in VALID_HW_TYPES:
|
if hw_type not in VALID_HW_TYPES:
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid hw_type '{hw_type}'")
|
raise HTTPException(status_code=400, detail=f"Invalid hw_type '{hw_type}'")
|
||||||
if channel not in VALID_CHANNELS:
|
if channel not in VALID_CHANNELS:
|
||||||
@@ -126,18 +148,29 @@ def get_latest(hw_type: str, channel: str) -> FirmwareLatestResponse:
|
|||||||
if not docs:
|
if not docs:
|
||||||
raise NotFoundError("Firmware")
|
raise NotFoundError("Firmware")
|
||||||
|
|
||||||
fw = _doc_to_firmware_version(docs[0])
|
return _fw_to_metadata_response(_doc_to_firmware_version(docs[0]))
|
||||||
download_url = f"/api/firmware/{hw_type}/{channel}/{fw.version}/firmware.bin"
|
|
||||||
return FirmwareLatestResponse(
|
|
||||||
hw_type=fw.hw_type,
|
def get_version_info(hw_type: str, channel: str, version: str) -> FirmwareMetadataResponse:
|
||||||
channel=fw.channel,
|
"""Fetch metadata for a specific version. Used by devices resolving upgrade chains."""
|
||||||
version=fw.version,
|
if hw_type not in VALID_HW_TYPES:
|
||||||
size_bytes=fw.size_bytes,
|
raise HTTPException(status_code=400, detail=f"Invalid hw_type '{hw_type}'")
|
||||||
sha256=fw.sha256,
|
if channel not in VALID_CHANNELS:
|
||||||
download_url=download_url,
|
raise HTTPException(status_code=400, detail=f"Invalid channel '{channel}'")
|
||||||
uploaded_at=fw.uploaded_at,
|
|
||||||
notes=fw.notes,
|
db = get_db()
|
||||||
|
docs = list(
|
||||||
|
db.collection(COLLECTION)
|
||||||
|
.where("hw_type", "==", hw_type)
|
||||||
|
.where("channel", "==", channel)
|
||||||
|
.where("version", "==", version)
|
||||||
|
.limit(1)
|
||||||
|
.stream()
|
||||||
)
|
)
|
||||||
|
if not docs:
|
||||||
|
raise NotFoundError("Firmware version")
|
||||||
|
|
||||||
|
return _fw_to_metadata_response(_doc_to_firmware_version(docs[0]))
|
||||||
|
|
||||||
|
|
||||||
def get_firmware_path(hw_type: str, channel: str, version: str) -> Path:
|
def get_firmware_path(hw_type: str, channel: str, version: str) -> Path:
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ from builder.router import router as builder_router
|
|||||||
from manufacturing.router import router as manufacturing_router
|
from manufacturing.router import router as manufacturing_router
|
||||||
from firmware.router import router as firmware_router
|
from firmware.router import router as firmware_router
|
||||||
from admin.router import router as admin_router
|
from admin.router import router as admin_router
|
||||||
|
from crm.router import router as crm_products_router
|
||||||
|
from crm.customers_router import router as crm_customers_router
|
||||||
|
from crm.orders_router import router as crm_orders_router
|
||||||
|
from crm.comms_router import router as crm_comms_router
|
||||||
|
from crm.media_router import router as crm_media_router
|
||||||
|
from crm.nextcloud_router import router as crm_nextcloud_router
|
||||||
|
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.client import mqtt_manager
|
||||||
from mqtt import database as mqtt_db
|
from mqtt import database as mqtt_db
|
||||||
from melodies import service as melody_service
|
from melodies import service as melody_service
|
||||||
@@ -50,6 +59,30 @@ app.include_router(builder_router)
|
|||||||
app.include_router(manufacturing_router)
|
app.include_router(manufacturing_router)
|
||||||
app.include_router(firmware_router)
|
app.include_router(firmware_router)
|
||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
|
app.include_router(crm_products_router)
|
||||||
|
app.include_router(crm_customers_router)
|
||||||
|
app.include_router(crm_orders_router)
|
||||||
|
app.include_router(crm_comms_router)
|
||||||
|
app.include_router(crm_media_router)
|
||||||
|
app.include_router(crm_nextcloud_router)
|
||||||
|
app.include_router(crm_quotations_router)
|
||||||
|
|
||||||
|
|
||||||
|
async def nextcloud_keepalive_loop():
|
||||||
|
await nextcloud_keepalive() # eager warmup on startup
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(45)
|
||||||
|
await nextcloud_keepalive()
|
||||||
|
|
||||||
|
|
||||||
|
async def email_sync_loop():
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(settings.email_sync_interval_minutes * 60)
|
||||||
|
try:
|
||||||
|
from crm.email_sync import sync_emails
|
||||||
|
await sync_emails()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[EMAIL SYNC] Error: {e}")
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
@@ -59,12 +92,20 @@ async def startup():
|
|||||||
await melody_service.migrate_from_firestore()
|
await melody_service.migrate_from_firestore()
|
||||||
mqtt_manager.start(asyncio.get_event_loop())
|
mqtt_manager.start(asyncio.get_event_loop())
|
||||||
asyncio.create_task(mqtt_db.purge_loop())
|
asyncio.create_task(mqtt_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:
|
||||||
|
print(f"[EMAIL SYNC] IMAP configured for {len(sync_accounts)} account(s) - starting sync loop")
|
||||||
|
asyncio.create_task(email_sync_loop())
|
||||||
|
else:
|
||||||
|
print("[EMAIL SYNC] IMAP not configured - sync loop disabled")
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
async def shutdown():
|
async def shutdown():
|
||||||
mqtt_manager.stop()
|
mqtt_manager.stop()
|
||||||
await mqtt_db.close_db()
|
await mqtt_db.close_db()
|
||||||
|
await close_nextcloud_client()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
@@ -74,3 +115,4 @@ async def health_check():
|
|||||||
"firebase": firebase_initialized,
|
"firebase": firebase_initialized,
|
||||||
"mqtt": mqtt_manager.connected,
|
"mqtt": mqtt_manager.connected,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,23 +4,23 @@ from enum import Enum
|
|||||||
|
|
||||||
|
|
||||||
class BoardType(str, Enum):
|
class BoardType(str, Enum):
|
||||||
vs = "vs" # Vesper
|
vesper = "vesper"
|
||||||
vp = "vp" # Vesper Plus
|
vesper_plus = "vesper_plus"
|
||||||
vx = "vx" # Vesper Pro
|
vesper_pro = "vesper_pro"
|
||||||
cb = "cb" # Chronos
|
chronos = "chronos"
|
||||||
cp = "cp" # Chronos Pro
|
chronos_pro = "chronos_pro"
|
||||||
am = "am" # Agnus Mini
|
agnus_mini = "agnus_mini"
|
||||||
ab = "ab" # Agnus
|
agnus = "agnus"
|
||||||
|
|
||||||
|
|
||||||
BOARD_TYPE_LABELS = {
|
BOARD_TYPE_LABELS = {
|
||||||
"vs": "Vesper",
|
"vesper": "Vesper",
|
||||||
"vp": "Vesper Plus",
|
"vesper_plus": "Vesper+",
|
||||||
"vx": "Vesper Pro",
|
"vesper_pro": "Vesper Pro",
|
||||||
"cb": "Chronos",
|
"chronos": "Chronos",
|
||||||
"cp": "Chronos Pro",
|
"chronos_pro": "Chronos Pro",
|
||||||
"am": "Agnus Mini",
|
"agnus_mini": "Agnus Mini",
|
||||||
"ab": "Agnus",
|
"agnus": "Agnus",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class MqttManager:
|
|||||||
|
|
||||||
self._client = paho_mqtt.Client(
|
self._client = paho_mqtt.Client(
|
||||||
callback_api_version=paho_mqtt.CallbackAPIVersion.VERSION2,
|
callback_api_version=paho_mqtt.CallbackAPIVersion.VERSION2,
|
||||||
client_id="bellsystems-admin-panel",
|
client_id=settings.mqtt_client_id,
|
||||||
clean_session=True,
|
clean_session=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -64,6 +64,8 @@ class MqttManager:
|
|||||||
client.subscribe([
|
client.subscribe([
|
||||||
("vesper/+/data", 1),
|
("vesper/+/data", 1),
|
||||||
("vesper/+/status/heartbeat", 1),
|
("vesper/+/status/heartbeat", 1),
|
||||||
|
("vesper/+/status/alerts", 1),
|
||||||
|
("vesper/+/status/info", 0),
|
||||||
("vesper/+/logs", 1),
|
("vesper/+/logs", 1),
|
||||||
])
|
])
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -76,6 +76,102 @@ SCHEMA_STATEMENTS = [
|
|||||||
)""",
|
)""",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_mfg_audit_time ON mfg_audit_log(timestamp)",
|
"CREATE INDEX IF NOT EXISTS idx_mfg_audit_time ON mfg_audit_log(timestamp)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_mfg_audit_action ON mfg_audit_log(action)",
|
"CREATE INDEX IF NOT EXISTS idx_mfg_audit_action ON mfg_audit_log(action)",
|
||||||
|
# Active device alerts (current state, not history)
|
||||||
|
"""CREATE TABLE IF NOT EXISTS device_alerts (
|
||||||
|
device_serial TEXT NOT NULL,
|
||||||
|
subsystem TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
message TEXT,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (device_serial, subsystem)
|
||||||
|
)""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_device_alerts_serial ON device_alerts(device_serial)",
|
||||||
|
# CRM communications log
|
||||||
|
"""CREATE TABLE IF NOT EXISTS crm_comms_log (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
customer_id TEXT,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
mail_account TEXT,
|
||||||
|
direction TEXT NOT NULL,
|
||||||
|
subject TEXT,
|
||||||
|
body TEXT,
|
||||||
|
body_html TEXT,
|
||||||
|
attachments TEXT NOT NULL DEFAULT '[]',
|
||||||
|
ext_message_id TEXT,
|
||||||
|
from_addr TEXT,
|
||||||
|
to_addrs TEXT,
|
||||||
|
logged_by TEXT,
|
||||||
|
occurred_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_crm_comms_customer ON crm_comms_log(customer_id, occurred_at)",
|
||||||
|
# CRM media references
|
||||||
|
"""CREATE TABLE IF NOT EXISTS crm_media (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
customer_id TEXT,
|
||||||
|
order_id TEXT,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
nextcloud_path TEXT NOT NULL,
|
||||||
|
mime_type TEXT,
|
||||||
|
direction TEXT,
|
||||||
|
tags TEXT NOT NULL DEFAULT '[]',
|
||||||
|
uploaded_by TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_crm_media_customer ON crm_media(customer_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_crm_media_order ON crm_media(order_id)",
|
||||||
|
# CRM sync state (last email sync timestamp, etc.)
|
||||||
|
"""CREATE TABLE IF NOT EXISTS crm_sync_state (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
)""",
|
||||||
|
# CRM Quotations
|
||||||
|
"""CREATE TABLE IF NOT EXISTS crm_quotations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
quotation_number TEXT UNIQUE NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
subtitle TEXT,
|
||||||
|
customer_id TEXT NOT NULL,
|
||||||
|
language TEXT NOT NULL DEFAULT 'en',
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
order_type TEXT,
|
||||||
|
shipping_method TEXT,
|
||||||
|
estimated_shipping_date TEXT,
|
||||||
|
global_discount_label TEXT,
|
||||||
|
global_discount_percent REAL NOT NULL DEFAULT 0,
|
||||||
|
vat_percent REAL NOT NULL DEFAULT 24,
|
||||||
|
shipping_cost REAL NOT NULL DEFAULT 0,
|
||||||
|
shipping_cost_discount REAL NOT NULL DEFAULT 0,
|
||||||
|
install_cost REAL NOT NULL DEFAULT 0,
|
||||||
|
install_cost_discount REAL NOT NULL DEFAULT 0,
|
||||||
|
extras_label TEXT,
|
||||||
|
extras_cost REAL NOT NULL DEFAULT 0,
|
||||||
|
comments TEXT NOT NULL DEFAULT '[]',
|
||||||
|
subtotal_before_discount REAL NOT NULL DEFAULT 0,
|
||||||
|
global_discount_amount REAL NOT NULL DEFAULT 0,
|
||||||
|
new_subtotal REAL NOT NULL DEFAULT 0,
|
||||||
|
vat_amount REAL NOT NULL DEFAULT 0,
|
||||||
|
final_total REAL NOT NULL DEFAULT 0,
|
||||||
|
nextcloud_pdf_path TEXT,
|
||||||
|
nextcloud_pdf_url TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)""",
|
||||||
|
"""CREATE TABLE IF NOT EXISTS crm_quotation_items (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
quotation_id TEXT NOT NULL,
|
||||||
|
product_id TEXT,
|
||||||
|
description TEXT,
|
||||||
|
unit_type TEXT NOT NULL DEFAULT 'pcs',
|
||||||
|
unit_cost REAL NOT NULL DEFAULT 0,
|
||||||
|
discount_percent REAL NOT NULL DEFAULT 0,
|
||||||
|
quantity REAL NOT NULL DEFAULT 1,
|
||||||
|
line_total REAL NOT NULL DEFAULT 0,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY (quotation_id) REFERENCES crm_quotations(id)
|
||||||
|
)""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_crm_quotations_customer ON crm_quotations(customer_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_crm_quotation_items_quotation ON crm_quotation_items(quotation_id, sort_order)",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -86,6 +182,65 @@ async def init_db():
|
|||||||
for stmt in SCHEMA_STATEMENTS:
|
for stmt in SCHEMA_STATEMENTS:
|
||||||
await _db.execute(stmt)
|
await _db.execute(stmt)
|
||||||
await _db.commit()
|
await _db.commit()
|
||||||
|
# Migrations: add columns that may not exist in older DBs
|
||||||
|
_migrations = [
|
||||||
|
"ALTER TABLE crm_comms_log ADD COLUMN body_html TEXT",
|
||||||
|
"ALTER TABLE crm_comms_log ADD COLUMN mail_account TEXT",
|
||||||
|
"ALTER TABLE crm_comms_log ADD COLUMN from_addr TEXT",
|
||||||
|
"ALTER TABLE crm_comms_log ADD COLUMN to_addrs TEXT",
|
||||||
|
"ALTER TABLE crm_comms_log ADD COLUMN is_important INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE crm_comms_log ADD COLUMN is_read INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE crm_quotation_items ADD COLUMN vat_percent REAL NOT NULL DEFAULT 24",
|
||||||
|
"ALTER TABLE crm_quotations ADD COLUMN quick_notes TEXT NOT NULL DEFAULT '{}'",
|
||||||
|
"ALTER TABLE crm_quotations ADD COLUMN client_org TEXT",
|
||||||
|
"ALTER TABLE crm_quotations ADD COLUMN client_name TEXT",
|
||||||
|
"ALTER TABLE crm_quotations ADD COLUMN client_location TEXT",
|
||||||
|
"ALTER TABLE crm_quotations ADD COLUMN client_phone TEXT",
|
||||||
|
"ALTER TABLE crm_quotations ADD COLUMN client_email TEXT",
|
||||||
|
]
|
||||||
|
for m in _migrations:
|
||||||
|
try:
|
||||||
|
await _db.execute(m)
|
||||||
|
await _db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass # column already exists
|
||||||
|
|
||||||
|
# Migration: drop NOT NULL on crm_comms_log.customer_id if it exists.
|
||||||
|
# SQLite doesn't support ALTER COLUMN, so we check via table_info and
|
||||||
|
# rebuild the table if needed.
|
||||||
|
rows = await _db.execute_fetchall("PRAGMA table_info(crm_comms_log)")
|
||||||
|
for row in rows:
|
||||||
|
# row: (cid, name, type, notnull, dflt_value, pk)
|
||||||
|
if row[1] == "customer_id" and row[3] == 1: # notnull=1
|
||||||
|
logger.info("Migrating crm_comms_log: removing NOT NULL from customer_id")
|
||||||
|
await _db.execute("ALTER TABLE crm_comms_log RENAME TO crm_comms_log_old")
|
||||||
|
await _db.execute("""CREATE TABLE crm_comms_log (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
customer_id TEXT,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
mail_account TEXT,
|
||||||
|
direction TEXT NOT NULL,
|
||||||
|
subject TEXT,
|
||||||
|
body TEXT,
|
||||||
|
body_html TEXT,
|
||||||
|
attachments TEXT NOT NULL DEFAULT '[]',
|
||||||
|
ext_message_id TEXT,
|
||||||
|
from_addr TEXT,
|
||||||
|
to_addrs TEXT,
|
||||||
|
logged_by TEXT,
|
||||||
|
occurred_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)""")
|
||||||
|
await _db.execute("""INSERT INTO crm_comms_log
|
||||||
|
SELECT id, customer_id, type, NULL, direction, subject, body, body_html,
|
||||||
|
attachments, ext_message_id, from_addr, to_addrs, logged_by,
|
||||||
|
occurred_at, created_at
|
||||||
|
FROM crm_comms_log_old""")
|
||||||
|
await _db.execute("DROP TABLE crm_comms_log_old")
|
||||||
|
await _db.execute("CREATE INDEX IF NOT EXISTS idx_crm_comms_customer ON crm_comms_log(customer_id, occurred_at)")
|
||||||
|
await _db.commit()
|
||||||
|
logger.info("Migration complete: crm_comms_log.customer_id is now nullable")
|
||||||
|
break
|
||||||
logger.info(f"SQLite database initialized at {settings.sqlite_db_path}")
|
logger.info(f"SQLite database initialized at {settings.sqlite_db_path}")
|
||||||
|
|
||||||
|
|
||||||
@@ -252,3 +407,37 @@ async def purge_loop():
|
|||||||
await purge_old_data()
|
await purge_old_data()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Purge failed: {e}")
|
logger.error(f"Purge failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Device Alerts ---
|
||||||
|
|
||||||
|
async def upsert_alert(device_serial: str, subsystem: str, state: str,
|
||||||
|
message: str | None = None):
|
||||||
|
db = await get_db()
|
||||||
|
await db.execute(
|
||||||
|
"""INSERT INTO device_alerts (device_serial, subsystem, state, message, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(device_serial, subsystem)
|
||||||
|
DO UPDATE SET state=excluded.state, message=excluded.message,
|
||||||
|
updated_at=excluded.updated_at""",
|
||||||
|
(device_serial, subsystem, state, message),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_alert(device_serial: str, subsystem: str):
|
||||||
|
db = await get_db()
|
||||||
|
await db.execute(
|
||||||
|
"DELETE FROM device_alerts WHERE device_serial = ? AND subsystem = ?",
|
||||||
|
(device_serial, subsystem),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_alerts(device_serial: str) -> list:
|
||||||
|
db = await get_db()
|
||||||
|
rows = await db.execute_fetchall(
|
||||||
|
"SELECT * FROM device_alerts WHERE device_serial = ? ORDER BY updated_at DESC",
|
||||||
|
(device_serial,),
|
||||||
|
)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ async def handle_message(serial: str, topic_type: str, payload: dict):
|
|||||||
try:
|
try:
|
||||||
if topic_type == "status/heartbeat":
|
if topic_type == "status/heartbeat":
|
||||||
await _handle_heartbeat(serial, payload)
|
await _handle_heartbeat(serial, payload)
|
||||||
|
elif topic_type == "status/alerts":
|
||||||
|
await _handle_alerts(serial, payload)
|
||||||
|
elif topic_type == "status/info":
|
||||||
|
await _handle_info(serial, payload)
|
||||||
elif topic_type == "logs":
|
elif topic_type == "logs":
|
||||||
await _handle_log(serial, payload)
|
await _handle_log(serial, payload)
|
||||||
elif topic_type == "data":
|
elif topic_type == "data":
|
||||||
@@ -29,6 +33,8 @@ async def handle_message(serial: str, topic_type: str, payload: dict):
|
|||||||
|
|
||||||
|
|
||||||
async def _handle_heartbeat(serial: str, payload: dict):
|
async def _handle_heartbeat(serial: str, payload: dict):
|
||||||
|
# Store silently — do not log as a visible event.
|
||||||
|
# The console surfaces an alert only when the device goes silent (no heartbeat for 90s).
|
||||||
inner = payload.get("payload", {})
|
inner = payload.get("payload", {})
|
||||||
await db.insert_heartbeat(
|
await db.insert_heartbeat(
|
||||||
device_serial=serial,
|
device_serial=serial,
|
||||||
@@ -55,6 +61,31 @@ async def _handle_log(serial: str, payload: dict):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_alerts(serial: str, payload: dict):
|
||||||
|
subsystem = payload.get("subsystem", "")
|
||||||
|
state = payload.get("state", "")
|
||||||
|
if not subsystem or not state:
|
||||||
|
logger.warning(f"Malformed alert payload from {serial}: {payload}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if state == "CLEARED":
|
||||||
|
await db.delete_alert(serial, subsystem)
|
||||||
|
else:
|
||||||
|
await db.upsert_alert(serial, subsystem, state, payload.get("msg"))
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_info(serial: str, payload: dict):
|
||||||
|
event_type = payload.get("type", "")
|
||||||
|
data = payload.get("payload", {})
|
||||||
|
|
||||||
|
if event_type == "playback_started":
|
||||||
|
logger.debug(f"{serial}: playback started — melody_uid={data.get('melody_uid')}")
|
||||||
|
elif event_type == "playback_stopped":
|
||||||
|
logger.debug(f"{serial}: playback stopped")
|
||||||
|
else:
|
||||||
|
logger.debug(f"{serial}: info event '{event_type}'")
|
||||||
|
|
||||||
|
|
||||||
async def _handle_data_response(serial: str, payload: dict):
|
async def _handle_data_response(serial: str, payload: dict):
|
||||||
status = payload.get("status", "")
|
status = payload.get("status", "")
|
||||||
|
|
||||||
|
|||||||
@@ -84,3 +84,15 @@ class CommandSendResponse(BaseModel):
|
|||||||
success: bool
|
success: bool
|
||||||
command_id: int
|
command_id: int
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceAlertEntry(BaseModel):
|
||||||
|
device_serial: str
|
||||||
|
subsystem: str
|
||||||
|
state: str
|
||||||
|
message: Optional[str] = None
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceAlertsResponse(BaseModel):
|
||||||
|
alerts: List[DeviceAlertEntry]
|
||||||
|
|||||||
@@ -10,3 +10,6 @@ python-multipart==0.0.20
|
|||||||
bcrypt==4.0.1
|
bcrypt==4.0.1
|
||||||
aiosqlite==0.20.0
|
aiosqlite==0.20.0
|
||||||
resend==2.10.0
|
resend==2.10.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
weasyprint>=62.0
|
||||||
|
jinja2>=3.1.0
|
||||||
|
After Width: | Height: | Size: 21 KiB |
BIN
backend/templates/linktree.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
backend/templates/logo.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
708
backend/templates/quotation.html
Normal file
@@ -0,0 +1,708 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ lang }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<title>{% if lang == 'gr' %}Προσφορά{% else %}Quotation{% endif %} {{ quotation.quotation_number }}</title>
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,400;0,600;0,700;1,400&display=swap');
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Noto Sans', DejaVu Sans, Arial, sans-serif;
|
||||||
|
font-size: 9.5pt;
|
||||||
|
color: #1a1a2e;
|
||||||
|
background: #fff;
|
||||||
|
line-height: 1.45;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding-bottom: 36mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* pushes notes + validity down toward the fixed footer */
|
||||||
|
.main-content-gap {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 15mm 15mm 15mm 15mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── HEADER ── */
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 2.5px solid #5886c4;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.company-block img.logo {
|
||||||
|
max-height: 70px;
|
||||||
|
max-width: 250px;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.company-block p {
|
||||||
|
font-size: 10pt;
|
||||||
|
color: #6b8fc4;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotation-meta-block {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.quotation-meta-block .doc-type {
|
||||||
|
font-size: 14pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #5886c4;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.quotation-meta-block .meta-line {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.quotation-meta-block .meta-line .meta-label {
|
||||||
|
color: #7a9cc8;
|
||||||
|
}
|
||||||
|
.quotation-meta-block .meta-line .meta-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CLIENT + ORDER META ── */
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-block, .order-block {
|
||||||
|
border: 1px solid #c2d4ec;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
.client-block { flex: 65; }
|
||||||
|
.order-block { flex: 35; }
|
||||||
|
|
||||||
|
.block-title {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #5886c4;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
border-bottom: 1px solid #dce9f7;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row table.fields {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.info-row table.fields td {
|
||||||
|
padding: 1px 0;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.info-row table.fields td.lbl {
|
||||||
|
font-size: 8pt;
|
||||||
|
color: #7a9cc8;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
.info-row table.fields td.val {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── TITLE / SUBTITLE ── */
|
||||||
|
.quotation-title {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.quotation-title h2 {
|
||||||
|
font-size: 13pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #3a6aad;
|
||||||
|
}
|
||||||
|
.quotation-title p {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #555;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ITEMS TABLE ── */
|
||||||
|
.items-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
.items-table thead tr {
|
||||||
|
background: #5886c4;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.items-table thead th {
|
||||||
|
padding: 6px 8px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 8pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
.items-table thead th.right { text-align: right; }
|
||||||
|
.items-table thead th.center { text-align: center; }
|
||||||
|
|
||||||
|
.items-table tbody tr:nth-child(even) { background: #eef4fc; }
|
||||||
|
.items-table tbody tr:nth-child(odd) { background: #fff; }
|
||||||
|
|
||||||
|
.items-table tbody td {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-bottom: 1px solid #dce9f7;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.items-table tbody td.right { text-align: right; }
|
||||||
|
.items-table tbody td.center { text-align: center; }
|
||||||
|
.items-table tbody td.muted { color: #7a9cc8; font-size: 8pt; }
|
||||||
|
|
||||||
|
/* Special rows for shipping/install */
|
||||||
|
.items-table tbody tr.special-row td {
|
||||||
|
background: #edf3fb;
|
||||||
|
border-top: 1px solid #c2d4ec;
|
||||||
|
border-bottom: 1px solid #c2d4ec;
|
||||||
|
font-style: italic;
|
||||||
|
color: #3a6aad;
|
||||||
|
}
|
||||||
|
.items-table tbody tr.special-spacer td {
|
||||||
|
height: 6px;
|
||||||
|
background: #f4f8fd;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── BELOW TABLE ROW: VAT notice + totals ── */
|
||||||
|
.below-table {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vat-notice {
|
||||||
|
flex: 1;
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
.vat-notice p {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #3a6aad;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
border-left: 3px solid #5886c4;
|
||||||
|
padding-left: 7px;
|
||||||
|
padding-top: 2px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── TOTALS ── */
|
||||||
|
.totals-table {
|
||||||
|
width: 280px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.totals-table td {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-bottom: 1px solid #dce9f7;
|
||||||
|
}
|
||||||
|
.totals-table .label { color: #555; text-align: right; }
|
||||||
|
.totals-table .value { text-align: right; font-weight: 500; min-width: 90px; }
|
||||||
|
.totals-table .discount-row { color: #c0392b; }
|
||||||
|
.totals-table .new-subtotal-row td { font-size: 10pt; font-weight: 700; color: #1a1a2e; }
|
||||||
|
.totals-table .vat-row td { color: #7a9cc8; font-style: italic; }
|
||||||
|
.totals-table .final-row td {
|
||||||
|
font-size: 11pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #3a6aad;
|
||||||
|
border-top: 2px solid #5886c4;
|
||||||
|
border-bottom: none;
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── COMMENTS ── */
|
||||||
|
.comments-section {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.comments-section .section-title {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #5886c4;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.comments-section ul {
|
||||||
|
padding-left: 14px;
|
||||||
|
}
|
||||||
|
.comments-section li {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── FOOTER (validity line only) ── */
|
||||||
|
.footer {
|
||||||
|
border-top: 1px solid #c2d4ec;
|
||||||
|
padding-top: 7px;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.footer .validity {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
font-style: italic;
|
||||||
|
color: #7a9cc8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── FIXED BOTTOM FOOTER (repeats on every page) ── */
|
||||||
|
.fixed-footer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 8px 0 0 0;
|
||||||
|
border-top: 1.5px solid #5886c4;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 20px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.footer-block {
|
||||||
|
width: 30%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.footer-block-title {
|
||||||
|
font-size: 7pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #5886c4;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-bottom: 1px solid #dce9f7;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
.footer-block dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: 2px 6px;
|
||||||
|
padding-left: 0;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
.footer-block dt {
|
||||||
|
font-size: 7pt;
|
||||||
|
color: #7a9cc8;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.footer-block dd {
|
||||||
|
font-size: 7pt;
|
||||||
|
color: #1a1a2e;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.footer-ref {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.footer-ref .ref-quot {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #5886c4;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.footer-ref .ref-page {
|
||||||
|
font-size: 7pt;
|
||||||
|
color: #7a9cc8;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.footer-ref .ref-page::after {
|
||||||
|
content: counter(page) " / " counter(pages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── UTILS ── */
|
||||||
|
.text-muted { color: #aaa; }
|
||||||
|
.dash { color: #ccc; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{# ── Bilingual labels ── #}
|
||||||
|
{% if lang == 'gr' %}
|
||||||
|
{% set L_QUOTATION = "ΠΡΟΣΦΟΡΑ" %}
|
||||||
|
{% set L_NUMBER = "Αριθμός" %}
|
||||||
|
{% set L_DATE = "Ημερομηνία" %}
|
||||||
|
{% set L_CLIENT = "ΣΤΟΙΧΕΙΑ ΠΕΛΑΤΗ" %}
|
||||||
|
{% set L_ORDER_META = "ΣΤΟΙΧΕΙΑ ΠΑΡΑΓΓΕΛΙΑΣ" %}
|
||||||
|
{% set L_ORDER_TYPE = "Τύπος" %}
|
||||||
|
{% set L_SHIP_METHOD = "Τρ. Αποστολής" %}
|
||||||
|
{% set L_SHIP_DATE = "Εκτιμώμενη Παράδοση" %}
|
||||||
|
{% set L_DESC = "Περιγραφή" %}
|
||||||
|
{% set L_UNIT_COST = "Τιμή Μον." %}
|
||||||
|
{% set L_DISC = "Έκπτ." %}
|
||||||
|
{% set L_QTY = "Ποσ." %}
|
||||||
|
{% set L_UNIT = "Μον." %}
|
||||||
|
{% set L_VAT_COL = "Φ.Π.Α." %}
|
||||||
|
{% set L_TOTAL = "Σύνολο" %}
|
||||||
|
{% set L_SUBTOTAL = "Υποσύνολο" %}
|
||||||
|
{% set L_GLOBAL_DISC = quotation.global_discount_label or "Έκπτωση" %}
|
||||||
|
{% set L_NEW_SUBTOTAL = "Νέο Υποσύνολο" %}
|
||||||
|
{% set L_VAT = "ΣΥΝΟΛΟ Φ.Π.Α." %}
|
||||||
|
{% set L_SHIPPING_COST = "Μεταφορικά / Shipping" %}
|
||||||
|
{% set L_INSTALL_COST = "Εγκατάσταση / Installation" %}
|
||||||
|
{% set L_EXTRAS = quotation.extras_label or "Άλλα" %}
|
||||||
|
{% set L_FINAL = "ΣΥΝΟΛΟ ΠΛΗΡΩΤΕΟ" %}
|
||||||
|
{% set L_COMMENTS = "ΣΗΜΕΙΩΣΕΙΣ" %}
|
||||||
|
{% set L_VALIDITY = "Η προσφορά ισχύει για 30 ημέρες από την ημερομηνία έκδοσής της." %}
|
||||||
|
{% set L_ORG = "Φορέας" %}
|
||||||
|
{% set L_CONTACT = "Επικοινωνία" %}
|
||||||
|
{% set L_ADDRESS = "Διεύθυνση" %}
|
||||||
|
{% set L_PHONE = "Τηλέφωνο" %}
|
||||||
|
{% set L_COMPANY_ADDR = "Ε.Ο. Αντιρρίου Ιωαννίνων 23, Αγρίνιο, 30131" %}
|
||||||
|
{% set L_CONTACT_INFO = "ΣΤΟΙΧΕΙΑ ΕΠΙΚΟΙΝΩΝΙΑΣ" %}
|
||||||
|
{% set L_PAYMENT_INFO = "ΣΤΟΙΧΕΙΑ ΠΛΗΡΩΜΗΣ" %}
|
||||||
|
{% else %}
|
||||||
|
{% set L_QUOTATION = "QUOTATION" %}
|
||||||
|
{% set L_NUMBER = "Number" %}
|
||||||
|
{% set L_DATE = "Date" %}
|
||||||
|
{% set L_CLIENT = "CLIENT DETAILS" %}
|
||||||
|
{% set L_ORDER_META = "ORDER DETAILS" %}
|
||||||
|
{% set L_ORDER_TYPE = "Order Type" %}
|
||||||
|
{% set L_SHIP_METHOD = "Ship. Method" %}
|
||||||
|
{% set L_SHIP_DATE = "Est. Delivery" %}
|
||||||
|
{% set L_DESC = "Description" %}
|
||||||
|
{% set L_UNIT_COST = "Unit Cost" %}
|
||||||
|
{% set L_DISC = "Disc." %}
|
||||||
|
{% set L_QTY = "Qty" %}
|
||||||
|
{% set L_UNIT = "Unit" %}
|
||||||
|
{% set L_VAT_COL = "VAT" %}
|
||||||
|
{% set L_TOTAL = "Total" %}
|
||||||
|
{% set L_SUBTOTAL = "Subtotal" %}
|
||||||
|
{% set L_GLOBAL_DISC = quotation.global_discount_label or "Discount" %}
|
||||||
|
{% set L_NEW_SUBTOTAL = "New Subtotal" %}
|
||||||
|
{% set L_VAT = "Total VAT" %}
|
||||||
|
{% set L_SHIPPING_COST = "Shipping / Transport" %}
|
||||||
|
{% set L_INSTALL_COST = "Installation" %}
|
||||||
|
{% set L_EXTRAS = quotation.extras_label or "Extras" %}
|
||||||
|
{% set L_FINAL = "TOTAL DUE" %}
|
||||||
|
{% set L_COMMENTS = "NOTES" %}
|
||||||
|
{% set L_VALIDITY = "This quotation is valid for 30 days from the date of issue." %}
|
||||||
|
{% set L_ORG = "Organization" %}
|
||||||
|
{% set L_CONTACT = "Contact" %}
|
||||||
|
{% set L_ADDRESS = "Location" %}
|
||||||
|
{% set L_PHONE = "Phone" %}
|
||||||
|
{% set L_COMPANY_ADDR = "E.O. Antirriou Ioanninon 23, Agrinio, 30131, Greece" %}
|
||||||
|
{% set L_CONTACT_INFO = "CONTACT INFORMATION" %}
|
||||||
|
{% set L_PAYMENT_INFO = "PAYMENT DETAILS" %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Derived values ── #}
|
||||||
|
{% set today = quotation.created_at[:10] %}
|
||||||
|
|
||||||
|
{# ── Find phone/email contacts + check if primary contact is already phone/email ── #}
|
||||||
|
{% set ns = namespace(customer_phone='', customer_email='', primary_is_phone=false, primary_is_email=false) %}
|
||||||
|
{% for contact in customer.contacts %}
|
||||||
|
{% if contact.type == 'phone' and contact.value %}{% if contact.primary %}{% set ns.customer_phone = contact.value %}{% set ns.primary_is_phone = true %}{% elif not ns.customer_phone %}{% set ns.customer_phone = contact.value %}{% endif %}{% endif %}
|
||||||
|
{% if contact.type == 'email' and contact.value %}{% if contact.primary %}{% set ns.customer_email = contact.value %}{% set ns.primary_is_email = true %}{% elif not ns.customer_email %}{% set ns.customer_email = contact.value %}{% endif %}{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% set customer_phone = ns.customer_phone %}
|
||||||
|
{% set customer_email = ns.customer_email %}
|
||||||
|
{% set primary_is_phone = ns.primary_is_phone %}
|
||||||
|
{% set primary_is_email = ns.primary_is_email %}
|
||||||
|
|
||||||
|
<!-- HEADER -->
|
||||||
|
<div class="header">
|
||||||
|
<div class="company-block">
|
||||||
|
<img class="logo" src="./logo.png" alt="BellSystems"/>
|
||||||
|
<p>{{ L_COMPANY_ADDR }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="quotation-meta-block">
|
||||||
|
<div class="doc-type">{{ L_QUOTATION }}</div>
|
||||||
|
<div class="meta-line"><span class="meta-label">{{ L_NUMBER }}: </span><span class="meta-value">{{ quotation.quotation_number }}</span></div>
|
||||||
|
<div class="meta-line"><span class="meta-label">{{ L_DATE }}: </span><span class="meta-value">{{ today }}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TITLE / SUBTITLE -->
|
||||||
|
{% if quotation.title %}
|
||||||
|
<div class="quotation-title">
|
||||||
|
<h2>{{ quotation.title }}</h2>
|
||||||
|
{% if quotation.subtitle %}<p>{{ quotation.subtitle }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- CLIENT + ORDER META -->
|
||||||
|
<div class="info-row">
|
||||||
|
|
||||||
|
<div class="client-block">
|
||||||
|
<div class="block-title">{{ L_CLIENT }}</div>
|
||||||
|
<table class="fields"><tbody>{% if customer.organization %}<tr><td class="lbl">{{ L_ORG }}</td><td class="val">{{ customer.organization }}</td></tr>{% endif %}{% set name_parts = [customer.title, customer.name, customer.surname] | select | list %}{% if name_parts %}<tr><td class="lbl">{{ L_CONTACT }}</td><td class="val">{{ name_parts | join(' ') }}</td></tr>{% endif %}{% if customer.location %}{% set loc_parts = [customer.location.city, customer.location.region, customer.location.country] | select | list %}{% if loc_parts %}<tr><td class="lbl">{{ L_ADDRESS }}</td><td class="val">{{ loc_parts | join(', ') }}</td></tr>{% endif %}{% endif %}{% if customer_email %}<tr><td class="lbl">Email</td><td class="val">{{ customer_email }}</td></tr>{% endif %}{% if customer_phone %}<tr><td class="lbl">{{ L_PHONE }}</td><td class="val">{{ customer_phone }}</td></tr>{% endif %}</tbody></table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="order-block">
|
||||||
|
<div class="block-title">{{ L_ORDER_META }}</div>
|
||||||
|
<table class="fields"><tbody>{% if quotation.order_type %}<tr><td class="lbl">{{ L_ORDER_TYPE }}</td><td class="val">{{ quotation.order_type }}</td></tr>{% endif %}{% if quotation.shipping_method %}<tr><td class="lbl">{{ L_SHIP_METHOD }}</td><td class="val">{{ quotation.shipping_method }}</td></tr>{% endif %}{% if quotation.estimated_shipping_date %}<tr><td class="lbl">{{ L_SHIP_DATE }}</td><td class="val">{{ quotation.estimated_shipping_date }}</td></tr>{% else %}<tr><td class="lbl">{{ L_SHIP_DATE }}</td><td class="val text-muted">—</td></tr>{% endif %}</tbody></table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ITEMS TABLE -->
|
||||||
|
<table class="items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:38%">{{ L_DESC }}</th>
|
||||||
|
<th class="right" style="width:11%">{{ L_UNIT_COST }}</th>
|
||||||
|
<th class="center" style="width:7%">{{ L_DISC }}</th>
|
||||||
|
<th class="center" style="width:7%">{{ L_QTY }}</th>
|
||||||
|
<th class="center" style="width:7%">{{ L_UNIT }}</th>
|
||||||
|
<th class="center" style="width:6%">{{ L_VAT_COL }}</th>
|
||||||
|
<th class="right" style="width:12%">{{ L_TOTAL }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in quotation.items %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.description or '' }}</td>
|
||||||
|
<td class="right">{{ item.unit_cost | format_money }}</td>
|
||||||
|
<td class="center">
|
||||||
|
{% if item.discount_percent and item.discount_percent > 0 %}
|
||||||
|
{{ item.discount_percent | int }}%
|
||||||
|
{% else %}
|
||||||
|
<span class="dash">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="center">{{ item.quantity | int if item.quantity == (item.quantity | int) else item.quantity }}</td>
|
||||||
|
<td class="center muted">{{ item.unit_type }}</td>
|
||||||
|
<td class="center">
|
||||||
|
{% if item.vat_percent and item.vat_percent > 0 %}
|
||||||
|
{{ item.vat_percent | int }}%
|
||||||
|
{% else %}
|
||||||
|
<span class="dash">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="right">{{ item.line_total | format_money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if quotation.items | length == 0 %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="text-muted" style="text-align:center; padding: 12px;">—</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Shipping / Install as special rows ── #}
|
||||||
|
{% set has_special = (quotation.shipping_cost and quotation.shipping_cost > 0) or (quotation.install_cost and quotation.install_cost > 0) %}
|
||||||
|
{% if has_special %}
|
||||||
|
<tr class="special-spacer"><td colspan="7"></td></tr>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if quotation.shipping_cost and quotation.shipping_cost > 0 %}
|
||||||
|
{% set ship_net = quotation.shipping_cost * (1 - quotation.shipping_cost_discount / 100) %}
|
||||||
|
<tr class="special-row">
|
||||||
|
<td>{{ L_SHIPPING_COST }}{% if quotation.shipping_cost_discount and quotation.shipping_cost_discount > 0 %} <span style="font-size:7.5pt; color:#7a9cc8;">(-{{ quotation.shipping_cost_discount | int }}%)</span>{% endif %}</td>
|
||||||
|
<td class="right">{{ quotation.shipping_cost | format_money }}</td>
|
||||||
|
<td class="center"><span class="dash">—</span></td>
|
||||||
|
<td class="center">1</td>
|
||||||
|
<td class="center muted">—</td>
|
||||||
|
<td class="center"><span class="dash">—</span></td>
|
||||||
|
<td class="right">{{ ship_net | format_money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if quotation.install_cost and quotation.install_cost > 0 %}
|
||||||
|
{% set install_net = quotation.install_cost * (1 - quotation.install_cost_discount / 100) %}
|
||||||
|
<tr class="special-row">
|
||||||
|
<td>{{ L_INSTALL_COST }}{% if quotation.install_cost_discount and quotation.install_cost_discount > 0 %} <span style="font-size:7.5pt; color:#7a9cc8;">(-{{ quotation.install_cost_discount | int }}%)</span>{% endif %}</td>
|
||||||
|
<td class="right">{{ quotation.install_cost | format_money }}</td>
|
||||||
|
<td class="center"><span class="dash">—</span></td>
|
||||||
|
<td class="center">1</td>
|
||||||
|
<td class="center muted">—</td>
|
||||||
|
<td class="center"><span class="dash">—</span></td>
|
||||||
|
<td class="right">{{ install_net | format_money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- TOTALS + VAT NOTICE -->
|
||||||
|
<div class="below-table">
|
||||||
|
|
||||||
|
<div class="vat-notice">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="totals-table">
|
||||||
|
<tr>
|
||||||
|
<td class="label">{{ L_SUBTOTAL }}</td>
|
||||||
|
<td class="value">{{ quotation.subtotal_before_discount | format_money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% if quotation.global_discount_percent and quotation.global_discount_percent > 0 %}
|
||||||
|
<tr class="discount-row">
|
||||||
|
<td class="label">{{ L_GLOBAL_DISC }} ({{ quotation.global_discount_percent | int }}%)</td>
|
||||||
|
<td class="value">- {{ quotation.global_discount_amount | format_money }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="new-subtotal-row">
|
||||||
|
<td class="label">{{ L_NEW_SUBTOTAL }}</td>
|
||||||
|
<td class="value">{{ quotation.new_subtotal | format_money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<tr class="vat-row">
|
||||||
|
<td class="label">{{ L_VAT }}</td>
|
||||||
|
<td class="value">{{ quotation.vat_amount | format_money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% if quotation.extras_cost and quotation.extras_cost > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td class="label">{{ L_EXTRAS }}</td>
|
||||||
|
<td class="value">{{ quotation.extras_cost | format_money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<tr class="final-row">
|
||||||
|
<td class="label">{{ L_FINAL }}</td>
|
||||||
|
<td class="value">{{ quotation.final_total | format_money }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SPACER: flexible gap between totals and notes -->
|
||||||
|
<div class="main-content-gap"></div>
|
||||||
|
|
||||||
|
<!-- COMMENTS / NOTES -->
|
||||||
|
{% set qn = quotation.quick_notes or {} %}
|
||||||
|
{% set has_quick = (qn.payment_advance and qn.payment_advance.enabled) or (qn.lead_time and qn.lead_time.enabled) or (qn.backup_relays and qn.backup_relays.enabled) %}
|
||||||
|
{% set has_comments = quotation.comments and quotation.comments | length > 0 %}
|
||||||
|
|
||||||
|
{% if has_quick or has_comments %}
|
||||||
|
<div class="comments-section">
|
||||||
|
<div class="section-title">{{ L_COMMENTS }}</div>
|
||||||
|
<ul>
|
||||||
|
|
||||||
|
{# ── Quick Notes ── #}
|
||||||
|
|
||||||
|
{# Payment Advance #}
|
||||||
|
{% if qn.payment_advance and qn.payment_advance.enabled %}
|
||||||
|
{% set pct = qn.payment_advance.percent | string %}
|
||||||
|
{% if lang == 'gr' %}
|
||||||
|
<li>Απαιτείται προκαταβολή <strong>{{ pct }}%</strong> με την επιβεβαίωση της παραγγελίας.</li>
|
||||||
|
{% else %}
|
||||||
|
<li><strong>{{ pct }}%</strong> advance payment is required upon order confirmation.</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Lead Time #}
|
||||||
|
{% if qn.lead_time and qn.lead_time.enabled %}
|
||||||
|
{% set days = qn.lead_time.days | string %}
|
||||||
|
{% if lang == 'gr' %}
|
||||||
|
<li>Εκτιμώμενος χρόνος παράδοσης, <strong>{{ days }} εργάσιμες ημέρες</strong> από την επιβεβαίωση της παραγγελίας και παραλαβή της προκαταβολής.</li>
|
||||||
|
{% else %}
|
||||||
|
<li>Estimated delivery time is <strong>{{ days }} working days</strong> from order confirmation and receipt of advance payment.</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Backup Relays #}
|
||||||
|
{% if qn.backup_relays and qn.backup_relays.enabled %}
|
||||||
|
{% set n = qn.backup_relays.count | int %}
|
||||||
|
{% if lang == 'gr' %}
|
||||||
|
{% if n == 1 %}
|
||||||
|
<li>Συμπεριλαμβάνονται: <strong>{{ n }} έξτρα Εφεδρικό Ρελέ Ισχύος</strong></li>
|
||||||
|
{% else %}
|
||||||
|
<li>Συμπεριλαμβάνονται: <strong>{{ n }} έξτρα Εφεδρικά Ρελέ Ισχύος</strong></li>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if n == 1 %}
|
||||||
|
<li><strong>{{ n }} Extra Relay</strong> included as Backup, free of charge.</li>
|
||||||
|
{% else %}
|
||||||
|
<li><strong>{{ n }} Extra Relays</strong> included as Backups, free of charge.</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Dynamic comments ── #}
|
||||||
|
{% if has_comments %}
|
||||||
|
{% for comment in quotation.comments %}
|
||||||
|
{% if comment and comment.strip() %}
|
||||||
|
<li>{{ comment }}</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- VALIDITY -->
|
||||||
|
<div class="footer">
|
||||||
|
<span class="validity">{{ L_VALIDITY }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FIXED BOTTOM FOOTER: contact + payment (repeats every page) -->
|
||||||
|
<div class="fixed-footer">
|
||||||
|
|
||||||
|
<div class="footer-block">
|
||||||
|
<div class="footer-block-title">{{ L_CONTACT_INFO }}</div>
|
||||||
|
<dl>
|
||||||
|
<dt>{% if lang == 'gr' %}Εταιρεία{% else %}Company{% endif %}</dt>
|
||||||
|
<dd>BellSystems</dd>
|
||||||
|
<dt>{% if lang == 'gr' %}Τηλ.{% else %}Phone{% endif %}</dt>
|
||||||
|
<dd>+(30) 26410 33344</dd>
|
||||||
|
<dt>{% if lang == 'gr' %}Email{% else %}Email{% endif %}</dt>
|
||||||
|
<dd>sales@bellsystems.gr</dd>
|
||||||
|
<dt>Web</dt>
|
||||||
|
<dd>www.bellsystems.gr</dd>
|
||||||
|
<dt>Links</dt>
|
||||||
|
<dd><img src="./linktree.png" alt="linktr.ee/bellsystems" style="height: 7pt; vertical-align: middle;"/></dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-block">
|
||||||
|
<div class="footer-block-title">{{ L_PAYMENT_INFO }}</div>
|
||||||
|
<dl>
|
||||||
|
<dt>{% if lang == 'gr' %}Τράπεζα{% else %}Bank{% endif %}</dt>
|
||||||
|
<dd>Piraeus Bank</dd>
|
||||||
|
<dt>{% if lang == 'gr' %}Δικαιούχος{% else %}Holder{% endif %}</dt>
|
||||||
|
<dd>Pontikas Georgios</dd>
|
||||||
|
<dt>{% if lang == 'gr' %}Αριθμός{% else %}Account No.{% endif %}</dt>
|
||||||
|
<dd>6264-1484-35226</dd>
|
||||||
|
<dt>IBAN</dt>
|
||||||
|
<dd>GR8101712640006264148435226</dd>
|
||||||
|
<dt>BIC/SWIFT</dt>
|
||||||
|
<dd>PIRBGRAA</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-ref">
|
||||||
|
<span class="ref-quot">{{ quotation.quotation_number }}</span>
|
||||||
|
<span class="ref-page">{% if lang == 'gr' %}Σελίδα {% else %}Page {% endif %}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -181,7 +181,7 @@ def generate(serial_number: str, hw_type: str, hw_version: str) -> bytes:
|
|||||||
"""Generate a 0x5000-byte NVS partition binary for a Vesper device.
|
"""Generate a 0x5000-byte NVS partition binary for a Vesper device.
|
||||||
|
|
||||||
serial_number: full SN string e.g. 'PV-26B27-VS01R-X7KQA'
|
serial_number: full SN string e.g. 'PV-26B27-VS01R-X7KQA'
|
||||||
hw_type: lowercase board type e.g. 'vs', 'vp', 'vx'
|
hw_type: board type e.g. 'vesper', 'vesper_plus', 'vesper_pro'
|
||||||
hw_version: zero-padded version e.g. '01'
|
hw_version: zero-padded version e.g. '01'
|
||||||
|
|
||||||
Returns raw bytes ready to flash at 0x9000.
|
Returns raw bytes ready to flash at 0x9000.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>BellSystems Admin</title>
|
<title>BellSystems Admin</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
BIN
frontend/public/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
1
frontend/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 43 KiB |
@@ -30,6 +30,13 @@ import DeviceInventoryDetail from "./manufacturing/DeviceInventoryDetail";
|
|||||||
import ProvisioningWizard from "./manufacturing/ProvisioningWizard";
|
import ProvisioningWizard from "./manufacturing/ProvisioningWizard";
|
||||||
import FirmwareManager from "./firmware/FirmwareManager";
|
import FirmwareManager from "./firmware/FirmwareManager";
|
||||||
import DashboardPage from "./dashboard/DashboardPage";
|
import DashboardPage from "./dashboard/DashboardPage";
|
||||||
|
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 CommsPage from "./crm/inbox/CommsPage";
|
||||||
|
import MailPage from "./crm/mail/MailPage";
|
||||||
|
|
||||||
function ProtectedRoute({ children }) {
|
function ProtectedRoute({ children }) {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
@@ -150,6 +157,30 @@ export default function App() {
|
|||||||
<Route path="manufacturing/devices/:sn" element={<PermissionGate section="manufacturing"><DeviceInventoryDetail /></PermissionGate>} />
|
<Route path="manufacturing/devices/:sn" element={<PermissionGate section="manufacturing"><DeviceInventoryDetail /></PermissionGate>} />
|
||||||
<Route path="firmware" element={<PermissionGate section="manufacturing"><FirmwareManager /></PermissionGate>} />
|
<Route path="firmware" element={<PermissionGate section="manufacturing"><FirmwareManager /></PermissionGate>} />
|
||||||
|
|
||||||
|
{/* Mail */}
|
||||||
|
<Route path="mail" element={<PermissionGate section="crm"><MailPage /></PermissionGate>} />
|
||||||
|
|
||||||
|
{/* CRM */}
|
||||||
|
<Route path="crm/comms" element={<PermissionGate section="crm"><CommsPage /></PermissionGate>} />
|
||||||
|
<Route path="crm/inbox" element={<Navigate to="/crm/comms" replace />} />
|
||||||
|
<Route path="crm/products" element={<PermissionGate section="crm"><ProductList /></PermissionGate>} />
|
||||||
|
<Route path="crm/products/new" element={<PermissionGate section="crm" action="edit"><ProductForm /></PermissionGate>} />
|
||||||
|
<Route path="crm/products/:id" element={<PermissionGate section="crm"><ProductForm /></PermissionGate>} />
|
||||||
|
<Route path="crm/customers" element={<PermissionGate section="crm"><CustomerList /></PermissionGate>} />
|
||||||
|
<Route path="crm/customers/new" element={<PermissionGate section="crm" action="edit"><CustomerForm /></PermissionGate>} />
|
||||||
|
<Route path="crm/customers/:id" element={<PermissionGate section="crm"><CustomerDetail /></PermissionGate>} />
|
||||||
|
<Route path="crm/customers/:id/edit" element={<PermissionGate section="crm" action="edit"><CustomerForm /></PermissionGate>} />
|
||||||
|
<Route path="crm/orders" element={<PermissionGate section="crm"><OrderList /></PermissionGate>} />
|
||||||
|
<Route path="crm/orders/new" element={<PermissionGate section="crm" action="edit"><OrderForm /></PermissionGate>} />
|
||||||
|
<Route path="crm/orders/:id" element={<PermissionGate section="crm"><OrderDetail /></PermissionGate>} />
|
||||||
|
<Route path="crm/orders/:id/edit" element={<PermissionGate section="crm" action="edit"><OrderForm /></PermissionGate>} />
|
||||||
|
<Route path="crm/quotations/new" element={<PermissionGate section="crm" action="edit"><QuotationForm /></PermissionGate>} />
|
||||||
|
<Route path="crm/quotations/:id" element={<PermissionGate section="crm" action="edit"><QuotationForm /></PermissionGate>} />
|
||||||
|
|
||||||
|
{/* Developer */}
|
||||||
|
{/* TODO: replace RoleGate with a dedicated "developer" permission once granular permissions are implemented */}
|
||||||
|
<Route path="developer/api" element={<RoleGate roles={["sysadmin", "admin"]}><ApiReferencePage /></RoleGate>} />
|
||||||
|
|
||||||
{/* Settings - Staff Management */}
|
{/* Settings - Staff Management */}
|
||||||
<Route path="settings/staff" element={<RoleGate roles={["sysadmin", "admin"]}><StaffList /></RoleGate>} />
|
<Route path="settings/staff" element={<RoleGate roles={["sysadmin", "admin"]}><StaffList /></RoleGate>} />
|
||||||
<Route path="settings/staff/new" element={<RoleGate roles={["sysadmin", "admin"]}><StaffForm /></RoleGate>} />
|
<Route path="settings/staff/new" element={<RoleGate roles={["sysadmin", "admin"]}><StaffForm /></RoleGate>} />
|
||||||
|
|||||||
25
frontend/src/assets/comms/call.svg
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
viewBox="0 0 512 512" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#000000;}
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M500.177,55.798c0,0-21.735-7.434-39.551-11.967C411.686,31.369,308.824,24.727,256,24.727
|
||||||
|
S100.314,31.369,51.374,43.831c-17.816,4.534-39.551,11.967-39.551,11.967c-7.542,2.28-12.444,9.524-11.76,17.374l8.507,97.835
|
||||||
|
c0.757,8.596,7.957,15.201,16.581,15.201h84.787c8.506,0,15.643-6.416,16.553-14.878l4.28-39.973
|
||||||
|
c0.847-7.93,7.2-14.138,15.148-14.815c0,0,68.484-6.182,110.081-6.182c41.586,0,110.08,6.182,110.08,6.182
|
||||||
|
c7.949,0.676,14.302,6.885,15.148,14.815l4.29,39.973c0.9,8.462,8.038,14.878,16.545,14.878h84.777
|
||||||
|
c8.632,0,15.832-6.605,16.589-15.201l8.507-97.835C512.621,65.322,507.72,58.078,500.177,55.798z"/>
|
||||||
|
<path class="st0" d="M357.503,136.629h-55.365v46.137h-92.275v-46.137h-55.365c0,0-9.228,119.957-119.957,207.618
|
||||||
|
c0,32.296,0,129.95,0,129.95c0,7.218,5.857,13.076,13.075,13.076h416.768c7.218,0,13.076-5.858,13.076-13.076
|
||||||
|
c0,0,0-97.654,0-129.95C366.73,256.586,357.503,136.629,357.503,136.629z M338.768,391.42v37.406h-37.396V391.42H338.768z
|
||||||
|
M338.768,332.27v37.406h-37.396V332.27H338.768z M301.372,310.518v-37.396h37.396v37.396H301.372z M274.698,391.42v37.406h-37.396
|
||||||
|
V391.42H274.698z M274.698,332.27v37.406h-37.396V332.27H274.698z M274.698,273.122v37.396h-37.396v-37.396H274.698z
|
||||||
|
M210.629,391.42v37.406h-37.397V391.42H210.629z M210.629,332.27v37.406h-37.397V332.27H210.629z M210.629,273.122v37.396h-37.397
|
||||||
|
v-37.396H210.629z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
8
frontend/src/assets/comms/email.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" ?>
|
||||||
|
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 6.3500002 6.3500002" id="svg1976" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
|
<defs id="defs1970"/>
|
||||||
|
|
||||||
|
<g id="layer1" style="display:inline">
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
4
frontend/src/assets/comms/inbound.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9.16421 9.66421L15.4142 3.41421L12.5858 0.585785L6.33579 6.83578L3.5 4L2 5.5V14H10.5L12 12.5L9.16421 9.66421Z" fill="#000000"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 365 B |
17
frontend/src/assets/comms/inperson.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
viewBox="0 0 512 512" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#000000;}
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M458.159,404.216c-18.93-33.65-49.934-71.764-100.409-93.431c-28.868,20.196-63.938,32.087-101.745,32.087
|
||||||
|
c-37.828,0-72.898-11.89-101.767-32.087c-50.474,21.667-81.479,59.782-100.398,93.431C28.731,448.848,48.417,512,91.842,512
|
||||||
|
c43.426,0,164.164,0,164.164,0s120.726,0,164.153,0C463.583,512,483.269,448.848,458.159,404.216z"/>
|
||||||
|
<path class="st0" d="M256.005,300.641c74.144,0,134.231-60.108,134.231-134.242v-32.158C390.236,60.108,330.149,0,256.005,0
|
||||||
|
c-74.155,0-134.252,60.108-134.252,134.242V166.4C121.753,240.533,181.851,300.641,256.005,300.641z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
8
frontend/src/assets/comms/internal.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6 18V9C6 7.34315 7.34315 6 9 6H39C40.6569 6 42 7.34315 42 9V18" stroke="#000000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M32 24V31" stroke="#000000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M24 15V31" stroke="#000000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M16 19V31" stroke="#000000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M6 30V39C6 40.6569 7.34315 42 9 42H39C40.6569 42 42 40.6569 42 39V30" stroke="#000000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 855 B |
2
frontend/src/assets/comms/mail.svg
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" id="phone-out" class="icon glyph"><path d="M21,15v3.93a2,2,0,0,1-2.29,2A18,18,0,0,1,3.14,5.29,2,2,0,0,1,5.13,3H9a1,1,0,0,1,1,.89,10.74,10.74,0,0,0,1,3.78,1,1,0,0,1-.42,1.26l-.86.49a1,1,0,0,0-.33,1.46,14.08,14.08,0,0,0,3.69,3.69,1,1,0,0,0,1.46-.33l.49-.86A1,1,0,0,1,16.33,13a10.74,10.74,0,0,0,3.78,1A1,1,0,0,1,21,15Z" style="fill:#231f20"></path><path d="M21,10a1,1,0,0,1-1-1,5,5,0,0,0-5-5,1,1,0,0,1,0-2,7,7,0,0,1,7,7A1,1,0,0,1,21,10Z" style="fill:#231f20"></path><path d="M17,10a1,1,0,0,1-1-1,1,1,0,0,0-1-1,1,1,0,0,1,0-2,3,3,0,0,1,3,3A1,1,0,0,1,17,10Z" style="fill:#231f20"></path></svg>
|
||||||
|
After Width: | Height: | Size: 795 B |
2
frontend/src/assets/comms/note.svg
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" id="create-note" class="icon glyph"><path d="M20.71,3.29a2.91,2.91,0,0,0-2.2-.84,3.25,3.25,0,0,0-2.17,1L9.46,10.29s0,0,0,0a.62.62,0,0,0-.11.17,1,1,0,0,0-.1.18l0,0L8,14.72A1,1,0,0,0,9,16a.9.9,0,0,0,.28,0l4-1.17,0,0,.18-.1a.62.62,0,0,0,.17-.11l0,0,6.87-6.88a3.25,3.25,0,0,0,1-2.17A2.91,2.91,0,0,0,20.71,3.29Z"></path><path d="M20,22H4a2,2,0,0,1-2-2V4A2,2,0,0,1,4,2h8a1,1,0,0,1,0,2H4V20H20V12a1,1,0,0,1,2,0v8A2,2,0,0,1,20,22Z" style="fill:#231f20"></path></svg>
|
||||||
|
After Width: | Height: | Size: 666 B |
4
frontend/src/assets/comms/outbound.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14 2H5.50003L4.00003 3.5L6.83581 6.33579L0.585815 12.5858L3.41424 15.4142L9.66424 9.16421L12.5 12L14 10.5L14 2Z" fill="#000000"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 367 B |
2
frontend/src/assets/comms/sms.svg
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M256 32C114.6 32 0 125.1 0 240c0 49.6 21.4 95 57 130.7C44.5 421.1 2.7 466 2.2 466.5c-2.2 2.3-2.8 5.7-1.5 8.7 1.3 3 4.1 4.8 7.3 4.8 66.3 0 116-31.8 140.6-51.4 32.7 12.3 69 19.4 107.4 19.4 141.4 0 256-93.1 256-208S397.4 32 256 32zM128.2 304H116c-4.4 0-8-3.6-8-8v-16c0-4.4 3.6-8 8-8h12.3c6 0 10.4-3.5 10.4-6.6 0-1.3-.8-2.7-2.1-3.8l-21.9-18.8c-8.5-7.2-13.3-17.5-13.3-28.1 0-21.3 19-38.6 42.4-38.6H156c4.4 0 8 3.6 8 8v16c0 4.4-3.6 8-8 8h-12.3c-6 0-10.4 3.5-10.4 6.6 0 1.3.8 2.7 2.1 3.8l21.9 18.8c8.5 7.2 13.3 17.5 13.3 28.1.1 21.3-19 38.6-42.4 38.6zm191.8-8c0 4.4-3.6 8-8 8h-16c-4.4 0-8-3.6-8-8v-68.2l-24.8 55.8c-2.9 5.9-11.4 5.9-14.3 0L224 227.8V296c0 4.4-3.6 8-8 8h-16c-4.4 0-8-3.6-8-8V192c0-8.8 7.2-16 16-16h16c6.1 0 11.6 3.4 14.3 8.8l17.7 35.4 17.7-35.4c2.7-5.4 8.3-8.8 14.3-8.8h16c8.8 0 16 7.2 16 16v104zm48.3 8H356c-4.4 0-8-3.6-8-8v-16c0-4.4 3.6-8 8-8h12.3c6 0 10.4-3.5 10.4-6.6 0-1.3-.8-2.7-2.1-3.8l-21.9-18.8c-8.5-7.2-13.3-17.5-13.3-28.1 0-21.3 19-38.6 42.4-38.6H396c4.4 0 8 3.6 8 8v16c0 4.4-3.6 8-8 8h-12.3c-6 0-10.4 3.5-10.4 6.6 0 1.3.8 2.7 2.1 3.8l21.9 18.8c8.5 7.2 13.3 17.5 13.3 28.1.1 21.3-18.9 38.6-42.3 38.6z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
12
frontend/src/assets/comms/whatsapp.svg
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Icons" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
viewBox="0 0 32 32" xml:space="preserve">
|
||||||
|
<path d="M17,0C8.7,0,2,6.7,2,15c0,3.4,1.1,6.6,3.2,9.2l-2.1,6.4c-0.1,0.4,0,0.8,0.3,1.1C3.5,31.9,3.8,32,4,32c0.1,0,0.3,0,0.4-0.1
|
||||||
|
l6.9-3.1C13.1,29.6,15,30,17,30c8.3,0,15-6.7,15-15S25.3,0,17,0z M25.7,20.5c-0.4,1.2-1.9,2.2-3.2,2.4C22.2,23,21.9,23,21.5,23
|
||||||
|
c-0.8,0-2-0.2-4.1-1.1c-2.4-1-4.8-3.1-6.7-5.8L10.7,16C10.1,15.1,9,13.4,9,11.6c0-2.2,1.1-3.3,1.5-3.8c0.5-0.5,1.2-0.8,2-0.8
|
||||||
|
c0.2,0,0.3,0,0.5,0c0.7,0,1.2,0.2,1.7,1.2l0.4,0.8c0.3,0.8,0.7,1.7,0.8,1.8c0.3,0.6,0.3,1.1,0,1.6c-0.1,0.3-0.3,0.5-0.5,0.7
|
||||||
|
c-0.1,0.2-0.2,0.3-0.3,0.3c-0.1,0.1-0.1,0.1-0.2,0.2c0.3,0.5,0.9,1.4,1.7,2.1c1.2,1.1,2.1,1.4,2.6,1.6l0,0c0.2-0.2,0.4-0.6,0.7-0.9
|
||||||
|
l0.1-0.2c0.5-0.7,1.3-0.9,2.1-0.6c0.4,0.2,2.6,1.2,2.6,1.2l0.2,0.1c0.3,0.2,0.7,0.3,0.9,0.7C26.2,18.5,25.9,19.8,25.7,20.5z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -71,6 +71,25 @@ export function AuthProvider({ children }) {
|
|||||||
return roles.includes(user.role);
|
return roles.includes(user.role);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* hasPermission(section, action)
|
||||||
|
*
|
||||||
|
* Sections and their action keys:
|
||||||
|
* melodies: view, add, delete, safe_edit, full_edit, archetype_access, settings_access, compose_access
|
||||||
|
* devices: view, add, delete, safe_edit, edit_bells, edit_clock, edit_warranty, full_edit, control
|
||||||
|
* app_users: view, add, delete, safe_edit, full_edit
|
||||||
|
* issues_notes: view, add, delete, edit
|
||||||
|
* mail: view, compose, reply
|
||||||
|
* crm: activity_log
|
||||||
|
* crm_customers: full_access, overview, orders_view, orders_edit, quotations_view, quotations_edit,
|
||||||
|
* comms_view, comms_log, comms_edit, comms_compose, add, delete,
|
||||||
|
* files_view, files_edit, devices_view, devices_edit
|
||||||
|
* crm_orders: view (→ crm_customers.orders_view), edit (→ crm_customers.orders_edit) [derived]
|
||||||
|
* crm_products: view, add, edit
|
||||||
|
* mfg: view_inventory, edit, provision, firmware_view, firmware_edit
|
||||||
|
* api_reference: access
|
||||||
|
* mqtt: access
|
||||||
|
*/
|
||||||
const hasPermission = (section, action) => {
|
const hasPermission = (section, action) => {
|
||||||
if (!user) return false;
|
if (!user) return false;
|
||||||
// sysadmin and admin have full access
|
// sysadmin and admin have full access
|
||||||
@@ -79,13 +98,22 @@ export function AuthProvider({ children }) {
|
|||||||
const perms = user.permissions;
|
const perms = user.permissions;
|
||||||
if (!perms) return false;
|
if (!perms) return false;
|
||||||
|
|
||||||
// MQTT is a global flag
|
// crm_orders is derived from crm_customers
|
||||||
if (section === "mqtt") {
|
if (section === "crm_orders") {
|
||||||
return !!perms.mqtt;
|
const cc = perms.crm_customers;
|
||||||
|
if (!cc) return false;
|
||||||
|
if (cc.full_access) return true;
|
||||||
|
if (action === "view") return !!cc.orders_view;
|
||||||
|
if (action === "edit") return !!cc.orders_edit;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sectionPerms = perms[section];
|
const sectionPerms = perms[section];
|
||||||
if (!sectionPerms) return false;
|
if (!sectionPerms) return false;
|
||||||
|
|
||||||
|
// crm_customers.full_access grants everything in that section
|
||||||
|
if (section === "crm_customers" && sectionPerms.full_access) return true;
|
||||||
|
|
||||||
return !!sectionPerms[action];
|
return !!sectionPerms[action];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
141
frontend/src/crm/components/CommIcons.jsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import emailIconRaw from "../../assets/comms/email.svg?raw";
|
||||||
|
import inpersonIconRaw from "../../assets/comms/inperson.svg?raw";
|
||||||
|
import noteIconRaw from "../../assets/comms/note.svg?raw";
|
||||||
|
import smsIconRaw from "../../assets/comms/sms.svg?raw";
|
||||||
|
import whatsappIconRaw from "../../assets/comms/whatsapp.svg?raw";
|
||||||
|
import callIconRaw from "../../assets/comms/call.svg?raw";
|
||||||
|
import inboundIconRaw from "../../assets/comms/inbound.svg?raw";
|
||||||
|
import outboundIconRaw from "../../assets/comms/outbound.svg?raw";
|
||||||
|
import internalIconRaw from "../../assets/comms/internal.svg?raw";
|
||||||
|
|
||||||
|
const TYPE_TONES = {
|
||||||
|
email: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
|
||||||
|
whatsapp: { bg: "#dcfce7", color: "#166534" },
|
||||||
|
call: { bg: "#fef9c3", color: "#854d0e" },
|
||||||
|
sms: { bg: "#fef3c7", color: "#92400e" },
|
||||||
|
note: { bg: "var(--bg-card-hover)", color: "var(--text-secondary)" },
|
||||||
|
in_person: { bg: "#ede9fe", color: "#5b21b6" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIR_TONES = {
|
||||||
|
inbound: { bg: "#2c1a1a", color: "#ef4444", title: "Inbound" },
|
||||||
|
outbound: { bg: "#13261a", color: "#16a34a", title: "Outbound" },
|
||||||
|
internal: { bg: "#102335", color: "#4dabf7", title: "Internal" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function IconWrap({ title, bg, color, size = 22, children }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title={title}
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: "999px",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: bg,
|
||||||
|
color,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InlineRawSvg({ raw, size = 12, forceRootFill = true }) {
|
||||||
|
if (!raw) return null;
|
||||||
|
let normalized = raw
|
||||||
|
.replace(/<\?xml[\s\S]*?\?>/gi, "")
|
||||||
|
.replace(/<!DOCTYPE[\s\S]*?>/gi, "")
|
||||||
|
.replace(/#000000/gi, "currentColor")
|
||||||
|
.replace(/#000\b/gi, "currentColor")
|
||||||
|
.replace(/\sfill="(?!none|currentColor|url\()[^"]*"/gi, ' fill="currentColor"')
|
||||||
|
.replace(/\sstroke="(?!none|currentColor|url\()[^"]*"/gi, ' stroke="currentColor"')
|
||||||
|
.replace(/fill\s*:\s*(?!none|currentColor|url\()[^;"]+/gi, "fill:currentColor")
|
||||||
|
.replace(/stroke\s*:\s*(?!none|currentColor|url\()[^;"]+/gi, "stroke:currentColor");
|
||||||
|
normalized = forceRootFill
|
||||||
|
? normalized.replace(/<svg\b/i, '<svg width="100%" height="100%" fill="currentColor"')
|
||||||
|
: normalized.replace(/<svg\b/i, '<svg width="100%" height="100%"');
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
display: "inline-flex",
|
||||||
|
}}
|
||||||
|
dangerouslySetInnerHTML={{ __html: normalized }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_ICON_SRC = {
|
||||||
|
email: emailIconRaw,
|
||||||
|
whatsapp: whatsappIconRaw,
|
||||||
|
call: callIconRaw,
|
||||||
|
sms: smsIconRaw,
|
||||||
|
note: noteIconRaw,
|
||||||
|
in_person: inpersonIconRaw,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIRECTION_ICON_SRC = {
|
||||||
|
inbound: inboundIconRaw,
|
||||||
|
outbound: outboundIconRaw,
|
||||||
|
internal: internalIconRaw,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_ICON_COLORS = {
|
||||||
|
note: "#ffffff",
|
||||||
|
whatsapp: "#06bd00",
|
||||||
|
call: "#2c2c2c",
|
||||||
|
sms: "#002981",
|
||||||
|
};
|
||||||
|
|
||||||
|
function TypeSvg({ type }) {
|
||||||
|
const src = TYPE_ICON_SRC[type];
|
||||||
|
if (src) {
|
||||||
|
return <InlineRawSvg raw={src} />;
|
||||||
|
}
|
||||||
|
const props = { width: 12, height: 12, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.9, strokeLinecap: "round", strokeLinejoin: "round" };
|
||||||
|
// Fallback for missing custom icon files (e.g. call.svg).
|
||||||
|
if (type === "call") {
|
||||||
|
return <svg {...props}><path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.4 19.4 0 0 1-6-6 19.8 19.8 0 0 1-3.1-8.7A2 2 0 0 1 4 2h3a2 2 0 0 1 2 1.7c.1.8.4 1.6.7 2.3a2 2 0 0 1-.5 2.1L8 9.3a16 16 0 0 0 6.7 6.7l1.2-1.2a2 2 0 0 1 2.1-.5c.7.3 1.5.6 2.3.7A2 2 0 0 1 22 16.9Z"/></svg>;
|
||||||
|
}
|
||||||
|
return <svg {...props}><path d="M6 4h9l3 3v13H6z"/><path d="M15 4v4h4"/><path d="M9 13h6"/></svg>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DirSvg({ direction }) {
|
||||||
|
const src = DIRECTION_ICON_SRC[direction];
|
||||||
|
if (src) {
|
||||||
|
return <InlineRawSvg raw={src} forceRootFill={false} />;
|
||||||
|
}
|
||||||
|
const props = { width: 12, height: 12, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" };
|
||||||
|
if (direction === "inbound") return <svg {...props}><path d="M20 4 9 15"/><path d="M9 6v9h9"/></svg>;
|
||||||
|
if (direction === "outbound") return <svg {...props}><path d="m4 20 11-11"/><path d="M15 18V9H6"/></svg>;
|
||||||
|
return <svg {...props}><path d="M7 7h10"/><path d="m13 3 4 4-4 4"/><path d="M17 17H7"/><path d="m11 13-4 4 4 4"/></svg>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCommTypeTone(type) {
|
||||||
|
return TYPE_TONES[type] || TYPE_TONES.note;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommTypeIconBadge({ type, size = 22 }) {
|
||||||
|
const tone = getCommTypeTone(type);
|
||||||
|
const iconColor = TYPE_ICON_COLORS[type] || tone.color;
|
||||||
|
return (
|
||||||
|
<IconWrap title={type} bg={tone.bg} color={iconColor} size={size}>
|
||||||
|
<TypeSvg type={type} />
|
||||||
|
</IconWrap>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommDirectionIcon({ direction, size = 22 }) {
|
||||||
|
const tone = DIR_TONES[direction] || DIR_TONES.internal;
|
||||||
|
return (
|
||||||
|
<span title={tone.title} style={{ color: tone.color, display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
||||||
|
<DirSvg direction={direction} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
928
frontend/src/crm/components/ComposeEmailModal.jsx
Normal file
@@ -0,0 +1,928 @@
|
|||||||
|
/**
|
||||||
|
* ComposeEmailModal
|
||||||
|
* A full-featured email compose modal using Quill.js (loaded from CDN).
|
||||||
|
* Features: To / CC / Subject, WYSIWYG rich body, Ctrl+V image paste, file attachments.
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* open – boolean
|
||||||
|
* onClose – () => void
|
||||||
|
* defaultTo – string (pre-fill To field)
|
||||||
|
* defaultSubject – string
|
||||||
|
* customerId – string | null (linked customer, optional)
|
||||||
|
* onSent – (entry) => void (called after successful send)
|
||||||
|
*/
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import api from "../../api/client";
|
||||||
|
|
||||||
|
// ── Quill loader ──────────────────────────────────────────────────────────────
|
||||||
|
let _quillReady = false;
|
||||||
|
let _quillCallbacks = [];
|
||||||
|
|
||||||
|
function loadQuill(cb) {
|
||||||
|
if (_quillReady) { cb(); return; }
|
||||||
|
_quillCallbacks.push(cb);
|
||||||
|
if (document.getElementById("__quill_css__")) return; // already loading
|
||||||
|
|
||||||
|
// CSS
|
||||||
|
const link = document.createElement("link");
|
||||||
|
link.id = "__quill_css__";
|
||||||
|
link.rel = "stylesheet";
|
||||||
|
link.href = "https://cdn.quilljs.com/1.3.7/quill.snow.css";
|
||||||
|
document.head.appendChild(link);
|
||||||
|
|
||||||
|
// JS
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.id = "__quill_js__";
|
||||||
|
script.src = "https://cdn.quilljs.com/1.3.7/quill.min.js";
|
||||||
|
script.onload = () => {
|
||||||
|
_quillReady = true;
|
||||||
|
_quillCallbacks.forEach((fn) => fn());
|
||||||
|
_quillCallbacks = [];
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Attachment item ───────────────────────────────────────────────────────────
|
||||||
|
function AttachmentPill({ name, size, onRemove }) {
|
||||||
|
const kb = size ? ` (${Math.ceil(size / 1024)} KB)` : "";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 px-2 py-1 rounded-full text-xs"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-secondary)" }}
|
||||||
|
>
|
||||||
|
<span className="truncate" style={{ maxWidth: 160 }}>{name}{kb}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="flex-shrink-0 cursor-pointer hover:opacity-70"
|
||||||
|
style={{ color: "var(--danger)", background: "none", border: "none", padding: 0, lineHeight: 1 }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ────────────────────────────────────────────────────────────
|
||||||
|
export default function ComposeEmailModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
defaultTo = "",
|
||||||
|
defaultSubject = "",
|
||||||
|
defaultFromAccount = "",
|
||||||
|
requireFromAccount = true,
|
||||||
|
defaultServerAttachments = [],
|
||||||
|
customerId = null,
|
||||||
|
onSent,
|
||||||
|
}) {
|
||||||
|
const [to, setTo] = useState(defaultTo);
|
||||||
|
const [cc, setCc] = useState("");
|
||||||
|
const [subject, setSubject] = useState(defaultSubject);
|
||||||
|
const [fromAccount, setFromAccount] = useState(defaultFromAccount || "");
|
||||||
|
const [mailAccounts, setMailAccounts] = useState([]);
|
||||||
|
const [attachments, setAttachments] = useState([]); // { file: File, name: string, size?: number }[]
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [quillLoaded, setQuillLoaded] = useState(_quillReady);
|
||||||
|
const [showServerFiles, setShowServerFiles] = useState(false);
|
||||||
|
const [serverFiles, setServerFiles] = useState([]);
|
||||||
|
const [serverFilesLoading, setServerFilesLoading] = useState(false);
|
||||||
|
const [serverFileSearch, setServerFileSearch] = useState("");
|
||||||
|
const [serverFileType, setServerFileType] = useState("all");
|
||||||
|
const [previewFile, setPreviewFile] = useState(null); // { path, filename, mime_type }
|
||||||
|
const [editorPreviewDark, setEditorPreviewDark] = useState(true);
|
||||||
|
|
||||||
|
const editorRef = useRef(null);
|
||||||
|
const quillRef = useRef(null);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
// Reset fields when opened
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setTo(defaultTo);
|
||||||
|
setCc("");
|
||||||
|
setSubject(defaultSubject);
|
||||||
|
setFromAccount(defaultFromAccount || "");
|
||||||
|
setAttachments([]);
|
||||||
|
setError("");
|
||||||
|
setEditorPreviewDark(true);
|
||||||
|
}
|
||||||
|
}, [open, defaultTo, defaultSubject, defaultFromAccount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let cancelled = false;
|
||||||
|
api.get("/crm/comms/email/accounts")
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const accounts = data.accounts || [];
|
||||||
|
setMailAccounts(accounts);
|
||||||
|
if (!defaultFromAccount && accounts.length === 1) {
|
||||||
|
setFromAccount(accounts[0].key);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setMailAccounts([]);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [open, defaultFromAccount]);
|
||||||
|
|
||||||
|
// Load Quill
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
loadQuill(() => setQuillLoaded(true));
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// ESC to close
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e) => { if (e.key === "Escape") onClose(); };
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
// Init Quill editor
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !quillLoaded || !editorRef.current) return;
|
||||||
|
if (quillRef.current) return; // already initialized
|
||||||
|
|
||||||
|
const quill = new window.Quill(editorRef.current, {
|
||||||
|
theme: "snow",
|
||||||
|
placeholder: "Write your message...",
|
||||||
|
modules: {
|
||||||
|
toolbar: [
|
||||||
|
[{ size: ["small", false, "large", "huge"] }],
|
||||||
|
["bold", "italic", "underline", "strike"],
|
||||||
|
[{ color: [] }, { background: [] }],
|
||||||
|
[{ align: [] }],
|
||||||
|
[{ list: "ordered" }, { list: "bullet" }, { indent: "-1" }, { indent: "+1" }],
|
||||||
|
["code-block", "blockquote", "link", "image"],
|
||||||
|
["clean"],
|
||||||
|
],
|
||||||
|
clipboard: { matchVisual: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
quillRef.current = quill;
|
||||||
|
|
||||||
|
// Force single-row toolbar via JS (defeats Quill's float-based layout)
|
||||||
|
const container = editorRef.current;
|
||||||
|
const toolbar = container.querySelector(".ql-toolbar");
|
||||||
|
const qlContainer = container.querySelector(".ql-container");
|
||||||
|
if (toolbar && qlContainer) {
|
||||||
|
// Make editorRef a flex column
|
||||||
|
container.style.cssText += ";display:flex!important;flex-direction:column!important;";
|
||||||
|
// Toolbar: single flex row
|
||||||
|
toolbar.style.cssText += ";display:flex!important;flex-wrap:nowrap!important;align-items:center!important;flex-shrink:0!important;overflow:visible!important;padding:3px 8px!important;";
|
||||||
|
// Kill floats on every .ql-formats, button, .ql-picker
|
||||||
|
toolbar.querySelectorAll(".ql-formats").forEach(el => {
|
||||||
|
el.style.cssText += ";float:none!important;display:inline-flex!important;flex-wrap:nowrap!important;align-items:center!important;flex-shrink:0!important;";
|
||||||
|
});
|
||||||
|
toolbar.querySelectorAll("button").forEach(el => {
|
||||||
|
el.style.cssText += ";float:none!important;display:inline-flex!important;align-items:center!important;justify-content:center!important;width:24px!important;height:24px!important;";
|
||||||
|
});
|
||||||
|
toolbar.querySelectorAll(".ql-picker").forEach(el => {
|
||||||
|
el.style.cssText += ";float:none!important;display:inline-flex!important;align-items:center!important;flex-shrink:0!important;height:24px!important;overflow:visible!important;";
|
||||||
|
});
|
||||||
|
// Editor container fills remaining space
|
||||||
|
qlContainer.style.cssText += ";flex:1!important;min-height:0!important;overflow:hidden!important;";
|
||||||
|
qlContainer.querySelector(".ql-editor").style.cssText += ";height:100%!important;overflow-y:auto!important;";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep color picker icons in sync with currently selected text/highlight colors.
|
||||||
|
const syncPickerIndicators = () => {
|
||||||
|
if (!toolbar) return;
|
||||||
|
const formats = quill.getFormat();
|
||||||
|
const textColor = typeof formats.color === "string" ? formats.color : "";
|
||||||
|
const highlightColor = typeof formats.background === "string" ? formats.background : "";
|
||||||
|
toolbar.style.setProperty("--ql-current-color", textColor || "var(--text-secondary)");
|
||||||
|
toolbar.style.setProperty("--ql-current-bg", highlightColor || "var(--bg-input)");
|
||||||
|
toolbar.classList.toggle("ql-has-bg-color", Boolean(highlightColor));
|
||||||
|
|
||||||
|
const colorLabel =
|
||||||
|
toolbar.querySelector(".ql-picker.ql-color-picker.ql-color .ql-picker-label") ||
|
||||||
|
toolbar.querySelector(".ql-picker.ql-color .ql-picker-label");
|
||||||
|
const bgLabel =
|
||||||
|
toolbar.querySelector(".ql-picker.ql-color-picker.ql-background .ql-picker-label") ||
|
||||||
|
toolbar.querySelector(".ql-picker.ql-background .ql-picker-label");
|
||||||
|
|
||||||
|
if (colorLabel) {
|
||||||
|
colorLabel.style.boxShadow = `inset 0 -3px 0 ${textColor || "var(--text-secondary)"}`;
|
||||||
|
colorLabel.querySelectorAll(".ql-stroke, .ql-stroke-miter").forEach((el) => {
|
||||||
|
el.style.setProperty("stroke", textColor || "var(--text-secondary)", "important");
|
||||||
|
});
|
||||||
|
colorLabel.querySelectorAll(".ql-fill, .ql-color-label").forEach((el) => {
|
||||||
|
el.style.setProperty("fill", textColor || "var(--text-secondary)", "important");
|
||||||
|
});
|
||||||
|
|
||||||
|
let swatch = colorLabel.querySelector(".compose-picker-swatch");
|
||||||
|
if (!swatch) {
|
||||||
|
swatch = document.createElement("span");
|
||||||
|
swatch.className = "compose-picker-swatch";
|
||||||
|
swatch.style.cssText = "position:absolute;right:2px;top:2px;width:7px;height:7px;border-radius:999px;border:1px solid rgba(255,255,255,0.35);pointer-events:none;";
|
||||||
|
colorLabel.appendChild(swatch);
|
||||||
|
}
|
||||||
|
swatch.style.background = textColor || "var(--text-secondary)";
|
||||||
|
}
|
||||||
|
if (bgLabel) {
|
||||||
|
bgLabel.style.boxShadow = highlightColor
|
||||||
|
? `inset 0 -7px 0 ${highlightColor}`
|
||||||
|
: "inset 0 -7px 0 transparent";
|
||||||
|
bgLabel.style.borderBottom = "1px solid var(--border-secondary)";
|
||||||
|
bgLabel.querySelectorAll(".ql-fill, .ql-color-label").forEach((el) => {
|
||||||
|
el.style.setProperty("fill", "var(--text-secondary)", "important");
|
||||||
|
});
|
||||||
|
|
||||||
|
let swatch = bgLabel.querySelector(".compose-picker-swatch");
|
||||||
|
if (!swatch) {
|
||||||
|
swatch = document.createElement("span");
|
||||||
|
swatch.className = "compose-picker-swatch";
|
||||||
|
swatch.style.cssText = "position:absolute;right:2px;top:2px;width:7px;height:7px;border-radius:999px;border:1px solid rgba(255,255,255,0.35);pointer-events:none;";
|
||||||
|
bgLabel.appendChild(swatch);
|
||||||
|
}
|
||||||
|
swatch.style.background = highlightColor || "transparent";
|
||||||
|
swatch.style.borderColor = highlightColor ? "rgba(255,255,255,0.35)" : "var(--border-secondary)";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
quill.on("selection-change", syncPickerIndicators);
|
||||||
|
quill.on("text-change", syncPickerIndicators);
|
||||||
|
quill.on("editor-change", syncPickerIndicators);
|
||||||
|
syncPickerIndicators();
|
||||||
|
|
||||||
|
// Handle Ctrl+V image paste
|
||||||
|
quill.root.addEventListener("paste", (e) => {
|
||||||
|
const items = (e.clipboardData || e.originalEvent?.clipboardData)?.items;
|
||||||
|
if (!items) return;
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type.startsWith("image/")) {
|
||||||
|
e.preventDefault();
|
||||||
|
const blob = item.getAsFile();
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
const range = quill.getSelection(true);
|
||||||
|
quill.insertEmbed(range.index, "image", ev.target.result);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
quill.off("selection-change", syncPickerIndicators);
|
||||||
|
quill.off("text-change", syncPickerIndicators);
|
||||||
|
quill.off("editor-change", syncPickerIndicators);
|
||||||
|
quillRef.current = null;
|
||||||
|
};
|
||||||
|
}, [open, quillLoaded]);
|
||||||
|
|
||||||
|
const getContent = useCallback(() => {
|
||||||
|
const q = quillRef.current;
|
||||||
|
if (!q) return { html: "", text: "" };
|
||||||
|
const html = q.root.innerHTML;
|
||||||
|
const text = q.getText().trim();
|
||||||
|
return { html, text };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileAdd = (files) => {
|
||||||
|
const newFiles = Array.from(files).map((f) => ({ file: f, name: f.name }));
|
||||||
|
setAttachments((prev) => [...prev, ...newFiles]);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !defaultServerAttachments?.length) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
for (const f of defaultServerAttachments) {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
const resp = await fetch(`/api/crm/nextcloud/file?path=${encodeURIComponent(f.path)}&token=${encodeURIComponent(token)}`);
|
||||||
|
if (!resp.ok) continue;
|
||||||
|
const blob = await resp.blob();
|
||||||
|
if (cancelled) return;
|
||||||
|
const file = new File([blob], f.filename, { type: f.mime_type || "application/octet-stream" });
|
||||||
|
setAttachments((prev) => {
|
||||||
|
if (prev.some((a) => a.name === f.filename)) return prev;
|
||||||
|
return [...prev, { file, name: f.filename }];
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore pre-attachment failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [open, defaultServerAttachments]);
|
||||||
|
|
||||||
|
// Open server file picker and load files for this customer
|
||||||
|
const openServerFiles = async () => {
|
||||||
|
setShowServerFiles(true);
|
||||||
|
setServerFileSearch("");
|
||||||
|
setServerFileType("all");
|
||||||
|
if (serverFiles.length > 0) return; // already loaded
|
||||||
|
setServerFilesLoading(true);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
const resp = await fetch(`/api/crm/nextcloud/browse-all?customer_id=${customerId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error("Failed to load files");
|
||||||
|
const data = await resp.json();
|
||||||
|
setServerFiles((data.items || []).filter((f) => !f.is_dir));
|
||||||
|
} catch {
|
||||||
|
setServerFiles([]);
|
||||||
|
} finally {
|
||||||
|
setServerFilesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Attach a server file by downloading it as a Blob and adding to attachments
|
||||||
|
const attachServerFile = async (f) => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
const resp = await fetch(`/api/crm/nextcloud/file?path=${encodeURIComponent(f.path)}&token=${encodeURIComponent(token)}`);
|
||||||
|
if (!resp.ok) throw new Error("Download failed");
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const file = new File([blob], f.filename, { type: f.mime_type || "application/octet-stream" });
|
||||||
|
setAttachments((prev) => [...prev, { file, name: f.filename }]);
|
||||||
|
} catch (err) {
|
||||||
|
setError(`Could not attach ${f.filename}: ${err.message}`);
|
||||||
|
}
|
||||||
|
setShowServerFiles(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine file type category for filter
|
||||||
|
function getFileCategory(f) {
|
||||||
|
const mime = f.mime_type || "";
|
||||||
|
const sub = f.subfolder || "";
|
||||||
|
if (sub === "quotations") return "quotation";
|
||||||
|
if (mime === "application/pdf" || sub.includes("invoic") || sub.includes("document")) return "document";
|
||||||
|
if (mime.startsWith("image/") || mime.startsWith("video/") || mime.startsWith("audio/") || sub.includes("media")) return "media";
|
||||||
|
return "document";
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
const { html, text } = getContent();
|
||||||
|
const toClean = to.trim();
|
||||||
|
const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(toClean);
|
||||||
|
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 (!subject.trim()) { setError("Please enter a subject."); return; }
|
||||||
|
if (!text && !html.replace(/<[^>]*>/g, "").trim()) { setError("Please write a message."); 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("subject", subject.trim());
|
||||||
|
fd.append("body", text);
|
||||||
|
fd.append("body_html", html);
|
||||||
|
fd.append("cc", JSON.stringify(ccList));
|
||||||
|
for (const { file } of attachments) {
|
||||||
|
fd.append("files", file, file.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await fetch("/api/crm/comms/email/send", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || `Server error ${resp.status}`);
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
if (onSent) onSent(data.entry || data);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || "Failed to send email.");
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
backgroundColor: "var(--bg-input)",
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
border: "1px solid",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "7px 10px",
|
||||||
|
fontSize: 13,
|
||||||
|
width: "100%",
|
||||||
|
outline: "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 1000,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.55)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
padding: 80,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-primary)",
|
||||||
|
borderRadius: 12,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
boxShadow: "0 20px 60px rgba(0,0,0,0.4)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-5 py-4"
|
||||||
|
style={{ borderBottom: "1px solid var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<h2 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>
|
||||||
|
New Email
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="cursor-pointer hover:opacity-70"
|
||||||
|
style={{ color: "var(--text-muted)", background: "none", border: "none", fontSize: 20, lineHeight: 1 }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fields */}
|
||||||
|
<div className="px-5 py-4" style={{ borderBottom: "1px solid var(--border-secondary)" }}>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "200px 1fr 1fr", gap: 10, marginBottom: 10 }}>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>Send As</label>
|
||||||
|
<select
|
||||||
|
className="compose-email-input"
|
||||||
|
style={inputStyle}
|
||||||
|
value={fromAccount}
|
||||||
|
onChange={(e) => setFromAccount(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select sender...</option>
|
||||||
|
{mailAccounts.filter((a) => a.allow_send).map((a) => (
|
||||||
|
<option key={a.key} value={a.key}>{a.label} ({a.email})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>To</label>
|
||||||
|
<input
|
||||||
|
className="compose-email-input"
|
||||||
|
style={inputStyle}
|
||||||
|
value={to}
|
||||||
|
onChange={(e) => setTo(e.target.value)}
|
||||||
|
placeholder="recipient@example.com"
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>CC</label>
|
||||||
|
<input
|
||||||
|
className="compose-email-input"
|
||||||
|
style={inputStyle}
|
||||||
|
value={cc}
|
||||||
|
onChange={(e) => setCc(e.target.value)}
|
||||||
|
placeholder="cc1@example.com, cc2@..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>Subject</label>
|
||||||
|
<input
|
||||||
|
className="compose-email-input"
|
||||||
|
style={inputStyle}
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
placeholder="Email subject"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quill Editor — Quill injects toolbar+editor into editorRef */}
|
||||||
|
<div className="quill-compose-wrapper" style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0, position: "relative" }}>
|
||||||
|
<div style={{ position: "absolute", top: 8, right: 12, zIndex: 20 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditorPreviewDark((v) => !v)}
|
||||||
|
title={editorPreviewDark ? "Switch to light preview" : "Switch to dark preview"}
|
||||||
|
style={{
|
||||||
|
padding: "4px 10px", fontSize: 11, borderRadius: 6, cursor: "pointer",
|
||||||
|
border: "1px solid rgba(128,128,128,0.4)",
|
||||||
|
backgroundColor: editorPreviewDark ? "rgba(0,0,0,0.55)" : "rgba(255,255,255,0.7)",
|
||||||
|
color: editorPreviewDark ? "#e0e0e0" : "#333",
|
||||||
|
backdropFilter: "blur(4px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{editorPreviewDark ? "☀ Light" : "🌙 Dark"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{quillLoaded ? (
|
||||||
|
<div ref={editorRef} style={{ flex: 1, minHeight: 0 }} />
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center py-12" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Loading editor...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attachments */}
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div className="px-5 py-3 flex flex-wrap gap-2" style={{ borderTop: "1px solid var(--border-secondary)" }}>
|
||||||
|
{attachments.map((a, i) => (
|
||||||
|
<AttachmentPill
|
||||||
|
key={i}
|
||||||
|
name={a.name}
|
||||||
|
size={a.file.size}
|
||||||
|
onRemove={() => setAttachments((prev) => prev.filter((_, j) => j !== i))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-5 py-4"
|
||||||
|
style={{ borderTop: "1px solid var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{customerId && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openServerFiles}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
|
||||||
|
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}
|
||||||
|
>
|
||||||
|
🗂 Attach from Server
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
|
||||||
|
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}
|
||||||
|
>
|
||||||
|
📎 Attach
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={(e) => handleFileAdd(e.target.files)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const sig = localStorage.getItem("mail_signature") || "";
|
||||||
|
if (!sig.trim() || sig === "<p><br></p>") return;
|
||||||
|
const q = quillRef.current;
|
||||||
|
if (!q) return;
|
||||||
|
const current = q.root.innerHTML;
|
||||||
|
q.clipboard.dangerouslyPasteHTML(current + '<p><br></p><hr/><div class="mail-sig">' + sig + "</div>");
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
|
||||||
|
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}
|
||||||
|
title="Append signature to message"
|
||||||
|
>
|
||||||
|
✍ Add Signature
|
||||||
|
</button>
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Tip: Paste images directly with Ctrl+V
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{error && (
|
||||||
|
<span className="text-xs" style={{ color: "var(--danger-text)" }}>{error}</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
|
||||||
|
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={sending}
|
||||||
|
className="px-4 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-90"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--btn-primary)",
|
||||||
|
color: "var(--text-white)",
|
||||||
|
opacity: sending ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sending ? "Sending..." : "Send"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server File Picker Modal */}
|
||||||
|
{showServerFiles && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 1100,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.6)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
}}
|
||||||
|
onClick={() => setShowServerFiles(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-primary)",
|
||||||
|
borderRadius: 12,
|
||||||
|
width: 540,
|
||||||
|
maxHeight: "70vh",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
boxShadow: "0 16px 48px rgba(0,0,0,0.5)",
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4" style={{ borderBottom: "1px solid var(--border-primary)", flexShrink: 0 }}>
|
||||||
|
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>Attach File from Server</h3>
|
||||||
|
<button type="button" onClick={() => setShowServerFiles(false)} style={{ color: "var(--text-muted)", background: "none", border: "none", fontSize: 20, cursor: "pointer", lineHeight: 1 }}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search + Type filter */}
|
||||||
|
<div className="px-4 py-3 flex gap-2" style={{ borderBottom: "1px solid var(--border-secondary)", flexShrink: 0 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by filename..."
|
||||||
|
value={serverFileSearch}
|
||||||
|
onChange={(e) => setServerFileSearch(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
className="flex-1 px-3 py-1.5 text-sm rounded border"
|
||||||
|
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)" }}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={serverFileType}
|
||||||
|
onChange={(e) => setServerFileType(e.target.value)}
|
||||||
|
className="px-3 py-1.5 text-sm rounded border"
|
||||||
|
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
<option value="all">All types</option>
|
||||||
|
<option value="document">Documents</option>
|
||||||
|
<option value="quotation">Quotations</option>
|
||||||
|
<option value="media">Media</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File list */}
|
||||||
|
<div style={{ overflowY: "auto", flex: 1 }}>
|
||||||
|
{serverFilesLoading && (
|
||||||
|
<div className="text-center py-8 text-sm" style={{ color: "var(--text-muted)" }}>Loading files...</div>
|
||||||
|
)}
|
||||||
|
{!serverFilesLoading && serverFiles.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-sm" style={{ color: "var(--text-muted)" }}>No files found for this customer.</div>
|
||||||
|
)}
|
||||||
|
{!serverFilesLoading && (() => {
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
const filtered = serverFiles.filter((f) => {
|
||||||
|
const matchSearch = !serverFileSearch.trim() || f.filename.toLowerCase().includes(serverFileSearch.toLowerCase());
|
||||||
|
const matchType = serverFileType === "all" || getFileCategory(f) === serverFileType;
|
||||||
|
return matchSearch && matchType && !f.is_dir;
|
||||||
|
});
|
||||||
|
if (filtered.length === 0 && serverFiles.length > 0) {
|
||||||
|
return <div className="text-center py-8 text-sm" style={{ color: "var(--text-muted)" }}>No files match your search.</div>;
|
||||||
|
}
|
||||||
|
return filtered.map((f) => {
|
||||||
|
const alreadyAttached = attachments.some((a) => a.name === f.filename);
|
||||||
|
const cat = getFileCategory(f);
|
||||||
|
const catColors = {
|
||||||
|
quotation: { bg: "#1a2d1e", color: "#6aab7a" },
|
||||||
|
document: { bg: "#1e1a2d", color: "#a78bfa" },
|
||||||
|
media: { bg: "#2d2a1a", color: "#c9a84c" },
|
||||||
|
};
|
||||||
|
const c = catColors[cat] || catColors.document;
|
||||||
|
const kb = f.size > 0 ? `${(f.size / 1024).toFixed(0)} KB` : "";
|
||||||
|
const isImage = (f.mime_type || "").startsWith("image/");
|
||||||
|
const thumbUrl = `/api/crm/nextcloud/file?path=${encodeURIComponent(f.path)}&token=${encodeURIComponent(token)}`;
|
||||||
|
|
||||||
|
// Thumbnail / icon
|
||||||
|
const thumb = isImage ? (
|
||||||
|
<img
|
||||||
|
src={thumbUrl}
|
||||||
|
alt=""
|
||||||
|
onClick={(e) => { e.stopPropagation(); setPreviewFile(f); }}
|
||||||
|
style={{ width: 40, height: 40, objectFit: "cover", borderRadius: 5, flexShrink: 0, cursor: "zoom-in", border: "1px solid var(--border-secondary)" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onClick={(e) => { e.stopPropagation(); setPreviewFile(f); }}
|
||||||
|
style={{ width: 40, height: 40, borderRadius: 5, flexShrink: 0, backgroundColor: c.bg, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 20, cursor: "zoom-in", border: "1px solid var(--border-secondary)" }}
|
||||||
|
>
|
||||||
|
{cat === "quotation" ? "🧾" : cat === "media" ? "🎵" : "📄"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={f.path}
|
||||||
|
onClick={() => !alreadyAttached && attachServerFile(f)}
|
||||||
|
style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 12,
|
||||||
|
padding: "8px 16px",
|
||||||
|
borderBottom: "1px solid var(--border-secondary)",
|
||||||
|
cursor: alreadyAttached ? "default" : "pointer",
|
||||||
|
opacity: alreadyAttached ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { if (!alreadyAttached) e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ""; }}
|
||||||
|
>
|
||||||
|
{thumb}
|
||||||
|
<span className="flex-1 text-sm truncate" style={{ color: "var(--text-primary)" }}>{f.filename}</span>
|
||||||
|
<span className="text-xs flex-shrink-0" style={{ color: "var(--text-muted)" }}>{kb}</span>
|
||||||
|
{alreadyAttached
|
||||||
|
? <span className="text-xs flex-shrink-0" style={{ color: "var(--accent)" }}>Attached</span>
|
||||||
|
: <span style={{ fontSize: 11, padding: "2px 7px", borderRadius: 10, backgroundColor: c.bg, color: c.color, fontWeight: 500, flexShrink: 0, whiteSpace: "nowrap" }}>{cat}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* File preview modal */}
|
||||||
|
{previewFile && (() => {
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
const fileUrl = `/api/crm/nextcloud/file?path=${encodeURIComponent(previewFile.path)}&token=${encodeURIComponent(token)}`;
|
||||||
|
const mime = previewFile.mime_type || "";
|
||||||
|
const isImage = mime.startsWith("image/");
|
||||||
|
const isPdf = mime === "application/pdf";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{ position: "fixed", inset: 0, zIndex: 1200, backgroundColor: "rgba(0,0,0,0.8)", display: "flex", alignItems: "center", justifyContent: "center", padding: 40 }}
|
||||||
|
onClick={() => setPreviewFile(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ position: "relative", maxWidth: "90vw", maxHeight: "85vh", display: "flex", flexDirection: "column", borderRadius: 10, overflow: "hidden", backgroundColor: "var(--bg-card)", boxShadow: "0 20px 60px rgba(0,0,0,0.6)" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Preview header */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 16px", borderBottom: "1px solid var(--border-primary)", flexShrink: 0 }}>
|
||||||
|
<span className="text-sm font-medium truncate" style={{ color: "var(--text-heading)", maxWidth: 400 }}>{previewFile.filename}</span>
|
||||||
|
<div style={{ display: "flex", gap: 8, marginLeft: 16, flexShrink: 0 }}>
|
||||||
|
<a href={fileUrl} download={previewFile.filename} style={{ fontSize: 12, color: "var(--accent)", textDecoration: "none" }}>Download</a>
|
||||||
|
<button onClick={() => setPreviewFile(null)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)", fontSize: 20, lineHeight: 1 }}>×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Preview body */}
|
||||||
|
<div style={{ flex: 1, minHeight: 0, overflow: "auto", display: "flex", alignItems: "center", justifyContent: "center", padding: 16, backgroundColor: "var(--bg-primary)" }}>
|
||||||
|
{isImage && <img src={fileUrl} alt={previewFile.filename} style={{ maxWidth: "80vw", maxHeight: "70vh", objectFit: "contain", borderRadius: 6 }} />}
|
||||||
|
{isPdf && <iframe src={fileUrl} title={previewFile.filename} style={{ width: "75vw", height: "70vh", border: "none", borderRadius: 6 }} />}
|
||||||
|
{!isImage && !isPdf && (
|
||||||
|
<div style={{ textAlign: "center", color: "var(--text-muted)" }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 12 }}>📄</div>
|
||||||
|
<div className="text-sm">{previewFile.filename}</div>
|
||||||
|
<a href={fileUrl} download={previewFile.filename} className="text-sm" style={{ color: "var(--accent)", marginTop: 8, display: "inline-block" }}>Download to view</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Quill snow theme overrides — layout handled via JS, only cosmetics here */}
|
||||||
|
<style>{`
|
||||||
|
.quill-compose-wrapper .ql-toolbar.ql-snow::after { display: none !important; }
|
||||||
|
.quill-compose-wrapper .ql-toolbar.ql-snow {
|
||||||
|
background: var(--bg-card-hover) !important;
|
||||||
|
border-top: none !important;
|
||||||
|
border-left: none !important;
|
||||||
|
border-right: none !important;
|
||||||
|
border-bottom: 1px solid var(--border-secondary) !important;
|
||||||
|
position: relative !important;
|
||||||
|
z-index: 3 !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
.quill-compose-wrapper .ql-toolbar .ql-formats {
|
||||||
|
gap: 1px !important;
|
||||||
|
margin: 0 10px 0 0 !important;
|
||||||
|
padding: 0 10px 0 0 !important;
|
||||||
|
border-right: 1px solid #4b5563 !important;
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
.quill-compose-wrapper .ql-toolbar .ql-formats:last-child {
|
||||||
|
border-right: none !important;
|
||||||
|
padding-right: 0 !important;
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
.quill-compose-wrapper .ql-toolbar .ql-picker,
|
||||||
|
.quill-compose-wrapper .ql-toolbar .ql-picker-label,
|
||||||
|
.quill-compose-wrapper .ql-toolbar .ql-picker-item {
|
||||||
|
color: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
|
.quill-compose-wrapper .ql-toolbar .ql-picker-label {
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-size .ql-picker-label::before {
|
||||||
|
color: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
|
.quill-compose-wrapper .ql-toolbar button:hover,
|
||||||
|
.quill-compose-wrapper .ql-toolbar button.ql-active { background: var(--bg-primary) !important; }
|
||||||
|
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-color .ql-picker-label .ql-stroke,
|
||||||
|
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-color .ql-picker-label .ql-fill {
|
||||||
|
stroke: var(--ql-current-color, var(--text-secondary)) !important;
|
||||||
|
fill: var(--ql-current-color, var(--text-secondary)) !important;
|
||||||
|
}
|
||||||
|
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-background .ql-picker-label .ql-fill {
|
||||||
|
fill: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
|
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-background .ql-picker-label .ql-stroke {
|
||||||
|
stroke: var(--border-secondary) !important;
|
||||||
|
}
|
||||||
|
/* Dropdowns: keep anchored to picker and above editor scroll area */
|
||||||
|
.quill-compose-wrapper .ql-toolbar .ql-picker {
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
.quill-compose-wrapper .ql-picker-options {
|
||||||
|
position: absolute !important;
|
||||||
|
z-index: 40 !important;
|
||||||
|
background: var(--bg-card) !important;
|
||||||
|
border: 1px solid var(--border-primary) !important;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.45) !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
max-height: 220px !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
}
|
||||||
|
.quill-compose-wrapper .ql-stroke { stroke: var(--text-secondary) !important; }
|
||||||
|
.quill-compose-wrapper .ql-toolbar button:hover .ql-stroke,
|
||||||
|
.quill-compose-wrapper .ql-toolbar button.ql-active .ql-stroke { stroke: var(--accent) !important; }
|
||||||
|
.quill-compose-wrapper .ql-fill { fill: var(--text-secondary) !important; }
|
||||||
|
.quill-compose-wrapper .ql-toolbar button:hover .ql-fill,
|
||||||
|
.quill-compose-wrapper .ql-toolbar button.ql-active .ql-fill { fill: var(--accent) !important; }
|
||||||
|
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-color .ql-picker-label:hover .ql-stroke,
|
||||||
|
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-color.ql-expanded .ql-picker-label .ql-stroke,
|
||||||
|
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-color .ql-picker-label:hover .ql-fill,
|
||||||
|
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-color.ql-expanded .ql-picker-label .ql-fill {
|
||||||
|
stroke: var(--ql-current-color, var(--text-secondary)) !important;
|
||||||
|
fill: var(--ql-current-color, var(--text-secondary)) !important;
|
||||||
|
}
|
||||||
|
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-background .ql-picker-label:hover .ql-fill,
|
||||||
|
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-background.ql-expanded .ql-picker-label .ql-fill {
|
||||||
|
fill: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
|
.quill-compose-wrapper .ql-container.ql-snow {
|
||||||
|
border-left: none !important;
|
||||||
|
border-right: none !important;
|
||||||
|
border-bottom: none !important;
|
||||||
|
font-size: 14px !important;
|
||||||
|
}
|
||||||
|
.quill-compose-wrapper .ql-editor {
|
||||||
|
color: ${editorPreviewDark ? "var(--text-primary)" : "#1a1a1a"} !important;
|
||||||
|
background: ${editorPreviewDark ? "var(--bg-input)" : "#ffffff"} !important;
|
||||||
|
}
|
||||||
|
.quill-compose-wrapper .ql-editor.ql-blank::before {
|
||||||
|
color: ${editorPreviewDark ? "var(--text-muted)" : "#6b7280"} !important;
|
||||||
|
font-style: normal !important;
|
||||||
|
}
|
||||||
|
.quill-compose-wrapper .ql-editor blockquote {
|
||||||
|
border-left: 3px solid ${editorPreviewDark ? "var(--border-primary)" : "#d1d5db"} !important;
|
||||||
|
color: ${editorPreviewDark ? "var(--text-secondary)" : "#6b7280"} !important;
|
||||||
|
padding-left: 12px !important;
|
||||||
|
}
|
||||||
|
.compose-email-input {
|
||||||
|
border: 1px solid var(--border-primary) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
background-color: var(--bg-input) !important;
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
.compose-email-input:focus {
|
||||||
|
border-color: var(--border-primary) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
.compose-email-input:-webkit-autofill,
|
||||||
|
.compose-email-input:-webkit-autofill:hover,
|
||||||
|
.compose-email-input:-webkit-autofill:focus {
|
||||||
|
-webkit-box-shadow: 0 0 0 1000px var(--bg-input) inset !important;
|
||||||
|
-webkit-text-fill-color: var(--text-primary) !important;
|
||||||
|
border: 1px solid var(--border-primary) !important;
|
||||||
|
transition: background-color 9999s ease-out 0s;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
669
frontend/src/crm/components/MailViewModal.jsx
Normal file
@@ -0,0 +1,669 @@
|
|||||||
|
/**
|
||||||
|
* MailViewModal
|
||||||
|
* Full email view with:
|
||||||
|
* - Dark-themed iframe body
|
||||||
|
* - ESC / click-outside to close
|
||||||
|
* - Save inline images (via JSON endpoint) or attachments (re-fetched from IMAP)
|
||||||
|
* to customer's Nextcloud media folder
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* open – boolean
|
||||||
|
* onClose – () => void
|
||||||
|
* entry – CommInDB
|
||||||
|
* customer – customer object | null
|
||||||
|
* onReply – (defaultTo: string) => void
|
||||||
|
*/
|
||||||
|
import { useRef, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const SUBFOLDERS = ["received_media", "documents", "sent_media", "photos"];
|
||||||
|
|
||||||
|
// ── Add Customer mini modal ────────────────────────────────────────────────────
|
||||||
|
function AddCustomerModal({ email, onClose, onCreated }) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [surname, setSurname] = useState("");
|
||||||
|
const [organization, setOrganization] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e) => { if (e.key === "Escape") onClose(); };
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!name.trim()) { setErr("Please enter a first name."); return; }
|
||||||
|
setSaving(true);
|
||||||
|
setErr("");
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
const body = {
|
||||||
|
name: name.trim(),
|
||||||
|
surname: surname.trim(),
|
||||||
|
organization: organization.trim(),
|
||||||
|
language: "en",
|
||||||
|
contacts: [{ type: "email", label: "Email", value: email, primary: true }],
|
||||||
|
};
|
||||||
|
const resp = await fetch("/api/crm/customers", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const e = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(e.detail || `Error ${resp.status}`);
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
onCreated(data);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
backgroundColor: "var(--bg-input)",
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
border: "1px solid",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "6px 10px",
|
||||||
|
fontSize: 13,
|
||||||
|
width: "100%",
|
||||||
|
outline: "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 1100,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.6)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-primary)",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 24,
|
||||||
|
width: 400,
|
||||||
|
boxShadow: "0 12px 40px rgba(0,0,0,0.5)",
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold mb-1" style={{ color: "var(--text-heading)" }}>
|
||||||
|
Add Customer
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs mb-4" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Adding <strong style={{ color: "var(--accent)" }}>{email}</strong> as a new customer.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>First Name *</label>
|
||||||
|
<input style={inputStyle} value={name} onChange={(e) => setName(e.target.value)} placeholder="First name" autoFocus />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>Last Name</label>
|
||||||
|
<input style={inputStyle} value={surname} onChange={(e) => setSurname(e.target.value)} placeholder="Last name" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>Organization</label>
|
||||||
|
<input style={inputStyle} value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder="Church, school, etc." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>Email</label>
|
||||||
|
<input style={{ ...inputStyle, opacity: 0.6, cursor: "not-allowed" }} value={email} readOnly />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <p className="text-xs mb-3" style={{ color: "var(--danger-text)" }}>{err}</p>}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
|
||||||
|
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-90"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: saving ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Add Customer"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Save-to-DB mini modal ─────────────────────────────────────────────────────
|
||||||
|
function SaveModal({ item, commId, onClose }) {
|
||||||
|
// item: { type: "inline"|"attachment", filename, mime_type, dataUri?, attachmentIndex? }
|
||||||
|
const [filename, setFilename] = useState(item.filename || "file");
|
||||||
|
const [subfolder, setSubfolder] = useState("received_media");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
// ESC to close
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e) => { if (e.key === "Escape") onClose(); };
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!filename.trim()) { setErr("Please enter a filename."); return; }
|
||||||
|
setSaving(true);
|
||||||
|
setErr("");
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
|
||||||
|
if (item.type === "inline") {
|
||||||
|
// JSON body — avoids form multipart size limits for large data URIs
|
||||||
|
const resp = await fetch(`/api/crm/comms/email/${commId}/save-inline`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
data_uri: item.dataUri,
|
||||||
|
filename: filename.trim(),
|
||||||
|
subfolder,
|
||||||
|
mime_type: item.mime_type,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const e = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(e.detail || `Error ${resp.status}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Attachment: re-fetched from IMAP server-side
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("filename", filename.trim());
|
||||||
|
fd.append("subfolder", subfolder);
|
||||||
|
const resp = await fetch(
|
||||||
|
`/api/crm/comms/email/${commId}/save-attachment/${item.attachmentIndex}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: fd,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!resp.ok) {
|
||||||
|
const e = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(e.detail || `Error ${resp.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setDone(true);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
backgroundColor: "var(--bg-input)",
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
border: "1px solid",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "6px 10px",
|
||||||
|
fontSize: 13,
|
||||||
|
width: "100%",
|
||||||
|
outline: "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 1100,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.6)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
}}
|
||||||
|
// Do NOT close on backdrop click — user must use Cancel
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-primary)",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 24,
|
||||||
|
width: 380,
|
||||||
|
boxShadow: "0 12px 40px rgba(0,0,0,0.5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{done ? (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<div style={{ fontSize: 32, marginBottom: 8 }}>✅</div>
|
||||||
|
<p className="text-sm font-medium" style={{ color: "var(--text-heading)" }}>Saved successfully</p>
|
||||||
|
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
|
||||||
|
File stored in <strong>{subfolder}/</strong>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="mt-4 px-4 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h3 className="text-sm font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
|
||||||
|
Save to Customer Media
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
Filename
|
||||||
|
</label>
|
||||||
|
<input style={inputStyle} value={filename} onChange={(e) => setFilename(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
Folder
|
||||||
|
</label>
|
||||||
|
<select value={subfolder} onChange={(e) => setSubfolder(e.target.value)} style={inputStyle}>
|
||||||
|
{SUBFOLDERS.map((f) => (
|
||||||
|
<option key={f} value={f}>{f}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && (
|
||||||
|
<p className="text-xs mb-3" style={{ color: "var(--danger-text)" }}>{err}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
|
||||||
|
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-90"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: saving ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main modal ────────────────────────────────────────────────────────────────
|
||||||
|
export default function MailViewModal({ open, onClose, entry, customer, onReply, onCustomerAdded }) {
|
||||||
|
const iframeRef = useRef(null);
|
||||||
|
const [saveItem, setSaveItem] = useState(null);
|
||||||
|
const [inlineImages, setInlineImages] = useState([]);
|
||||||
|
const [bodyDark, setBodyDark] = useState(true);
|
||||||
|
const [showAddCustomer, setShowAddCustomer] = useState(false);
|
||||||
|
const [addedCustomer, setAddedCustomer] = useState(null);
|
||||||
|
|
||||||
|
// ESC to close
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e) => { if (e.key === "Escape") onClose(); };
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
// Extract inline images from HTML body
|
||||||
|
useEffect(() => {
|
||||||
|
if (!entry?.body_html) { setInlineImages([]); return; }
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(entry.body_html, "text/html");
|
||||||
|
const imgs = Array.from(doc.querySelectorAll("img[src^='data:']"));
|
||||||
|
const found = imgs.map((img, i) => {
|
||||||
|
const src = img.getAttribute("src");
|
||||||
|
const mimeMatch = src.match(/^data:([^;]+);/);
|
||||||
|
const mime = mimeMatch ? mimeMatch[1] : "image/png";
|
||||||
|
const ext = mime.split("/")[1] || "png";
|
||||||
|
return {
|
||||||
|
type: "inline",
|
||||||
|
filename: `inline-image-${i + 1}.${ext}`,
|
||||||
|
mime_type: mime,
|
||||||
|
dataUri: src,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setInlineImages(found);
|
||||||
|
}, [entry]);
|
||||||
|
|
||||||
|
// Reset dark mode when new entry opens
|
||||||
|
useEffect(() => { setBodyDark(true); }, [entry]);
|
||||||
|
|
||||||
|
// Reset addedCustomer when a new entry opens
|
||||||
|
useEffect(() => { setAddedCustomer(null); }, [entry]);
|
||||||
|
|
||||||
|
if (!open || !entry) return null;
|
||||||
|
|
||||||
|
const isInbound = entry.direction === "inbound";
|
||||||
|
const fromLabel = isInbound
|
||||||
|
? (entry.from_addr || customer?.name || "Unknown Sender")
|
||||||
|
: "Me";
|
||||||
|
const toLabel = Array.isArray(entry.to_addrs)
|
||||||
|
? entry.to_addrs.join(", ")
|
||||||
|
: (entry.to_addrs || "");
|
||||||
|
|
||||||
|
const hasHtml = !!entry.body_html && entry.body_html.trim().length > 0;
|
||||||
|
const attachments = Array.isArray(entry.attachments) ? entry.attachments : [];
|
||||||
|
const canSave = !!entry.customer_id;
|
||||||
|
|
||||||
|
const handleReply = () => {
|
||||||
|
const replyTo = isInbound ? (entry.from_addr || "") : toLabel;
|
||||||
|
if (onReply) onReply(replyTo, entry.mail_account || "");
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const iframeDoc = hasHtml
|
||||||
|
? entry.body_html
|
||||||
|
: `<pre style="font-family:inherit;white-space:pre-wrap;margin:0">${(entry.body || "").replace(/</g, "<")}</pre>`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop — click outside closes */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 1000,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.55)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
padding: 60,
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{/* Modal box */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-primary)",
|
||||||
|
borderRadius: 12,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
boxShadow: "0 20px 60px rgba(0,0,0,0.4)",
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-5 py-4"
|
||||||
|
style={{ borderBottom: "1px solid var(--border-primary)", flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<h2 className="text-base font-semibold truncate" style={{ color: "var(--text-heading)" }}>
|
||||||
|
{entry.subject || <span style={{ color: "var(--text-muted)", fontStyle: "italic" }}>(no subject)</span>}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{entry.occurred_at ? new Date(entry.occurred_at).toLocaleString() : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="cursor-pointer hover:opacity-70 ml-4 flex-shrink-0"
|
||||||
|
style={{ color: "var(--text-muted)", background: "none", border: "none", fontSize: 22, lineHeight: 1 }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta row */}
|
||||||
|
<div className="px-5 py-3" style={{ borderBottom: "1px solid var(--border-secondary)", backgroundColor: "var(--bg-primary)", flexShrink: 0 }}>
|
||||||
|
<div className="flex flex-wrap items-center gap-y-1 text-xs" style={{ gap: "0 0" }}>
|
||||||
|
<span style={{ paddingRight: 12 }}>
|
||||||
|
<span style={{ color: "var(--text-muted)" }}>From: </span>
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>{fromLabel}</span>
|
||||||
|
{isInbound && entry.from_addr && customer && (
|
||||||
|
<span style={{ color: "var(--text-muted)", marginLeft: 4 }}>({entry.from_addr})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{toLabel && (
|
||||||
|
<>
|
||||||
|
<span style={{ color: "var(--border-primary)", paddingRight: 12 }}>|</span>
|
||||||
|
<span style={{ paddingRight: 12 }}>
|
||||||
|
<span style={{ color: "var(--text-muted)" }}>To: </span>
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>{toLabel}</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{customer && (
|
||||||
|
<>
|
||||||
|
<span style={{ color: "var(--border-primary)", paddingRight: 12 }}>|</span>
|
||||||
|
<span style={{ paddingRight: 12 }}>
|
||||||
|
<span style={{ color: "var(--text-muted)" }}>Customer: </span>
|
||||||
|
<span style={{ color: "var(--accent)" }}>{customer.name}</span>
|
||||||
|
{customer.organization && (
|
||||||
|
<span style={{ color: "var(--text-muted)" }}> · {customer.organization}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span style={{ color: "var(--border-primary)", paddingRight: 12 }}>|</span>
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 rounded-full capitalize"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isInbound ? "var(--danger-bg)" : "#dcfce7",
|
||||||
|
color: isInbound ? "var(--danger-text)" : "#166534",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.direction}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body — dark iframe with dark/light toggle */}
|
||||||
|
<div className="flex-1 overflow-hidden" style={{ position: "relative" }}>
|
||||||
|
{/* Dark / Light toggle */}
|
||||||
|
<div style={{ position: "absolute", top: 8, right: 20, zIndex: 10 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBodyDark((v) => !v)}
|
||||||
|
title={bodyDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
|
style={{
|
||||||
|
padding: "4px 10px", fontSize: 11, borderRadius: 6, cursor: "pointer",
|
||||||
|
border: "1px solid rgba(128,128,128,0.4)",
|
||||||
|
backgroundColor: bodyDark ? "rgba(0,0,0,0.55)" : "rgba(255,255,255,0.7)",
|
||||||
|
color: bodyDark ? "#e0e0e0" : "#333",
|
||||||
|
backdropFilter: "blur(4px)",
|
||||||
|
transition: "all 0.15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bodyDark ? "☀ Light" : "🌙 Dark"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
sandbox="allow-same-origin allow-popups"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
style={{ width: "100%", height: "100%", border: "none", display: "block" }}
|
||||||
|
srcDoc={`<!DOCTYPE html><html><head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; padding: 0; background: ${bodyDark ? "#1a1a1a" : "#ffffff"}; color: ${bodyDark ? "#e0e0e0" : "#1a1a1a"}; }
|
||||||
|
body { padding: 16px 20px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; line-height: 1.6; }
|
||||||
|
img { max-width: 100%; height: auto; display: inline-block; }
|
||||||
|
pre { white-space: pre-wrap; word-break: break-word; }
|
||||||
|
a { color: ${bodyDark ? "#60a5fa" : "#1d4ed8"}; }
|
||||||
|
blockquote { border-left: 3px solid ${bodyDark ? "#404040" : "#d1d5db"}; margin: 8px 0; padding-left: 12px; color: ${bodyDark ? "#9ca3af" : "#6b7280"}; }
|
||||||
|
table { color: ${bodyDark ? "#e0e0e0" : "#1a1a1a"}; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
</style>
|
||||||
|
</head><body>${iframeDoc}</body></html>`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline images */}
|
||||||
|
{inlineImages.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="px-5 py-3 flex flex-wrap gap-2"
|
||||||
|
style={{ borderTop: "1px solid var(--border-secondary)", backgroundColor: "var(--bg-primary)", flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium w-full mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
Inline Images ({inlineImages.length})
|
||||||
|
</span>
|
||||||
|
{inlineImages.map((img, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)", color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
<span>🖼️</span>
|
||||||
|
<span className="truncate" style={{ maxWidth: 160 }}>{img.filename}</span>
|
||||||
|
{canSave && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSaveItem(img)}
|
||||||
|
className="cursor-pointer hover:opacity-80 text-xs px-2 py-0.5 rounded"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", border: "none", marginLeft: 4 }}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attachments */}
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="px-5 py-3 flex flex-wrap gap-2"
|
||||||
|
style={{ borderTop: "1px solid var(--border-secondary)", backgroundColor: "var(--bg-primary)", flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium w-full mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
Attachments ({attachments.length})
|
||||||
|
</span>
|
||||||
|
{attachments.map((a, i) => {
|
||||||
|
const name = a.filename || a.name || "file";
|
||||||
|
const ct = a.content_type || "";
|
||||||
|
const kb = a.size ? ` · ${Math.ceil(a.size / 1024)} KB` : "";
|
||||||
|
const icon = ct.startsWith("image/") ? "🖼️" : ct === "application/pdf" ? "📑" : ct.startsWith("video/") ? "🎬" : "📎";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)", color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
<span>{icon}</span>
|
||||||
|
<span className="truncate" style={{ maxWidth: 200 }}>{name}{kb}</span>
|
||||||
|
{canSave && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSaveItem({ type: "attachment", filename: name, mime_type: ct, attachmentIndex: i })}
|
||||||
|
className="cursor-pointer hover:opacity-80 text-xs px-2 py-0.5 rounded"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", border: "none", marginLeft: 4 }}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Unknown sender banner */}
|
||||||
|
{isInbound && !customer && !addedCustomer && entry.from_addr && (
|
||||||
|
<div
|
||||||
|
className="px-5 py-3 flex items-center gap-2 text-xs"
|
||||||
|
style={{ borderTop: "1px solid var(--border-secondary)", backgroundColor: "var(--bg-primary)", flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--text-muted)" }}>
|
||||||
|
<strong style={{ color: "var(--text-secondary)" }}>{entry.from_addr}</strong> is not in your Customer's list.{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAddCustomer(true)}
|
||||||
|
className="cursor-pointer hover:underline"
|
||||||
|
style={{ background: "none", border: "none", padding: 0, color: "var(--accent)", fontWeight: 500, fontSize: "inherit" }}
|
||||||
|
>
|
||||||
|
Click here to add them.
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isInbound && addedCustomer && (
|
||||||
|
<div
|
||||||
|
className="px-5 py-3 flex items-center gap-2 text-xs"
|
||||||
|
style={{ borderTop: "1px solid var(--border-secondary)", backgroundColor: "var(--bg-primary)", flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--success-text, #16a34a)" }}>
|
||||||
|
✓ <strong>{addedCustomer.name}</strong> has been added as a customer.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-end gap-3 px-5 py-4"
|
||||||
|
style={{ borderTop: "1px solid var(--border-primary)", flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
|
||||||
|
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
{onReply && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleReply}
|
||||||
|
className="px-4 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
|
||||||
|
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}
|
||||||
|
>
|
||||||
|
↩ Reply
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save sub-modal */}
|
||||||
|
{saveItem && (
|
||||||
|
<SaveModal
|
||||||
|
item={saveItem}
|
||||||
|
commId={entry.id}
|
||||||
|
onClose={() => setSaveItem(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Customer sub-modal */}
|
||||||
|
{showAddCustomer && entry?.from_addr && (
|
||||||
|
<AddCustomerModal
|
||||||
|
email={entry.from_addr}
|
||||||
|
onClose={() => setShowAddCustomer(false)}
|
||||||
|
onCreated={(newCustomer) => {
|
||||||
|
setAddedCustomer(newCustomer);
|
||||||
|
setShowAddCustomer(false);
|
||||||
|
if (onCustomerAdded) onCustomerAdded(newCustomer);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
2883
frontend/src/crm/customers/CustomerDetail.jsx
Normal file
579
frontend/src/crm/customers/CustomerForm.jsx
Normal file
@@ -0,0 +1,579 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import api from "../../api/client";
|
||||||
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
|
|
||||||
|
const CONTACT_TYPES = ["email", "phone", "whatsapp", "other"];
|
||||||
|
const LANGUAGES = [
|
||||||
|
{ value: "el", label: "Greek" },
|
||||||
|
{ value: "en", label: "English" },
|
||||||
|
{ value: "de", label: "German" },
|
||||||
|
{ value: "fr", label: "French" },
|
||||||
|
{ value: "it", label: "Italian" },
|
||||||
|
];
|
||||||
|
const TITLES = ["", "Fr.", "Rev.", "Archim.", "Bp.", "Abp.", "Met.", "Mr.", "Mrs.", "Ms.", "Dr.", "Prof."];
|
||||||
|
const PRESET_TAGS = ["church", "monastery", "municipality", "school", "repeat-customer", "vip", "pending", "inactive"];
|
||||||
|
|
||||||
|
const CONTACT_TYPE_ICONS = {
|
||||||
|
email: "📧",
|
||||||
|
phone: "📞",
|
||||||
|
whatsapp: "💬",
|
||||||
|
other: "🔗",
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputClass = "w-full px-3 py-2 text-sm rounded-md border";
|
||||||
|
const inputStyle = {
|
||||||
|
backgroundColor: "var(--bg-input)",
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
};
|
||||||
|
const labelStyle = {
|
||||||
|
display: "block",
|
||||||
|
marginBottom: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
fontWeight: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
function Field({ label, children, style }) {
|
||||||
|
return (
|
||||||
|
<div style={style}>
|
||||||
|
<label style={labelStyle}>{label}</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionCard({ title, children }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-lg border p-5 mb-4"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<h2 className="text-sm font-semibold mb-4" style={{ color: "var(--text-heading)" }}>{title}</h2>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyContact = () => ({ type: "email", label: "", value: "", primary: false });
|
||||||
|
|
||||||
|
export default function CustomerForm() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const isEdit = Boolean(id);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, hasPermission } = useAuth();
|
||||||
|
const canEdit = hasPermission("crm", "edit");
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
title: "",
|
||||||
|
name: "",
|
||||||
|
surname: "",
|
||||||
|
organization: "",
|
||||||
|
language: "el",
|
||||||
|
tags: [],
|
||||||
|
folder_id: "",
|
||||||
|
location: { city: "", country: "", region: "" },
|
||||||
|
contacts: [],
|
||||||
|
notes: [],
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(isEdit);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [tagInput, setTagInput] = useState("");
|
||||||
|
const [newNoteText, setNewNoteText] = useState("");
|
||||||
|
const [editingNoteIdx, setEditingNoteIdx] = useState(null);
|
||||||
|
const [editingNoteText, setEditingNoteText] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEdit) return;
|
||||||
|
api.get(`/crm/customers/${id}`)
|
||||||
|
.then((data) => {
|
||||||
|
setForm({
|
||||||
|
title: data.title || "",
|
||||||
|
name: data.name || "",
|
||||||
|
surname: data.surname || "",
|
||||||
|
organization: data.organization || "",
|
||||||
|
language: data.language || "el",
|
||||||
|
tags: data.tags || [],
|
||||||
|
folder_id: data.folder_id || "",
|
||||||
|
location: {
|
||||||
|
city: data.location?.city || "",
|
||||||
|
country: data.location?.country || "",
|
||||||
|
region: data.location?.region || "",
|
||||||
|
},
|
||||||
|
contacts: data.contacts || [],
|
||||||
|
notes: data.notes || [],
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id, isEdit]);
|
||||||
|
|
||||||
|
const set = (field, value) => setForm((f) => ({ ...f, [field]: value }));
|
||||||
|
const setLoc = (field, value) => setForm((f) => ({ ...f, location: { ...f.location, [field]: value } }));
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
const addTag = (raw) => {
|
||||||
|
const tag = raw.trim();
|
||||||
|
if (tag && !form.tags.includes(tag)) {
|
||||||
|
set("tags", [...form.tags, tag]);
|
||||||
|
}
|
||||||
|
setTagInput("");
|
||||||
|
};
|
||||||
|
const removeTag = (tag) => set("tags", form.tags.filter((t) => t !== tag));
|
||||||
|
|
||||||
|
// Contacts
|
||||||
|
const addContact = () => set("contacts", [...form.contacts, emptyContact()]);
|
||||||
|
const removeContact = (i) => set("contacts", form.contacts.filter((_, idx) => idx !== i));
|
||||||
|
const setContact = (i, field, value) => {
|
||||||
|
const updated = form.contacts.map((c, idx) => idx === i ? { ...c, [field]: value } : c);
|
||||||
|
set("contacts", updated);
|
||||||
|
};
|
||||||
|
const setPrimaryContact = (i) => {
|
||||||
|
const type = form.contacts[i].type;
|
||||||
|
const updated = form.contacts.map((c, idx) => ({
|
||||||
|
...c,
|
||||||
|
primary: c.type === type ? idx === i : c.primary,
|
||||||
|
}));
|
||||||
|
set("contacts", updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
const addNote = () => {
|
||||||
|
if (!newNoteText.trim()) return;
|
||||||
|
const note = {
|
||||||
|
text: newNoteText.trim(),
|
||||||
|
by: user?.name || "unknown",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
set("notes", [...form.notes, note]);
|
||||||
|
setNewNoteText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeNote = (i) => {
|
||||||
|
set("notes", form.notes.filter((_, idx) => idx !== i));
|
||||||
|
if (editingNoteIdx === i) setEditingNoteIdx(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditNote = (i) => {
|
||||||
|
setEditingNoteIdx(i);
|
||||||
|
setEditingNoteText(form.notes[i].text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEditNote = (i) => {
|
||||||
|
if (!editingNoteText.trim()) return;
|
||||||
|
const updated = form.notes.map((n, idx) =>
|
||||||
|
idx === i ? { ...n, text: editingNoteText.trim(), at: new Date().toISOString() } : n
|
||||||
|
);
|
||||||
|
set("notes", updated);
|
||||||
|
setEditingNoteIdx(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPayload = () => ({
|
||||||
|
title: form.title.trim() || null,
|
||||||
|
name: form.name.trim(),
|
||||||
|
surname: form.surname.trim() || null,
|
||||||
|
organization: form.organization.trim() || null,
|
||||||
|
language: form.language,
|
||||||
|
tags: form.tags,
|
||||||
|
...(!isEdit && { folder_id: form.folder_id.trim().toLowerCase() }),
|
||||||
|
location: {
|
||||||
|
city: form.location.city.trim(),
|
||||||
|
country: form.location.country.trim(),
|
||||||
|
region: form.location.region.trim(),
|
||||||
|
},
|
||||||
|
contacts: form.contacts.filter((c) => c.value.trim()),
|
||||||
|
notes: form.notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.name.trim()) { setError("Customer name is required."); return; }
|
||||||
|
if (!isEdit && !form.folder_id.trim()) { setError("Internal Folder ID is required."); return; }
|
||||||
|
if (!isEdit && !/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(form.folder_id.trim().toLowerCase())) {
|
||||||
|
setError("Internal Folder ID must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
if (isEdit) {
|
||||||
|
await api.put(`/crm/customers/${id}`, buildPayload());
|
||||||
|
navigate(`/crm/customers/${id}`);
|
||||||
|
} else {
|
||||||
|
const res = await api.post("/crm/customers", buildPayload());
|
||||||
|
navigate(`/crm/customers/${res.id}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api.delete(`/crm/customers/${id}`);
|
||||||
|
navigate("/crm/customers");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 800, margin: "0 auto" }}>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
|
||||||
|
{isEdit ? "Edit Customer" : "New Customer"}
|
||||||
|
</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(isEdit ? `/crm/customers/${id}` : "/crm/customers")}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border cursor-pointer"
|
||||||
|
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-colors cursor-pointer"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: saving ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="text-sm rounded-md p-3 mb-4 border"
|
||||||
|
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Basic Info */}
|
||||||
|
<SectionCard title="Basic Info">
|
||||||
|
{/* Row 1: Title, Name, Surname */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "140px 1fr 1fr", gap: 16, marginBottom: 16 }}>
|
||||||
|
<Field label="Title">
|
||||||
|
<select className={inputClass} style={inputStyle} value={form.title}
|
||||||
|
onChange={(e) => set("title", e.target.value)}>
|
||||||
|
{TITLES.map((t) => <option key={t} value={t}>{t || "—"}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Name *">
|
||||||
|
<input className={inputClass} style={inputStyle} value={form.name}
|
||||||
|
onChange={(e) => set("name", e.target.value)} placeholder="First name" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Surname">
|
||||||
|
<input className={inputClass} style={inputStyle} value={form.surname}
|
||||||
|
onChange={(e) => set("surname", e.target.value)} placeholder="Last name" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Organization, Language, Folder ID */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16, marginBottom: 16 }}>
|
||||||
|
<Field label="Organization">
|
||||||
|
<input className={inputClass} style={inputStyle} value={form.organization}
|
||||||
|
onChange={(e) => set("organization", e.target.value)} placeholder="Church, organization, etc." />
|
||||||
|
</Field>
|
||||||
|
<Field label="Language">
|
||||||
|
<select className={inputClass} style={inputStyle} value={form.language}
|
||||||
|
onChange={(e) => set("language", e.target.value)}>
|
||||||
|
{LANGUAGES.map((l) => <option key={l.value} value={l.value}>{l.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
{!isEdit ? (
|
||||||
|
<Field label="Folder ID *">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.folder_id}
|
||||||
|
onChange={(e) => set("folder_id", e.target.value.toLowerCase().replace(/[^a-z0-9\-]/g, ""))}
|
||||||
|
placeholder="e.g. saint-john-corfu"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Folder ID</div>
|
||||||
|
<div style={{ fontSize: 13, color: "var(--text-primary)", padding: "6px 0" }}>{form.folder_id || "—"}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!isEdit && (
|
||||||
|
<p className="text-xs" style={{ color: "var(--text-muted)", marginTop: -8, marginBottom: 16 }}>
|
||||||
|
Lowercase letters, numbers and hyphens only. This becomes the Nextcloud folder name and cannot be changed later.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Row 3: Tags */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Tags</label>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||||
|
{form.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="flex items-center gap-1 px-2 py-0.5 text-xs rounded-full cursor-pointer"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
|
||||||
|
onClick={() => removeTag(tag)}
|
||||||
|
title="Click to remove"
|
||||||
|
>
|
||||||
|
{tag} ×
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Preset quick-add tags */}
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||||
|
{PRESET_TAGS.filter((t) => !form.tags.includes(t)).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => addTag(t)}
|
||||||
|
className="px-2 py-0.5 text-xs rounded-full border cursor-pointer hover:opacity-80"
|
||||||
|
style={{ borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
+ {t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === ",") {
|
||||||
|
e.preventDefault();
|
||||||
|
addTag(tagInput);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => tagInput.trim() && addTag(tagInput)}
|
||||||
|
placeholder="Type a custom tag and press Enter or comma..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
<SectionCard title="Location">
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16 }}>
|
||||||
|
<Field label="City">
|
||||||
|
<input className={inputClass} style={inputStyle} value={form.location.city}
|
||||||
|
onChange={(e) => setLoc("city", e.target.value)} placeholder="City" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Country">
|
||||||
|
<input className={inputClass} style={inputStyle} value={form.location.country}
|
||||||
|
onChange={(e) => setLoc("country", e.target.value)} placeholder="Country" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Region">
|
||||||
|
<input className={inputClass} style={inputStyle} value={form.location.region}
|
||||||
|
onChange={(e) => setLoc("region", e.target.value)} placeholder="Region" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Contacts */}
|
||||||
|
<SectionCard title="Contacts">
|
||||||
|
{form.contacts.map((c, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex gap-2 mb-2 items-center"
|
||||||
|
>
|
||||||
|
<span className="text-base w-6 text-center flex-shrink-0">{CONTACT_TYPE_ICONS[c.type] || "🔗"}</span>
|
||||||
|
<select
|
||||||
|
className="px-2 py-2 text-sm rounded-md border w-32 flex-shrink-0"
|
||||||
|
style={inputStyle}
|
||||||
|
value={c.type}
|
||||||
|
onChange={(e) => setContact(i, "type", e.target.value)}
|
||||||
|
>
|
||||||
|
{CONTACT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
className="px-2 py-2 text-sm rounded-md border w-28 flex-shrink-0"
|
||||||
|
style={inputStyle}
|
||||||
|
value={c.label}
|
||||||
|
onChange={(e) => setContact(i, "label", e.target.value)}
|
||||||
|
placeholder="label (e.g. work)"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className={inputClass + " flex-1"}
|
||||||
|
style={inputStyle}
|
||||||
|
value={c.value}
|
||||||
|
onChange={(e) => setContact(i, "value", e.target.value)}
|
||||||
|
placeholder="value"
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-1 text-xs flex-shrink-0 cursor-pointer" style={{ color: "var(--text-muted)" }}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`primary-${c.type}`}
|
||||||
|
checked={!!c.primary}
|
||||||
|
onChange={() => setPrimaryContact(i)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
/>
|
||||||
|
Primary
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeContact(i)}
|
||||||
|
className="text-xs cursor-pointer hover:opacity-70 flex-shrink-0"
|
||||||
|
style={{ color: "var(--danger)" }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addContact}
|
||||||
|
className="mt-2 px-3 py-1.5 text-xs rounded-md border cursor-pointer hover:opacity-80"
|
||||||
|
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
+ Add Contact
|
||||||
|
</button>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<SectionCard title="Notes">
|
||||||
|
{form.notes.length > 0 && (
|
||||||
|
<div className="mb-4 space-y-2">
|
||||||
|
{form.notes.map((note, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="px-3 py-2 rounded-md text-sm"
|
||||||
|
style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{editingNoteIdx === i ? (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
<textarea
|
||||||
|
className={inputClass}
|
||||||
|
style={{ ...inputStyle, resize: "vertical", minHeight: 56 }}
|
||||||
|
value={editingNoteText}
|
||||||
|
onChange={(e) => setEditingNoteText(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) saveEditNote(i);
|
||||||
|
if (e.key === "Escape") setEditingNoteIdx(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ display: "flex", gap: 6 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => saveEditNote(i)}
|
||||||
|
disabled={!editingNoteText.trim()}
|
||||||
|
className="px-2 py-1 text-xs rounded-md cursor-pointer hover:opacity-90"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: editingNoteText.trim() ? 1 : 0.5 }}
|
||||||
|
>Save</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingNoteIdx(null)}
|
||||||
|
className="px-2 py-1 text-xs rounded-md cursor-pointer hover:opacity-80"
|
||||||
|
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)" }}
|
||||||
|
>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p>{note.text}</p>
|
||||||
|
<div className="flex items-center justify-between mt-1">
|
||||||
|
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{note.by} · {note.at ? new Date(note.at).toLocaleDateString() : ""}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => startEditNote(i)}
|
||||||
|
className="text-xs cursor-pointer hover:opacity-70"
|
||||||
|
style={{ color: "var(--accent)", background: "none", border: "none", padding: 0 }}
|
||||||
|
>Edit</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeNote(i)}
|
||||||
|
className="text-xs cursor-pointer hover:opacity-70"
|
||||||
|
style={{ color: "var(--danger)", background: "none", border: "none", padding: 0 }}
|
||||||
|
>Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<textarea
|
||||||
|
className={inputClass + " flex-1"}
|
||||||
|
style={{ ...inputStyle, resize: "vertical", minHeight: 64 }}
|
||||||
|
value={newNoteText}
|
||||||
|
onChange={(e) => setNewNoteText(e.target.value)}
|
||||||
|
placeholder="Add a note..."
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addNote}
|
||||||
|
disabled={!newNoteText.trim()}
|
||||||
|
className="px-3 py-2 text-sm rounded-md cursor-pointer hover:opacity-80 self-start"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: newNoteText.trim() ? 1 : 0.5 }}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
{isEdit && canEdit && (
|
||||||
|
<div
|
||||||
|
className="rounded-lg border p-5"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<h2 className="text-sm font-semibold mb-2" style={{ color: "var(--danger)" }}>Danger Zone</h2>
|
||||||
|
{!showDeleteConfirm ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border cursor-pointer hover:opacity-80"
|
||||||
|
style={{ borderColor: "var(--danger)", color: "var(--danger)" }}
|
||||||
|
>
|
||||||
|
Delete Customer
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm mb-3" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
Are you sure? This cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 text-sm rounded-md cursor-pointer hover:opacity-80"
|
||||||
|
style={{ backgroundColor: "var(--danger)", color: "#fff", opacity: saving ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{saving ? "Deleting..." : "Yes, Delete"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border cursor-pointer"
|
||||||
|
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
frontend/src/crm/customers/CustomerList.jsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import api from "../../api/client";
|
||||||
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
backgroundColor: "var(--bg-input)",
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
};
|
||||||
|
|
||||||
|
function primaryContact(customer, type) {
|
||||||
|
const contacts = customer.contacts || [];
|
||||||
|
const primary = contacts.find((c) => c.type === type && c.primary);
|
||||||
|
return primary?.value || contacts.find((c) => c.type === type)?.value || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomerList() {
|
||||||
|
const [customers, setCustomers] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [tagFilter, setTagFilter] = useState("");
|
||||||
|
const [hoveredRow, setHoveredRow] = useState(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
const canEdit = hasPermission("crm", "edit");
|
||||||
|
|
||||||
|
const fetchCustomers = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (search) params.set("search", search);
|
||||||
|
if (tagFilter) params.set("tag", tagFilter);
|
||||||
|
const qs = params.toString();
|
||||||
|
const data = await api.get(`/crm/customers${qs ? `?${qs}` : ""}`);
|
||||||
|
setCustomers(data.customers);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCustomers();
|
||||||
|
}, [search, tagFilter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Customers</h1>
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/crm/customers/new")}
|
||||||
|
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-colors cursor-pointer"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
New Customer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mb-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name, location, email, phone, tags..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="flex-1 px-3 py-2 text-sm rounded-md border"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter by tag..."
|
||||||
|
value={tagFilter}
|
||||||
|
onChange={(e) => setTagFilter(e.target.value)}
|
||||||
|
className="w-40 px-3 py-2 text-sm rounded-md border"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="text-sm rounded-md p-3 mb-4 border"
|
||||||
|
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
|
||||||
|
) : customers.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="rounded-lg p-8 text-center text-sm border"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
No customers found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="rounded-lg overflow-hidden border"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Name</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Organization</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Location</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Email</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Phone</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Tags</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{customers.map((c, index) => {
|
||||||
|
const loc = c.location || {};
|
||||||
|
const locationStr = [loc.city, loc.country].filter(Boolean).join(", ");
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => navigate(`/crm/customers/${c.id}`)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
style={{
|
||||||
|
borderBottom: index < customers.length - 1 ? "1px solid var(--border-secondary)" : "none",
|
||||||
|
backgroundColor: hoveredRow === c.id ? "var(--bg-card-hover)" : "transparent",
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHoveredRow(c.id)}
|
||||||
|
onMouseLeave={() => setHoveredRow(null)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-medium" style={{ color: "var(--text-heading)" }}>
|
||||||
|
{[c.title, c.name, c.surname].filter(Boolean).join(" ")}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{c.organization || "—"}</td>
|
||||||
|
<td className="px-4 py-3" style={{ color: "var(--text-muted)" }}>{locationStr || "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{primaryContact(c, "email") || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{primaryContact(c, "phone") || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(c.tags || []).slice(0, 3).map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="px-2 py-0.5 text-xs rounded-full"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{(c.tags || []).length > 3 && (
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
+{c.tags.length - 3}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
frontend/src/crm/customers/index.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as CustomerList } from "./CustomerList";
|
||||||
|
export { default as CustomerForm } from "./CustomerForm";
|
||||||
|
export { default as CustomerDetail } from "./CustomerDetail";
|
||||||
466
frontend/src/crm/inbox/CommsPage.jsx
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import api from "../../api/client";
|
||||||
|
import MailViewModal from "../components/MailViewModal";
|
||||||
|
import ComposeEmailModal from "../components/ComposeEmailModal";
|
||||||
|
import { CommTypeIconBadge, CommDirectionIcon } from "../components/CommIcons";
|
||||||
|
|
||||||
|
// Display labels for transport types - always lowercase
|
||||||
|
const TYPE_LABELS = {
|
||||||
|
email: "e-mail",
|
||||||
|
whatsapp: "whatsapp",
|
||||||
|
call: "phonecall",
|
||||||
|
sms: "sms",
|
||||||
|
note: "note",
|
||||||
|
in_person: "in person",
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMMS_TYPES = ["email", "whatsapp", "call", "sms", "note", "in_person"];
|
||||||
|
const DIRECTIONS = ["inbound", "outbound", "internal"];
|
||||||
|
const COMM_DATE_FMT = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", year: "numeric" });
|
||||||
|
const COMM_TIME_FMT = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
|
||||||
|
|
||||||
|
function formatCommDateTime(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 selectStyle = {
|
||||||
|
backgroundColor: "var(--bg-input)",
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: 13,
|
||||||
|
padding: "6px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid",
|
||||||
|
cursor: "pointer",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Customer search mini modal (replaces the giant dropdown)
|
||||||
|
function CustomerPickerModal({ open, onClose, customers, value, onChange }) {
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) { setQ(""); setTimeout(() => inputRef.current?.focus(), 60); }
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// ESC to close
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e) => { if (e.key === "Escape") onClose(); };
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const lower = q.trim().toLowerCase();
|
||||||
|
const filtered = customers.filter((c) =>
|
||||||
|
!lower ||
|
||||||
|
(c.name || "").toLowerCase().includes(lower) ||
|
||||||
|
(c.surname || "").toLowerCase().includes(lower) ||
|
||||||
|
(c.organization || "").toLowerCase().includes(lower) ||
|
||||||
|
(c.contacts || []).some((ct) => (ct.value || "").toLowerCase().includes(lower))
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{ position: "fixed", inset: 0, zIndex: 500, backgroundColor: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)", borderRadius: 10, width: 380, maxHeight: 460, display: "flex", flexDirection: "column", boxShadow: "0 16px 48px rgba(0,0,0,0.35)", overflow: "hidden" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={{ padding: "12px 14px", borderBottom: "1px solid var(--border-secondary)" }}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="Search customer..."
|
||||||
|
style={{ width: "100%", padding: "7px 10px", fontSize: 13, borderRadius: 6, border: "1px solid var(--border-primary)", backgroundColor: "var(--bg-input)", color: "var(--text-primary)", outline: "none" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ overflowY: "auto", flex: 1 }}>
|
||||||
|
{/* All customers option */}
|
||||||
|
<div
|
||||||
|
onClick={() => { onChange(""); onClose(); }}
|
||||||
|
style={{ padding: "9px 14px", fontSize: 13, cursor: "pointer", color: value === "" ? "var(--accent)" : "var(--text-primary)", backgroundColor: value === "" ? "color-mix(in srgb, var(--accent) 8%, var(--bg-card))" : "transparent", fontWeight: value === "" ? 600 : 400 }}
|
||||||
|
onMouseEnter={(e) => { if (value !== "") e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }}
|
||||||
|
onMouseLeave={(e) => { if (value !== "") e.currentTarget.style.backgroundColor = "transparent"; }}
|
||||||
|
>
|
||||||
|
All customers
|
||||||
|
</div>
|
||||||
|
{filtered.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => { onChange(c.id); onClose(); }}
|
||||||
|
style={{ padding: "9px 14px", fontSize: 13, cursor: "pointer", color: value === c.id ? "var(--accent)" : "var(--text-primary)", backgroundColor: value === c.id ? "color-mix(in srgb, var(--accent) 8%, var(--bg-card))" : "transparent" }}
|
||||||
|
onMouseEnter={(e) => { if (value !== c.id) e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }}
|
||||||
|
onMouseLeave={(e) => { if (value !== c.id) e.currentTarget.style.backgroundColor = value === c.id ? "color-mix(in srgb, var(--accent) 8%, var(--bg-card))" : "transparent"; }}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 500 }}>{c.name}{c.surname ? ` ${c.surname}` : ""}</div>
|
||||||
|
{c.organization && <div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 1 }}>{c.organization}</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && q && (
|
||||||
|
<div style={{ padding: "16px 14px", textAlign: "center", fontSize: 13, color: "var(--text-muted)" }}>No customers found</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommsPage() {
|
||||||
|
const [entries, setEntries] = useState([]);
|
||||||
|
const [customers, setCustomers] = useState({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [typeFilter, setTypeFilter] = useState("");
|
||||||
|
const [dirFilter, setDirFilter] = useState("");
|
||||||
|
const [custFilter, setCustFilter] = useState("");
|
||||||
|
const [expandedId, setExpandedId] = useState(null); // only 1 at a time
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [syncResult, setSyncResult] = useState(null);
|
||||||
|
const [custPickerOpen, setCustPickerOpen] = useState(false);
|
||||||
|
|
||||||
|
// Modals
|
||||||
|
const [viewEntry, setViewEntry] = useState(null);
|
||||||
|
const [composeOpen, setComposeOpen] = useState(false);
|
||||||
|
const [composeTo, setComposeTo] = useState("");
|
||||||
|
const [composeFromAccount, setComposeFromAccount] = useState("");
|
||||||
|
|
||||||
|
const loadAll = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ limit: 200 });
|
||||||
|
if (typeFilter) params.set("type", typeFilter);
|
||||||
|
if (dirFilter) params.set("direction", dirFilter);
|
||||||
|
const [commsData, custsData] = await Promise.all([
|
||||||
|
api.get(`/crm/comms/all?${params}`),
|
||||||
|
api.get("/crm/customers"),
|
||||||
|
]);
|
||||||
|
setEntries(commsData.entries || []);
|
||||||
|
const map = {};
|
||||||
|
for (const c of custsData.customers || []) map[c.id] = c;
|
||||||
|
setCustomers(map);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [typeFilter, dirFilter]);
|
||||||
|
|
||||||
|
useEffect(() => { loadAll(); }, [loadAll]);
|
||||||
|
|
||||||
|
const syncEmails = async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
setSyncResult(null);
|
||||||
|
try {
|
||||||
|
const data = await api.post("/crm/comms/email/sync", {});
|
||||||
|
setSyncResult(data);
|
||||||
|
await loadAll();
|
||||||
|
} catch (err) {
|
||||||
|
setSyncResult({ error: err.message });
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle expand — only one at a time
|
||||||
|
const toggleExpand = (id) =>
|
||||||
|
setExpandedId((prev) => (prev === id ? null : id));
|
||||||
|
|
||||||
|
const openReply = (entry) => {
|
||||||
|
const toAddr = entry.direction === "inbound"
|
||||||
|
? (entry.from_addr || "")
|
||||||
|
: (Array.isArray(entry.to_addrs) ? entry.to_addrs[0] : "");
|
||||||
|
setViewEntry(null);
|
||||||
|
setComposeTo(toAddr);
|
||||||
|
setComposeOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = custFilter
|
||||||
|
? entries.filter((e) => e.customer_id === custFilter)
|
||||||
|
: entries;
|
||||||
|
const sortedFiltered = [...filtered].sort((a, b) => {
|
||||||
|
const ta = Date.parse(a?.occurred_at || a?.created_at || "") || 0;
|
||||||
|
const tb = Date.parse(b?.occurred_at || b?.created_at || "") || 0;
|
||||||
|
if (tb !== ta) return tb - ta;
|
||||||
|
return String(b?.id || "").localeCompare(String(a?.id || ""));
|
||||||
|
});
|
||||||
|
|
||||||
|
const customerOptions = Object.values(customers).sort((a, b) =>
|
||||||
|
(a.name || "").localeCompare(b.name || "")
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedCustomerLabel = custFilter && customers[custFilter]
|
||||||
|
? customers[custFilter].name + (customers[custFilter].organization ? ` — ${customers[custFilter].organization}` : "")
|
||||||
|
: "All customers";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold" style={{ color: "var(--text-heading)" }}>Activity Log</h1>
|
||||||
|
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||||
|
All customer communications across all channels
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{syncResult && (
|
||||||
|
<span className="text-xs" style={{ color: syncResult.error ? "var(--danger-text)" : "var(--success-text)" }}>
|
||||||
|
{syncResult.error
|
||||||
|
? syncResult.error
|
||||||
|
: `${syncResult.new_count} new email${syncResult.new_count !== 1 ? "s" : ""}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={syncEmails}
|
||||||
|
disabled={syncing || loading}
|
||||||
|
title="Connect to mail server and download new emails into the log"
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
|
||||||
|
style={{ border: "1px solid", borderColor: "var(--border-primary)", color: "var(--text-secondary)", opacity: (syncing || loading) ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
{syncing ? "Syncing..." : "Sync Emails"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={loadAll}
|
||||||
|
disabled={loading}
|
||||||
|
title="Reload from local database"
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
|
||||||
|
style={{ border: "1px solid", borderColor: "var(--border-primary)", color: "var(--text-secondary)", opacity: loading ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap gap-3 mb-5">
|
||||||
|
<select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)} style={selectStyle}>
|
||||||
|
<option value="">All types</option>
|
||||||
|
{COMMS_TYPES.map((t) => <option key={t} value={t}>{TYPE_LABELS[t] || t}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={dirFilter} onChange={(e) => setDirFilter(e.target.value)} style={selectStyle}>
|
||||||
|
<option value="">All directions</option>
|
||||||
|
{DIRECTIONS.map((d) => <option key={d} value={d}>{d}</option>)}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Customer picker button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCustPickerOpen(true)}
|
||||||
|
style={{
|
||||||
|
...selectStyle,
|
||||||
|
minWidth: 180,
|
||||||
|
textAlign: "left",
|
||||||
|
color: custFilter ? "var(--accent)" : "var(--text-primary)",
|
||||||
|
fontWeight: custFilter ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedCustomerLabel} ▾
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{(typeFilter || dirFilter || custFilter) && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setTypeFilter(""); setDirFilter(""); setCustFilter(""); }}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
|
||||||
|
style={{ color: "var(--danger-text)", backgroundColor: "var(--danger-bg)", borderRadius: 6 }}
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12" style={{ color: "var(--text-muted)" }}>Loading...</div>
|
||||||
|
) : sortedFiltered.length === 0 ? (
|
||||||
|
<div className="rounded-lg p-10 text-center text-sm border" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}>
|
||||||
|
No communications found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs mb-3" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{sortedFiltered.length} entr{sortedFiltered.length !== 1 ? "ies" : "y"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
{/* Connector line */}
|
||||||
|
<div style={{
|
||||||
|
position: "absolute", left: 19, top: 12, bottom: 12,
|
||||||
|
width: 2, backgroundColor: "var(--border-secondary)", zIndex: 0,
|
||||||
|
}} />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sortedFiltered.map((entry) => {
|
||||||
|
const customer = customers[entry.customer_id];
|
||||||
|
const isExpanded = expandedId === entry.id;
|
||||||
|
const isEmail = entry.type === "email";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={entry.id} style={{ position: "relative", paddingLeft: 44 }}>
|
||||||
|
{/* Type icon marker */}
|
||||||
|
<div style={{ position: "absolute", left: 8, top: 11, zIndex: 1 }}>
|
||||||
|
<CommTypeIconBadge type={entry.type} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="rounded-lg border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-card)",
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
cursor: entry.body ? "pointer" : "default",
|
||||||
|
}}
|
||||||
|
onClick={() => entry.body && toggleExpand(entry.id)}
|
||||||
|
>
|
||||||
|
{/* Entry header */}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 flex-wrap">
|
||||||
|
<CommDirectionIcon direction={entry.direction} />
|
||||||
|
{customer ? (
|
||||||
|
<Link
|
||||||
|
to={`/crm/customers/${entry.customer_id}`}
|
||||||
|
className="text-xs font-medium hover:underline"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{customer.name}
|
||||||
|
{customer.organization ? ` · ${customer.organization}` : ""}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-mono" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{entry.from_addr || entry.customer_id || "—"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entry.subject && (
|
||||||
|
<span className="text-sm font-medium truncate" style={{ color: "var(--text-heading)", maxWidth: 280 }}>
|
||||||
|
{entry.subject}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{/* Full View button (for email entries) */}
|
||||||
|
{isEmail && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setViewEntry(entry); }}
|
||||||
|
className="text-xs px-2 py-0.5 rounded cursor-pointer hover:opacity-80 flex-shrink-0"
|
||||||
|
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-primary)" }}
|
||||||
|
>
|
||||||
|
Full View
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="text-xs flex-shrink-0" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{formatCommDateTime(entry.occurred_at)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{entry.body && (
|
||||||
|
<span className="text-xs flex-shrink-0" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{isExpanded ? "▲" : "▼"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
{entry.body && (
|
||||||
|
<div className="pb-3" style={{ paddingLeft: 16, paddingRight: 16 }}>
|
||||||
|
<div style={{ borderTop: "1px solid var(--border-secondary)", marginLeft: 0, marginRight: 0 }} />
|
||||||
|
<p
|
||||||
|
className="text-sm mt-2"
|
||||||
|
style={{
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
display: "-webkit-box",
|
||||||
|
WebkitLineClamp: isExpanded ? "unset" : 2,
|
||||||
|
WebkitBoxOrient: "vertical",
|
||||||
|
overflow: isExpanded ? "visible" : "hidden",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.body}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer: logged_by + attachments + Quick Reply */}
|
||||||
|
{(entry.logged_by || (entry.attachments?.length > 0) || (isExpanded && isEmail)) && (
|
||||||
|
<div className="px-4 pb-3 flex items-center gap-3 flex-wrap">
|
||||||
|
{entry.logged_by && (
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
by {entry.logged_by}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{entry.attachments?.length > 0 && (
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
📎 {entry.attachments.length} attachment{entry.attachments.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isExpanded && isEmail && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); openReply(entry); }}
|
||||||
|
className="ml-auto text-xs px-2 py-1 rounded-md cursor-pointer hover:opacity-90"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", border: "none" }}
|
||||||
|
>
|
||||||
|
↩ Quick Reply
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Customer Picker Modal */}
|
||||||
|
<CustomerPickerModal
|
||||||
|
open={custPickerOpen}
|
||||||
|
onClose={() => setCustPickerOpen(false)}
|
||||||
|
customers={customerOptions}
|
||||||
|
value={custFilter}
|
||||||
|
onChange={setCustFilter}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Mail View Modal */}
|
||||||
|
<MailViewModal
|
||||||
|
open={!!viewEntry}
|
||||||
|
onClose={() => setViewEntry(null)}
|
||||||
|
entry={viewEntry}
|
||||||
|
customerName={viewEntry ? customers[viewEntry.customer_id]?.name : null}
|
||||||
|
onReply={(toAddr, sourceAccount) => {
|
||||||
|
setViewEntry(null);
|
||||||
|
setComposeTo(toAddr);
|
||||||
|
setComposeFromAccount(sourceAccount || "");
|
||||||
|
setComposeOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Compose Modal */}
|
||||||
|
<ComposeEmailModal
|
||||||
|
open={composeOpen}
|
||||||
|
onClose={() => { setComposeOpen(false); setComposeFromAccount(""); }}
|
||||||
|
defaultTo={composeTo}
|
||||||
|
defaultFromAccount={composeFromAccount}
|
||||||
|
requireFromAccount={true}
|
||||||
|
onSent={() => loadAll()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
327
frontend/src/crm/inbox/InboxPage.jsx
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import api from "../../api/client";
|
||||||
|
|
||||||
|
const TYPE_COLORS = {
|
||||||
|
email: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
|
||||||
|
whatsapp: { bg: "#dcfce7", color: "#166534" },
|
||||||
|
call: { bg: "#fef9c3", color: "#854d0e" },
|
||||||
|
sms: { bg: "#fef3c7", color: "#92400e" },
|
||||||
|
note: { bg: "var(--bg-card-hover)", color: "var(--text-secondary)" },
|
||||||
|
in_person: { bg: "#ede9fe", color: "#5b21b6" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMMS_TYPES = ["email", "whatsapp", "call", "sms", "note", "in_person"];
|
||||||
|
const DIRECTIONS = ["inbound", "outbound", "internal"];
|
||||||
|
|
||||||
|
function TypeBadge({ type }) {
|
||||||
|
const s = TYPE_COLORS[type] || TYPE_COLORS.note;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 text-xs rounded-full capitalize"
|
||||||
|
style={{ backgroundColor: s.bg, color: s.color }}
|
||||||
|
>
|
||||||
|
{type}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DirectionIcon({ direction }) {
|
||||||
|
if (direction === "inbound")
|
||||||
|
return <span title="Inbound" style={{ color: "var(--success-text)" }}>↙</span>;
|
||||||
|
if (direction === "outbound")
|
||||||
|
return <span title="Outbound" style={{ color: "var(--accent)" }}>↗</span>;
|
||||||
|
return <span title="Internal" style={{ color: "var(--text-muted)" }}>↔</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InboxPage() {
|
||||||
|
const [entries, setEntries] = useState([]);
|
||||||
|
const [customers, setCustomers] = useState({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [typeFilter, setTypeFilter] = useState("");
|
||||||
|
const [dirFilter, setDirFilter] = useState("");
|
||||||
|
const [custFilter, setCustFilter] = useState("");
|
||||||
|
const [expanded, setExpanded] = useState({});
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [syncResult, setSyncResult] = useState(null); // { new_count } | null
|
||||||
|
|
||||||
|
const loadAll = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ limit: 200 });
|
||||||
|
if (typeFilter) params.set("type", typeFilter);
|
||||||
|
if (dirFilter) params.set("direction", dirFilter);
|
||||||
|
const [commsData, custsData] = await Promise.all([
|
||||||
|
api.get(`/crm/comms/all?${params}`),
|
||||||
|
api.get("/crm/customers"),
|
||||||
|
]);
|
||||||
|
setEntries(commsData.entries || []);
|
||||||
|
// Build id→name map
|
||||||
|
const map = {};
|
||||||
|
for (const c of custsData.customers || []) {
|
||||||
|
map[c.id] = c;
|
||||||
|
}
|
||||||
|
setCustomers(map);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [typeFilter, dirFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAll();
|
||||||
|
}, [loadAll]);
|
||||||
|
|
||||||
|
const syncEmails = async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
setSyncResult(null);
|
||||||
|
try {
|
||||||
|
const data = await api.post("/crm/comms/email/sync", {});
|
||||||
|
setSyncResult(data);
|
||||||
|
await loadAll();
|
||||||
|
} catch (err) {
|
||||||
|
setSyncResult({ error: err.message });
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExpand = (id) =>
|
||||||
|
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||||
|
|
||||||
|
// Client-side customer filter
|
||||||
|
const filtered = custFilter
|
||||||
|
? entries.filter((e) => e.customer_id === custFilter)
|
||||||
|
: entries;
|
||||||
|
|
||||||
|
const customerOptions = Object.values(customers).sort((a, b) =>
|
||||||
|
(a.name || "").localeCompare(b.name || "")
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectStyle = {
|
||||||
|
backgroundColor: "var(--bg-input)",
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: 13,
|
||||||
|
padding: "6px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid",
|
||||||
|
cursor: "pointer",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold" style={{ color: "var(--text-heading)" }}>Inbox</h1>
|
||||||
|
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||||
|
All communications across all customers
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{syncResult && (
|
||||||
|
<span className="text-xs" style={{ color: syncResult.error ? "var(--danger-text)" : "var(--success-text)" }}>
|
||||||
|
{syncResult.error ? syncResult.error : `${syncResult.new_count} new email${syncResult.new_count !== 1 ? "s" : ""}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={syncEmails}
|
||||||
|
disabled={syncing || loading}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
|
||||||
|
style={{ border: "1px solid", borderColor: "var(--border-primary)", color: "var(--text-secondary)", opacity: (syncing || loading) ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
{syncing ? "Syncing..." : "Sync Emails"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={loadAll}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
|
||||||
|
style={{ borderColor: "var(--border-primary)", border: "1px solid", color: "var(--text-secondary)", opacity: loading ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap gap-3 mb-5">
|
||||||
|
<select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)} style={selectStyle}>
|
||||||
|
<option value="">All types</option>
|
||||||
|
{COMMS_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={dirFilter} onChange={(e) => setDirFilter(e.target.value)} style={selectStyle}>
|
||||||
|
<option value="">All directions</option>
|
||||||
|
{DIRECTIONS.map((d) => <option key={d} value={d}>{d}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={custFilter} onChange={(e) => setCustFilter(e.target.value)} style={selectStyle}>
|
||||||
|
<option value="">All customers</option>
|
||||||
|
{customerOptions.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}{c.organization ? ` — ${c.organization}` : ""}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{(typeFilter || dirFilter || custFilter) && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setTypeFilter(""); setDirFilter(""); setCustFilter(""); }}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
|
||||||
|
style={{ color: "var(--danger-text)", backgroundColor: "var(--danger-bg)", borderRadius: 6 }}
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12" style={{ color: "var(--text-muted)" }}>Loading...</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="rounded-lg p-10 text-center text-sm border" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}>
|
||||||
|
No communications found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs mb-3" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{filtered.length} entr{filtered.length !== 1 ? "ies" : "y"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
{/* Connector line */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 19,
|
||||||
|
top: 12,
|
||||||
|
bottom: 12,
|
||||||
|
width: 2,
|
||||||
|
backgroundColor: "var(--border-secondary)",
|
||||||
|
zIndex: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filtered.map((entry) => {
|
||||||
|
const customer = customers[entry.customer_id];
|
||||||
|
const isExpanded = !!expanded[entry.id];
|
||||||
|
const typeStyle = TYPE_COLORS[entry.type] || TYPE_COLORS.note;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
style={{ position: "relative", paddingLeft: 44 }}
|
||||||
|
>
|
||||||
|
{/* Dot */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 12,
|
||||||
|
top: 14,
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: typeStyle.bg,
|
||||||
|
border: `2px solid ${typeStyle.color}`,
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="rounded-lg border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-card)",
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
cursor: entry.body ? "pointer" : "default",
|
||||||
|
}}
|
||||||
|
onClick={() => entry.body && toggleExpand(entry.id)}
|
||||||
|
>
|
||||||
|
{/* Entry header */}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 flex-wrap">
|
||||||
|
<TypeBadge type={entry.type} />
|
||||||
|
<DirectionIcon direction={entry.direction} />
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>{entry.direction}</span>
|
||||||
|
|
||||||
|
{customer ? (
|
||||||
|
<Link
|
||||||
|
to={`/crm/customers/${entry.customer_id}`}
|
||||||
|
className="text-xs font-medium hover:underline"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{customer.name}
|
||||||
|
{customer.organization ? ` · ${customer.organization}` : ""}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-mono" style={{ color: "var(--text-muted)" }}>{entry.customer_id}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="ml-auto text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{entry.occurred_at ? new Date(entry.occurred_at).toLocaleString() : ""}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{entry.body && (
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{isExpanded ? "▲" : "▼"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subject / body preview */}
|
||||||
|
{(entry.subject || entry.body) && (
|
||||||
|
<div className="px-4 pb-3 border-t" style={{ borderColor: "var(--border-secondary)" }}>
|
||||||
|
{entry.subject && (
|
||||||
|
<p className="text-sm font-medium mt-2" style={{ color: "var(--text-heading)" }}>
|
||||||
|
{entry.subject}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{entry.body && (
|
||||||
|
<p
|
||||||
|
className="text-sm mt-1"
|
||||||
|
style={{
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
display: "-webkit-box",
|
||||||
|
WebkitLineClamp: isExpanded ? "unset" : 2,
|
||||||
|
WebkitBoxOrient: "vertical",
|
||||||
|
overflow: isExpanded ? "visible" : "hidden",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.body}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{(entry.logged_by || (entry.attachments && entry.attachments.length > 0)) && (
|
||||||
|
<div className="px-4 pb-2 flex items-center gap-3">
|
||||||
|
{entry.logged_by && (
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
by {entry.logged_by}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{entry.attachments && entry.attachments.length > 0 && (
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{entry.attachments.length} attachment{entry.attachments.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
838
frontend/src/crm/mail/MailPage.jsx
Normal file
@@ -0,0 +1,838 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import api from "../../api/client";
|
||||||
|
import ComposeEmailModal from "../components/ComposeEmailModal";
|
||||||
|
import MailViewModal from "../components/MailViewModal";
|
||||||
|
|
||||||
|
const TABS = ["inbound", "outbound"];
|
||||||
|
const CLIENT_FILTER_TABS = ["all", "clients"];
|
||||||
|
const MAILBOX_TABS = ["sales", "support", "both"];
|
||||||
|
const READ_FILTER_TABS = ["all", "unread", "read", "important"];
|
||||||
|
|
||||||
|
const FILTER_COLORS = {
|
||||||
|
inbound: "var(--mail-filter-green)",
|
||||||
|
outbound: "var(--mail-filter-blue)",
|
||||||
|
all_messages: "var(--mail-filter-yellow)",
|
||||||
|
clients_only: "var(--mail-filter-green)",
|
||||||
|
sales: "var(--mail-filter-orange)",
|
||||||
|
support: "var(--mail-filter-red)",
|
||||||
|
mailbox_all: "var(--mail-filter-green)",
|
||||||
|
read_all: "var(--mail-filter-yellow)",
|
||||||
|
unread: "var(--mail-filter-green)",
|
||||||
|
read: "var(--mail-filter-blue)",
|
||||||
|
important: "var(--mail-filter-red)",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fixed pixel width of the identity (sender/recipient) column
|
||||||
|
const ID_COL_W = 210;
|
||||||
|
|
||||||
|
const DEFAULT_POLL_INTERVAL = 30; // seconds
|
||||||
|
|
||||||
|
function getPollInterval() {
|
||||||
|
const stored = parseInt(localStorage.getItem("mail_poll_interval"), 10);
|
||||||
|
if (!isNaN(stored) && stored >= 15 && stored <= 300) return stored;
|
||||||
|
return DEFAULT_POLL_INTERVAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relative time helper
|
||||||
|
function relativeTime(dateStr) {
|
||||||
|
if (!dateStr) return "";
|
||||||
|
const now = Date.now();
|
||||||
|
const then = new Date(dateStr).getTime();
|
||||||
|
const diff = now - then;
|
||||||
|
if (diff < 0) return "just now";
|
||||||
|
const secs = Math.floor(diff / 1000);
|
||||||
|
if (secs < 60) return "just now";
|
||||||
|
const mins = Math.floor(secs / 60);
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hrs = Math.floor(mins / 60);
|
||||||
|
if (hrs < 24) return `${hrs}h ago`;
|
||||||
|
const days = Math.floor(hrs / 24);
|
||||||
|
if (days < 7) return `${days}d ago`;
|
||||||
|
const weeks = Math.floor(days / 7);
|
||||||
|
if (weeks < 5) return `${weeks}w ago`;
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
if (months < 12) return `${months}mo ago`;
|
||||||
|
return `${Math.floor(months / 12)}y ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentedButtonStyle(active, color, borderRight = "none", inactiveTextColor = "var(--text-white)") {
|
||||||
|
return {
|
||||||
|
backgroundColor: active ? color : "var(--bg-card)",
|
||||||
|
color: active ? "var(--text-white)" : inactiveTextColor,
|
||||||
|
borderRight,
|
||||||
|
fontWeight: active ? 600 : 500,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookmarkButton({ important, onClick }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||||
|
title={important ? "Remove bookmark" : "Bookmark"}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
padding: "0 2px",
|
||||||
|
cursor: "pointer",
|
||||||
|
lineHeight: 1,
|
||||||
|
color: important ? "#f59e0b" : "var(--border-primary)",
|
||||||
|
opacity: important ? 1 : 0.35,
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: "opacity 0.15s, color 0.15s",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
className="row-star"
|
||||||
|
>
|
||||||
|
<svg width="13" height="16" viewBox="0 0 13 16" fill={important ? "#f59e0b" : "currentColor"} xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1.5 1C1.22386 1 1 1.22386 1 1.5V14.5C1 14.7652 1.14583 14.9627 1.35217 15.0432C1.55851 15.1237 1.78462 15.0693 1.93301 14.9045L6.5 9.81L11.067 14.9045C11.2154 15.0693 11.4415 15.1237 11.6478 15.0432C11.8542 14.9627 12 14.7652 12 14.5V1.5C12 1.22386 11.7761 1 11.5 1H1.5Z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings / Signature Modal
|
||||||
|
function SettingsModal({ open, onClose }) {
|
||||||
|
const [signature, setSignature] = useState(() => localStorage.getItem("mail_signature") || "");
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [pollInterval, setPollInterval] = useState(() => getPollInterval());
|
||||||
|
const editorRef = useRef(null);
|
||||||
|
const quillRef = useRef(null);
|
||||||
|
const [quillLoaded, setQuillLoaded] = useState(!!window.Quill);
|
||||||
|
|
||||||
|
// ESC to close
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e) => { if (e.key === "Escape") onClose(); };
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
// Load Quill (reuse global loader pattern)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
if (window.Quill) { setQuillLoaded(true); return; }
|
||||||
|
if (!document.getElementById("__quill_js__")) {
|
||||||
|
const link = document.createElement("link");
|
||||||
|
link.id = "__quill_css__";
|
||||||
|
link.rel = "stylesheet";
|
||||||
|
link.href = "https://cdn.quilljs.com/1.3.7/quill.snow.css";
|
||||||
|
document.head.appendChild(link);
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.id = "__quill_js__";
|
||||||
|
script.src = "https://cdn.quilljs.com/1.3.7/quill.min.js";
|
||||||
|
script.onload = () => setQuillLoaded(true);
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !quillLoaded || !editorRef.current || quillRef.current) return;
|
||||||
|
const q = new window.Quill(editorRef.current, {
|
||||||
|
theme: "snow",
|
||||||
|
placeholder: "Your signature...",
|
||||||
|
modules: {
|
||||||
|
toolbar: [
|
||||||
|
["bold", "italic", "underline"],
|
||||||
|
[{ color: [] }],
|
||||||
|
["link"],
|
||||||
|
["clean"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Load existing signature HTML
|
||||||
|
const saved = localStorage.getItem("mail_signature") || "";
|
||||||
|
if (saved) q.clipboard.dangerouslyPasteHTML(saved);
|
||||||
|
quillRef.current = q;
|
||||||
|
return () => { quillRef.current = null; };
|
||||||
|
}, [open, quillLoaded]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const html = quillRef.current ? quillRef.current.root.innerHTML : signature;
|
||||||
|
localStorage.setItem("mail_signature", html);
|
||||||
|
const interval = Math.min(300, Math.max(15, parseInt(pollInterval, 10) || DEFAULT_POLL_INTERVAL));
|
||||||
|
localStorage.setItem("mail_poll_interval", String(interval));
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => { setSaved(false); onClose(); }, 800);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{ position: "fixed", inset: 0, zIndex: 1100, backgroundColor: "rgba(0,0,0,0.55)", display: "flex", alignItems: "center", justifyContent: "center", padding: 60 }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)", borderRadius: 12, width: "min(700px, 94vw)", display: "flex", flexDirection: "column", overflow: "hidden", boxShadow: "0 20px 60px rgba(0,0,0,0.4)", maxHeight: "85vh" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-5 py-4" style={{ borderBottom: "1px solid var(--border-primary)", flexShrink: 0 }}>
|
||||||
|
<h2 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Mail Settings</h2>
|
||||||
|
<button type="button" onClick={onClose} style={{ color: "var(--text-muted)", background: "none", border: "none", fontSize: 22, cursor: "pointer", lineHeight: 1 }}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Polling Rate */}
|
||||||
|
<div className="px-5 pt-4 pb-3 flex-shrink-0" style={{ borderBottom: "1px solid var(--border-secondary)" }}>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: "var(--text-secondary)" }}>Auto-Check Interval</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={15}
|
||||||
|
max={300}
|
||||||
|
value={pollInterval}
|
||||||
|
onChange={(e) => setPollInterval(e.target.value)}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md border"
|
||||||
|
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)", width: 90 }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm" style={{ color: "var(--text-muted)" }}>seconds <span style={{ fontSize: 11 }}>(min 15, max 300)</span></span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs mt-1.5" style={{ color: "var(--text-muted)" }}>
|
||||||
|
How often to check if new emails are available on the server. A banner will appear if new mail is found.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 pt-4 pb-2 flex-shrink-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide mb-3" style={{ color: "var(--text-secondary)" }}>Email Signature</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto quill-sig-wrapper" style={{ minHeight: 200, paddingBottom: 0 }}>
|
||||||
|
{quillLoaded ? (
|
||||||
|
<div ref={editorRef} style={{ minHeight: 160, backgroundColor: "var(--bg-input)", color: "var(--text-primary)" }} />
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center py-12" style={{ color: "var(--text-muted)" }}>Loading editor...</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 px-5 py-4" style={{ borderTop: "1px solid var(--border-primary)", flexShrink: 0 }}>
|
||||||
|
{saved && <span className="text-xs" style={{ color: "var(--success-text)" }}>Saved!</span>}
|
||||||
|
<button type="button" onClick={onClose} className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80" style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={handleSave} className="px-4 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-90" style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}>
|
||||||
|
Save Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.quill-sig-wrapper .ql-toolbar { background: var(--bg-card-hover); border-color: var(--border-primary) !important; border-top: none !important; }
|
||||||
|
.quill-sig-wrapper .ql-toolbar button, .quill-sig-wrapper .ql-toolbar .ql-picker-label { color: var(--text-secondary) !important; }
|
||||||
|
.quill-sig-wrapper .ql-toolbar .ql-stroke { stroke: var(--text-secondary) !important; }
|
||||||
|
.quill-sig-wrapper .ql-toolbar button:hover .ql-stroke, .quill-sig-wrapper .ql-toolbar button.ql-active .ql-stroke { stroke: var(--accent) !important; }
|
||||||
|
.quill-sig-wrapper .ql-container { border-color: var(--border-secondary) !important; }
|
||||||
|
.quill-sig-wrapper .ql-editor { color: var(--text-primary) !important; background: var(--bg-input) !important; min-height: 160px; }
|
||||||
|
.quill-sig-wrapper .ql-editor.ql-blank::before { color: var(--text-muted) !important; font-style: normal !important; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MailPage() {
|
||||||
|
const [entries, setEntries] = useState([]);
|
||||||
|
const [customers, setCustomers] = useState({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [activeTab, setActiveTab] = useState("inbound");
|
||||||
|
const [clientFilter, setClientFilter] = useState("all");
|
||||||
|
const [readFilter, setReadFilter] = useState("all");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [syncResult, setSyncResult] = useState(null);
|
||||||
|
|
||||||
|
// New-mail banner
|
||||||
|
const [newMailCount, setNewMailCount] = useState(0);
|
||||||
|
const [bannerDismissed, setBannerDismissed] = useState(false);
|
||||||
|
|
||||||
|
// Polling
|
||||||
|
const pollIntervalRef = useRef(null);
|
||||||
|
|
||||||
|
// Multi-select
|
||||||
|
const [selected, setSelected] = useState(new Set());
|
||||||
|
const [hoveredId, setHoveredId] = useState(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
// Modals
|
||||||
|
const [viewEntry, setViewEntry] = useState(null);
|
||||||
|
const [composeOpen, setComposeOpen] = useState(false);
|
||||||
|
const [composeTo, setComposeTo] = useState("");
|
||||||
|
const [composeFromAccount, setComposeFromAccount] = useState("");
|
||||||
|
const [mailboxFilter, setMailboxFilter] = useState("both");
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
|
||||||
|
const loadAll = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const [mailData, custsData] = await Promise.all([
|
||||||
|
api.get(`/crm/comms/email/all?limit=500&mailbox=${encodeURIComponent(mailboxFilter)}`),
|
||||||
|
api.get("/crm/customers"),
|
||||||
|
]);
|
||||||
|
setEntries(mailData.entries || []);
|
||||||
|
const map = {};
|
||||||
|
for (const c of custsData.customers || []) map[c.id] = c;
|
||||||
|
setCustomers(map);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [mailboxFilter]);
|
||||||
|
|
||||||
|
useEffect(() => { loadAll(); }, [loadAll]);
|
||||||
|
|
||||||
|
// Clear selection when tab changes
|
||||||
|
useEffect(() => { setSelected(new Set()); setReadFilter("all"); }, [activeTab]);
|
||||||
|
|
||||||
|
// Auto-poll: check for new emails on server
|
||||||
|
const startPolling = useCallback(() => {
|
||||||
|
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
|
||||||
|
const intervalMs = getPollInterval() * 1000;
|
||||||
|
pollIntervalRef.current = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.get("/crm/comms/email/check");
|
||||||
|
if (data.new_count > 0) {
|
||||||
|
setNewMailCount(data.new_count);
|
||||||
|
setBannerDismissed(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silently ignore poll errors
|
||||||
|
}
|
||||||
|
}, intervalMs);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
startPolling();
|
||||||
|
return () => { if (pollIntervalRef.current) clearInterval(pollIntervalRef.current); };
|
||||||
|
}, [startPolling]);
|
||||||
|
|
||||||
|
const syncEmails = async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
setSyncResult(null);
|
||||||
|
setNewMailCount(0);
|
||||||
|
setBannerDismissed(false);
|
||||||
|
try {
|
||||||
|
const data = await api.post("/crm/comms/email/sync", {});
|
||||||
|
setSyncResult(data);
|
||||||
|
await loadAll();
|
||||||
|
} catch (err) {
|
||||||
|
setSyncResult({ error: err.message });
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openReply = (toAddr, sourceAccount = "") => {
|
||||||
|
setViewEntry(null);
|
||||||
|
setComposeTo(toAddr || "");
|
||||||
|
setComposeFromAccount(sourceAccount || "");
|
||||||
|
setComposeOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelect = (id) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSelected = async () => {
|
||||||
|
if (selected.size === 0) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await api.post("/crm/comms/bulk-delete", { ids: [...selected] });
|
||||||
|
setSelected(new Set());
|
||||||
|
await loadAll();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleImportant = async (entry) => {
|
||||||
|
const newVal = !entry.is_important;
|
||||||
|
setEntries((prev) =>
|
||||||
|
prev.map((e) => e.id === entry.id ? { ...e, is_important: newVal } : e)
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await api.patch(`/crm/comms/${entry.id}/important`, { important: newVal });
|
||||||
|
} catch {
|
||||||
|
setEntries((prev) =>
|
||||||
|
prev.map((e) => e.id === entry.id ? { ...e, is_important: !newVal } : e)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEntry = async (entry) => {
|
||||||
|
setViewEntry(entry);
|
||||||
|
// Mark inbound as read if not already
|
||||||
|
if (entry.direction === "inbound" && !entry.is_read) {
|
||||||
|
setEntries((prev) =>
|
||||||
|
prev.map((e) => e.id === entry.id ? { ...e, is_read: true } : e)
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await api.patch(`/crm/comms/${entry.id}/read`, { read: true });
|
||||||
|
} catch {
|
||||||
|
// non-critical — don't revert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
const customerEmailMap = useMemo(() => {
|
||||||
|
const map = {};
|
||||||
|
Object.values(customers).forEach((c) => {
|
||||||
|
(c.contacts || []).forEach((ct) => {
|
||||||
|
if (ct?.type === "email" && ct?.value) map[String(ct.value).toLowerCase()] = c.id;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [customers]);
|
||||||
|
|
||||||
|
const resolveCustomerId = useCallback((entry) => {
|
||||||
|
if (entry.customer_id && customers[entry.customer_id]) return entry.customer_id;
|
||||||
|
const candidates = [];
|
||||||
|
if (entry.from_addr) candidates.push(String(entry.from_addr).toLowerCase());
|
||||||
|
const toList = Array.isArray(entry.to_addrs) ? entry.to_addrs : (entry.to_addrs ? [entry.to_addrs] : []);
|
||||||
|
toList.forEach((addr) => candidates.push(String(addr).toLowerCase()));
|
||||||
|
for (const addr of candidates) {
|
||||||
|
if (customerEmailMap[addr]) return customerEmailMap[addr];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [customerEmailMap, customers]);
|
||||||
|
|
||||||
|
const tabEntries = entries.filter((e) => e.direction === activeTab);
|
||||||
|
const clientFiltered = clientFilter === "clients"
|
||||||
|
? tabEntries.filter((e) => !!resolveCustomerId(e))
|
||||||
|
: tabEntries;
|
||||||
|
const readFiltered = readFilter === "unread"
|
||||||
|
? clientFiltered.filter((e) => !e.is_read)
|
||||||
|
: readFilter === "read"
|
||||||
|
? clientFiltered.filter((e) => !!e.is_read)
|
||||||
|
: readFilter === "important"
|
||||||
|
? clientFiltered.filter((e) => !!e.is_important)
|
||||||
|
: clientFiltered;
|
||||||
|
const filtered = readFiltered.filter((e) => {
|
||||||
|
if (!q) return true;
|
||||||
|
const custId = resolveCustomerId(e);
|
||||||
|
const cust = custId ? customers[custId] : null;
|
||||||
|
return (
|
||||||
|
(e.subject || "").toLowerCase().includes(q) ||
|
||||||
|
(e.body || "").toLowerCase().includes(q) ||
|
||||||
|
(e.from_addr || "").toLowerCase().includes(q) ||
|
||||||
|
(cust?.name || "").toLowerCase().includes(q) ||
|
||||||
|
(cust?.organization || "").toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const unreadInboundCount = entries.filter((e) => e.direction === "inbound" && !e.is_read).length;
|
||||||
|
const anySelected = selected.size > 0;
|
||||||
|
const showBanner = newMailCount > 0 && !bannerDismissed;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold" style={{ color: "var(--text-heading)" }}>Mail</h1>
|
||||||
|
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>All synced emails</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{syncResult && (
|
||||||
|
<span className="text-xs" style={{ color: syncResult.error ? "var(--danger-text)" : "var(--success-text)" }}>
|
||||||
|
{syncResult.error ? syncResult.error : `${syncResult.new_count} new email${syncResult.new_count !== 1 ? "s" : ""}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => { setComposeTo(""); setComposeFromAccount(""); setComposeOpen(true); }}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-90"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
✉ Compose
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={syncEmails}
|
||||||
|
disabled={syncing || loading}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
|
||||||
|
style={{ border: "1px solid", borderColor: "var(--border-primary)", color: "var(--text-secondary)", opacity: (syncing || loading) ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
{syncing ? "Syncing..." : "Sync Mail"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={loadAll}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
|
||||||
|
style={{ border: "1px solid", borderColor: "var(--border-primary)", color: "var(--text-secondary)", opacity: loading ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSettingsOpen(true)}
|
||||||
|
title="Mail Settings"
|
||||||
|
className="px-2 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
|
||||||
|
style={{ border: "1px solid", borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
⚙
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New-mail banner */}
|
||||||
|
{showBanner && (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-2.5 rounded-lg mb-4 text-sm"
|
||||||
|
style={{ backgroundColor: "color-mix(in srgb, var(--accent) 12%, var(--bg-card))", border: "1px solid var(--accent)", color: "var(--text-heading)" }}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span style={{ fontWeight: 600 }}>{newMailCount} new email{newMailCount !== 1 ? "s" : ""}</span> available on server
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={syncEmails}
|
||||||
|
disabled={syncing}
|
||||||
|
className="px-3 py-1 text-xs rounded-md cursor-pointer hover:opacity-90"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: syncing ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
{syncing ? "Syncing..." : "Sync Now"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setBannerDismissed(true)}
|
||||||
|
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)", fontSize: 18, lineHeight: 1, padding: "0 2px" }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs + Filters + Search + Bulk actions */}
|
||||||
|
<div className="flex items-center gap-3 mb-4 flex-wrap">
|
||||||
|
{/* Direction Tabs */}
|
||||||
|
<div className="flex rounded-lg overflow-hidden" style={{ border: "1px solid var(--border-primary)" }}>
|
||||||
|
{TABS.map((tab) => {
|
||||||
|
const count = entries.filter((e) => e.direction === tab).length;
|
||||||
|
const active = tab === activeTab;
|
||||||
|
const unread = tab === "inbound" ? unreadInboundCount : 0;
|
||||||
|
const color = tab === "inbound" ? FILTER_COLORS.inbound : FILTER_COLORS.outbound;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
className="px-4 py-1.5 text-sm cursor-pointer capitalize"
|
||||||
|
style={segmentedButtonStyle(
|
||||||
|
active,
|
||||||
|
color,
|
||||||
|
tab === "inbound" ? "1px solid var(--border-primary)" : "none",
|
||||||
|
color
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab}{" "}
|
||||||
|
<span style={{ opacity: 0.7, fontSize: 11 }}>({count})</span>
|
||||||
|
{unread > 0 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
||||||
|
backgroundColor: active ? "rgba(255,255,255,0.25)" : "var(--accent)",
|
||||||
|
color: active ? "var(--text-white)" : "#fff",
|
||||||
|
borderRadius: 10, fontSize: 10, fontWeight: 700,
|
||||||
|
minWidth: 16, height: 16, padding: "0 4px", marginLeft: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{unread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Client filter */}
|
||||||
|
<div className="flex rounded-lg overflow-hidden" style={{ border: "1px solid var(--border-primary)" }}>
|
||||||
|
{[["all", "All Messages"], ["clients", "Clients Only"]].map(([val, label]) => {
|
||||||
|
const active = clientFilter === val;
|
||||||
|
const color = val === "all" ? FILTER_COLORS.all_messages : FILTER_COLORS.clients_only;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={val}
|
||||||
|
onClick={() => setClientFilter(val)}
|
||||||
|
className="px-4 py-1.5 text-sm cursor-pointer"
|
||||||
|
style={segmentedButtonStyle(active, color, val === "all" ? "1px solid var(--border-primary)" : "none")}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex rounded-lg overflow-hidden" style={{ border: "1px solid var(--border-primary)" }}>
|
||||||
|
{MAILBOX_TABS.map((tab) => {
|
||||||
|
const active = mailboxFilter === tab;
|
||||||
|
const label = tab === "both" ? "ALL" : tab[0].toUpperCase() + tab.slice(1);
|
||||||
|
const color = tab === "sales" ? FILTER_COLORS.sales : tab === "support" ? FILTER_COLORS.support : FILTER_COLORS.mailbox_all;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => setMailboxFilter(tab)}
|
||||||
|
className="px-4 py-1.5 text-sm cursor-pointer"
|
||||||
|
style={segmentedButtonStyle(active, color, tab !== "both" ? "1px solid var(--border-primary)" : "none")}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Read status filter */}
|
||||||
|
<div className="flex rounded-lg overflow-hidden" style={{ border: "1px solid var(--border-primary)" }}>
|
||||||
|
{READ_FILTER_TABS.map((val) => {
|
||||||
|
const active = readFilter === val;
|
||||||
|
const label = val === "all" ? "All" : val === "unread" ? "Unread" : val === "read" ? "Read" : "Importants";
|
||||||
|
const color = val === "all"
|
||||||
|
? FILTER_COLORS.read_all
|
||||||
|
: val === "unread"
|
||||||
|
? FILTER_COLORS.unread
|
||||||
|
: val === "read"
|
||||||
|
? FILTER_COLORS.read
|
||||||
|
: FILTER_COLORS.important;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={val}
|
||||||
|
onClick={() => setReadFilter(val)}
|
||||||
|
className="px-4 py-1.5 text-sm cursor-pointer"
|
||||||
|
style={segmentedButtonStyle(active, color, val !== "important" ? "1px solid var(--border-primary)" : "none")}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div style={{ flex: "1 1 320px", minWidth: 220, maxWidth: 800, display: "flex", gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search subject, sender, customer..."
|
||||||
|
className="px-3 text-sm rounded-md border"
|
||||||
|
style={{
|
||||||
|
height: 34,
|
||||||
|
width: "100%",
|
||||||
|
minWidth: 220,
|
||||||
|
backgroundColor: "var(--bg-input)",
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch("")}
|
||||||
|
className="px-3 text-sm rounded-md cursor-pointer hover:opacity-80"
|
||||||
|
style={{ height: 34, color: "var(--danger-text)", backgroundColor: "var(--danger-bg)", borderRadius: 6 }}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk delete */}
|
||||||
|
{anySelected && (
|
||||||
|
<button
|
||||||
|
onClick={deleteSelected}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80 ml-auto"
|
||||||
|
style={{ backgroundColor: "var(--danger)", color: "#fff", opacity: deleting ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
{deleting ? "Deleting..." : `Delete ${selected.size} selected`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12" style={{ color: "var(--text-muted)" }}>Loading...</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="rounded-lg p-10 text-center text-sm border" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}>
|
||||||
|
{tabEntries.length === 0 ? `No ${activeTab} emails yet.` : "No emails match your search."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs mb-3" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{filtered.length} email{filtered.length !== 1 ? "s" : ""}
|
||||||
|
{anySelected && <span style={{ color: "var(--accent)", marginLeft: 8 }}>{selected.size} selected</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
|
{filtered.map((entry, idx) => {
|
||||||
|
const resolvedCustomerId = resolveCustomerId(entry);
|
||||||
|
const customer = resolvedCustomerId ? customers[resolvedCustomerId] : null;
|
||||||
|
const addrLine = activeTab === "inbound"
|
||||||
|
? (entry.from_addr || "")
|
||||||
|
: (Array.isArray(entry.to_addrs) ? entry.to_addrs[0] : (entry.to_addrs || ""));
|
||||||
|
const isSelected = selected.has(entry.id);
|
||||||
|
const isKnownCustomer = !!customer;
|
||||||
|
const isUnread = entry.direction === "inbound" && !entry.is_read;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className={idx > 0 ? "border-t" : ""}
|
||||||
|
style={{ borderColor: "var(--border-secondary)" }}
|
||||||
|
onMouseEnter={() => setHoveredId(entry.id)}
|
||||||
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 px-3 py-3 cursor-pointer"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isSelected
|
||||||
|
? "color-mix(in srgb, var(--btn-primary) 10%, var(--bg-card))"
|
||||||
|
: isUnread
|
||||||
|
? "color-mix(in srgb, var(--accent) 5%, var(--bg-card))"
|
||||||
|
: "var(--bg-card)",
|
||||||
|
transition: "background-color 0.1s",
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (anySelected) toggleSelect(entry.id);
|
||||||
|
else openEntry(entry);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Unread dot */}
|
||||||
|
<div style={{ width: 8, flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
{isUnread && (
|
||||||
|
<span style={{ width: 7, height: 7, borderRadius: "50%", backgroundColor: "var(--accent)", display: "block", flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bookmark */}
|
||||||
|
<BookmarkButton important={entry.is_important} onClick={() => toggleImportant(entry)} />
|
||||||
|
|
||||||
|
{/* Identity column */}
|
||||||
|
<div style={{ width: ID_COL_W, flexShrink: 0, minWidth: 0, overflow: "hidden" }}>
|
||||||
|
{isKnownCustomer ? (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to={`/crm/customers/${resolvedCustomerId}`}
|
||||||
|
className="hover:underline block text-xs leading-tight"
|
||||||
|
style={{ color: "var(--accent)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: isUnread ? 700 : 500 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{customer.name}
|
||||||
|
</Link>
|
||||||
|
{customer.organization && (
|
||||||
|
<span
|
||||||
|
className="block text-xs leading-tight"
|
||||||
|
style={{ color: "var(--accent)", opacity: 0.75, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
{customer.organization}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="block text-xs leading-tight"
|
||||||
|
style={{ color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: isUnread ? 700 : 500 }}
|
||||||
|
>
|
||||||
|
{addrLine}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subject + preview */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="text-sm"
|
||||||
|
style={{ color: "var(--text-heading)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", display: "block", fontWeight: isUnread ? 700 : 400 }}
|
||||||
|
>
|
||||||
|
{entry.subject || <span style={{ color: "var(--text-muted)", fontStyle: "italic", fontWeight: 400 }}>(no subject)</span>}
|
||||||
|
</span>
|
||||||
|
{Array.isArray(entry.attachments) && entry.attachments.length > 0 && (
|
||||||
|
<span className="text-xs flex-shrink-0" style={{ color: "var(--text-muted)" }}>
|
||||||
|
📎 {entry.attachments.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{entry.body && (
|
||||||
|
<p
|
||||||
|
className="text-xs mt-0.5"
|
||||||
|
style={{ color: "var(--text-muted)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
{entry.body}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date + checkbox + chevron */}
|
||||||
|
<div className="flex-shrink-0 text-right flex items-center gap-2" style={{ minWidth: 80 }}>
|
||||||
|
<span
|
||||||
|
className="text-xs"
|
||||||
|
title={entry.occurred_at ? new Date(entry.occurred_at).toLocaleString() : ""}
|
||||||
|
style={{ color: "var(--text-muted)", cursor: "default", fontWeight: isUnread ? 600 : 400 }}
|
||||||
|
>
|
||||||
|
{relativeTime(entry.occurred_at)}
|
||||||
|
</span>
|
||||||
|
<div style={{ width: 20, flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
{(anySelected || hoveredId === entry.id) ? (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => toggleSelect(entry.id)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ cursor: "pointer", accentColor: "var(--btn-primary)", width: 14, height: 14 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>›</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hover CSS for star visibility */}
|
||||||
|
<style>{`
|
||||||
|
.row-star { opacity: 0.35; transition: opacity 0.15s, color 0.15s; }
|
||||||
|
div:hover > div > .row-star { opacity: 0.6; }
|
||||||
|
.row-star[title="Mark as normal"] { opacity: 1 !important; color: #f59e0b !important; }
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<MailViewModal
|
||||||
|
open={!!viewEntry}
|
||||||
|
onClose={() => setViewEntry(null)}
|
||||||
|
entry={viewEntry}
|
||||||
|
customer={viewEntry ? customers[resolveCustomerId(viewEntry)] : null}
|
||||||
|
onReply={openReply}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ComposeEmailModal
|
||||||
|
open={composeOpen}
|
||||||
|
onClose={() => setComposeOpen(false)}
|
||||||
|
defaultTo={composeTo}
|
||||||
|
defaultFromAccount={composeFromAccount}
|
||||||
|
requireFromAccount={true}
|
||||||
|
onSent={() => loadAll()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingsModal
|
||||||
|
open={settingsOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setSettingsOpen(false);
|
||||||
|
startPolling(); // restart poll with potentially new interval
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
293
frontend/src/crm/orders/OrderDetail.jsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import api from "../../api/client";
|
||||||
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
draft: { bg: "var(--bg-card-hover)", color: "var(--text-secondary)" },
|
||||||
|
confirmed: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
|
||||||
|
in_production: { bg: "#fff7ed", color: "#9a3412" },
|
||||||
|
shipped: { bg: "#f5f3ff", color: "#6d28d9" },
|
||||||
|
delivered: { bg: "var(--success-bg)", color: "var(--success-text)" },
|
||||||
|
cancelled: { bg: "var(--danger-bg)", color: "var(--danger-text)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAYMENT_COLORS = {
|
||||||
|
pending: { bg: "#fef9c3", color: "#854d0e" },
|
||||||
|
partial: { bg: "#fff7ed", color: "#9a3412" },
|
||||||
|
paid: { bg: "var(--success-bg)", color: "var(--success-text)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle = {
|
||||||
|
display: "block",
|
||||||
|
marginBottom: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
fontWeight: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
function ReadField({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={labelStyle}>{label}</div>
|
||||||
|
<div className="text-sm" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{value || <span style={{ color: "var(--text-muted)" }}>—</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionCard({ title, children }) {
|
||||||
|
return (
|
||||||
|
<div className="ui-section-card mb-4">
|
||||||
|
<h2 className="ui-section-card__header-title mb-4">{title}</h2>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrderDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
const canEdit = hasPermission("crm", "edit");
|
||||||
|
|
||||||
|
const [order, setOrder] = useState(null);
|
||||||
|
const [customer, setCustomer] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get(`/crm/orders/${id}`)
|
||||||
|
.then((data) => {
|
||||||
|
setOrder(data);
|
||||||
|
if (data.customer_id) {
|
||||||
|
api.get(`/crm/customers/${data.customer_id}`)
|
||||||
|
.then(setCustomer)
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!order) return null;
|
||||||
|
|
||||||
|
const statusStyle = STATUS_COLORS[order.status] || STATUS_COLORS.draft;
|
||||||
|
const payStyle = PAYMENT_COLORS[order.payment_status] || PAYMENT_COLORS.pending;
|
||||||
|
const shipping = order.shipping || {};
|
||||||
|
|
||||||
|
const subtotal = (order.items || []).reduce((sum, item) => {
|
||||||
|
return sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const discount = order.discount || {};
|
||||||
|
const discountAmount =
|
||||||
|
discount.type === "percentage"
|
||||||
|
? subtotal * ((Number(discount.value) || 0) / 100)
|
||||||
|
: Number(discount.value) || 0;
|
||||||
|
|
||||||
|
const total = Number(order.total_price || 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 900 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<h1 className="text-2xl font-bold font-mono" style={{ color: "var(--text-heading)" }}>
|
||||||
|
{order.order_number}
|
||||||
|
</h1>
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 text-xs rounded-full capitalize"
|
||||||
|
style={{ backgroundColor: statusStyle.bg, color: statusStyle.color }}
|
||||||
|
>
|
||||||
|
{(order.status || "draft").replace("_", " ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{customer ? (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/crm/customers/${customer.id}`)}
|
||||||
|
className="text-sm hover:underline"
|
||||||
|
style={{ color: "var(--accent)", background: "none", border: "none", cursor: "pointer", padding: 0 }}
|
||||||
|
>
|
||||||
|
{customer.organization ? `${customer.name} / ${customer.organization}` : customer.name} ↗
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm" style={{ color: "var(--text-muted)" }}>{order.customer_id}</span>
|
||||||
|
)}
|
||||||
|
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Created {order.created_at ? new Date(order.created_at).toLocaleDateString() : "—"}
|
||||||
|
{order.updated_at && order.updated_at !== order.created_at && (
|
||||||
|
<span> · Updated {new Date(order.updated_at).toLocaleDateString()}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{customer && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/crm/customers/${customer.id}`)}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md border cursor-pointer hover:opacity-80"
|
||||||
|
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Back to Customer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/crm/orders/${id}/edit`)}
|
||||||
|
className="px-4 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-90"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<SectionCard title="Items">
|
||||||
|
{(order.items || []).length === 0 ? (
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-muted)" }}>No items.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: "1px solid var(--border-primary)" }}>
|
||||||
|
<th className="pb-2 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Item</th>
|
||||||
|
<th className="pb-2 text-right font-medium" style={{ color: "var(--text-secondary)" }}>Qty</th>
|
||||||
|
<th className="pb-2 text-right font-medium" style={{ color: "var(--text-secondary)" }}>Unit Price</th>
|
||||||
|
<th className="pb-2 text-right font-medium" style={{ color: "var(--text-secondary)" }}>Line Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{order.items.map((item, idx) => {
|
||||||
|
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||||
|
const label =
|
||||||
|
item.type === "product"
|
||||||
|
? item.product_name || item.product_id || "Product"
|
||||||
|
: item.type === "console_device"
|
||||||
|
? `${item.device_id || ""}${item.label ? ` (${item.label})` : ""}`
|
||||||
|
: item.description || "—";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={idx}
|
||||||
|
style={{ borderBottom: idx < order.items.length - 1 ? "1px solid var(--border-secondary)" : "none" }}
|
||||||
|
>
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>{label}</span>
|
||||||
|
<span className="ml-2 text-xs px-1.5 py-0.5 rounded-full" style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-muted)" }}>
|
||||||
|
{item.type.replace("_", " ")}
|
||||||
|
</span>
|
||||||
|
{Array.isArray(item.serial_numbers) && item.serial_numbers.length > 0 && (
|
||||||
|
<div className="text-xs mt-0.5 font-mono" style={{ color: "var(--text-muted)" }}>
|
||||||
|
SN: {item.serial_numbers.join(", ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right" style={{ color: "var(--text-primary)" }}>{item.quantity}</td>
|
||||||
|
<td className="py-2 text-right" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{order.currency} {Number(item.unit_price || 0).toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right font-medium" style={{ color: "var(--text-heading)" }}>
|
||||||
|
{order.currency} {lineTotal.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Pricing Summary */}
|
||||||
|
<SectionCard title="Pricing">
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8, maxWidth: 300, marginLeft: "auto" }}>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Subtotal</span>
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>{order.currency} {subtotal.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
{discountAmount > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>
|
||||||
|
Discount
|
||||||
|
{discount.type === "percentage" && ` (${discount.value}%)`}
|
||||||
|
{discount.reason && ` — ${discount.reason}`}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "var(--danger-text)" }}>−{order.currency} {discountAmount.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="flex justify-between text-sm font-semibold pt-2"
|
||||||
|
style={{ borderTop: "1px solid var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--text-heading)" }}>Total</span>
|
||||||
|
<span style={{ color: "var(--text-heading)" }}>{order.currency} {total.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Payment */}
|
||||||
|
<SectionCard title="Payment">
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
|
||||||
|
<div>
|
||||||
|
<div style={labelStyle}>Payment Status</div>
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 text-xs rounded-full capitalize"
|
||||||
|
style={{ backgroundColor: payStyle.bg, color: payStyle.color }}
|
||||||
|
>
|
||||||
|
{order.payment_status || "pending"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={labelStyle}>Invoice</div>
|
||||||
|
{order.invoice_path ? (
|
||||||
|
<span className="text-sm font-mono" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{order.invoice_path}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "var(--text-muted)", fontSize: 14 }}>—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Shipping */}
|
||||||
|
<SectionCard title="Shipping">
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16 }}>
|
||||||
|
<ReadField label="Method" value={shipping.method} />
|
||||||
|
<ReadField label="Carrier" value={shipping.carrier} />
|
||||||
|
<ReadField label="Tracking Number" value={shipping.tracking_number} />
|
||||||
|
<ReadField label="Destination" value={shipping.destination} />
|
||||||
|
<ReadField
|
||||||
|
label="Shipped At"
|
||||||
|
value={shipping.shipped_at ? new Date(shipping.shipped_at).toLocaleDateString() : null}
|
||||||
|
/>
|
||||||
|
<ReadField
|
||||||
|
label="Delivered At"
|
||||||
|
value={shipping.delivered_at ? new Date(shipping.delivered_at).toLocaleDateString() : null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{order.notes && (
|
||||||
|
<SectionCard title="Notes">
|
||||||
|
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--text-primary)" }}>{order.notes}</p>
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
662
frontend/src/crm/orders/OrderForm.jsx
Normal file
@@ -0,0 +1,662 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
import api from "../../api/client";
|
||||||
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
|
|
||||||
|
const ORDER_STATUSES = ["draft", "confirmed", "in_production", "shipped", "delivered", "cancelled"];
|
||||||
|
const PAYMENT_STATUSES = ["pending", "partial", "paid"];
|
||||||
|
const CURRENCIES = ["EUR", "USD", "GBP"];
|
||||||
|
const ITEM_TYPES = ["product", "console_device", "freetext"];
|
||||||
|
|
||||||
|
const inputClass = "w-full px-3 py-2 text-sm rounded-md border";
|
||||||
|
const inputStyle = {
|
||||||
|
backgroundColor: "var(--bg-input)",
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
};
|
||||||
|
const labelStyle = {
|
||||||
|
display: "block",
|
||||||
|
marginBottom: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
fontWeight: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
function Field({ label, children, style }) {
|
||||||
|
return (
|
||||||
|
<div style={style}>
|
||||||
|
<label style={labelStyle}>{label}</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionCard({ title, children }) {
|
||||||
|
return (
|
||||||
|
<div className="ui-section-card mb-4">
|
||||||
|
<h2 className="ui-section-card__header-title mb-4">{title}</h2>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyItem = () => ({
|
||||||
|
type: "product",
|
||||||
|
product_id: "",
|
||||||
|
product_name: "",
|
||||||
|
description: "",
|
||||||
|
device_id: "",
|
||||||
|
label: "",
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: 0,
|
||||||
|
serial_numbers: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function OrderForm() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const isEdit = Boolean(id);
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
const canEdit = hasPermission("crm", "edit");
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
customer_id: searchParams.get("customer_id") || "",
|
||||||
|
order_number: "",
|
||||||
|
status: "draft",
|
||||||
|
currency: "EUR",
|
||||||
|
items: [],
|
||||||
|
discount: { type: "percentage", value: 0, reason: "" },
|
||||||
|
payment_status: "pending",
|
||||||
|
invoice_path: "",
|
||||||
|
shipping: {
|
||||||
|
method: "",
|
||||||
|
carrier: "",
|
||||||
|
tracking_number: "",
|
||||||
|
destination: "",
|
||||||
|
shipped_at: "",
|
||||||
|
delivered_at: "",
|
||||||
|
},
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [customers, setCustomers] = useState([]);
|
||||||
|
const [products, setProducts] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(isEdit);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [customerSearch, setCustomerSearch] = useState("");
|
||||||
|
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
|
||||||
|
|
||||||
|
// Load customers and products
|
||||||
|
useEffect(() => {
|
||||||
|
api.get("/crm/customers").then((d) => setCustomers(d.customers || [])).catch(() => {});
|
||||||
|
api.get("/crm/products").then((d) => setProducts(d.products || [])).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load order for edit
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEdit) return;
|
||||||
|
api.get(`/crm/orders/${id}`)
|
||||||
|
.then((data) => {
|
||||||
|
const shipping = data.shipping || {};
|
||||||
|
setForm({
|
||||||
|
customer_id: data.customer_id || "",
|
||||||
|
order_number: data.order_number || "",
|
||||||
|
status: data.status || "draft",
|
||||||
|
currency: data.currency || "EUR",
|
||||||
|
items: (data.items || []).map((item) => ({
|
||||||
|
...emptyItem(),
|
||||||
|
...item,
|
||||||
|
serial_numbers: Array.isArray(item.serial_numbers)
|
||||||
|
? item.serial_numbers.join(", ")
|
||||||
|
: item.serial_numbers || "",
|
||||||
|
})),
|
||||||
|
discount: data.discount || { type: "percentage", value: 0, reason: "" },
|
||||||
|
payment_status: data.payment_status || "pending",
|
||||||
|
invoice_path: data.invoice_path || "",
|
||||||
|
shipping: {
|
||||||
|
method: shipping.method || "",
|
||||||
|
carrier: shipping.carrier || "",
|
||||||
|
tracking_number: shipping.tracking_number || "",
|
||||||
|
destination: shipping.destination || "",
|
||||||
|
shipped_at: shipping.shipped_at ? shipping.shipped_at.slice(0, 10) : "",
|
||||||
|
delivered_at: shipping.delivered_at ? shipping.delivered_at.slice(0, 10) : "",
|
||||||
|
},
|
||||||
|
notes: data.notes || "",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id, isEdit]);
|
||||||
|
|
||||||
|
// Set customer search label when customer_id loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (form.customer_id && customers.length > 0) {
|
||||||
|
const c = customers.find((x) => x.id === form.customer_id);
|
||||||
|
if (c) setCustomerSearch(c.organization ? `${c.name} / ${c.organization}` : c.name);
|
||||||
|
}
|
||||||
|
}, [form.customer_id, customers]);
|
||||||
|
|
||||||
|
const setField = (key, value) => setForm((f) => ({ ...f, [key]: value }));
|
||||||
|
const setShipping = (key, value) => setForm((f) => ({ ...f, shipping: { ...f.shipping, [key]: value } }));
|
||||||
|
const setDiscount = (key, value) => setForm((f) => ({ ...f, discount: { ...f.discount, [key]: value } }));
|
||||||
|
|
||||||
|
const addItem = () => setForm((f) => ({ ...f, items: [...f.items, emptyItem()] }));
|
||||||
|
const removeItem = (idx) => setForm((f) => ({ ...f, items: f.items.filter((_, i) => i !== idx) }));
|
||||||
|
const setItem = (idx, key, value) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
items: f.items.map((item, i) => (i === idx ? { ...item, [key]: value } : item)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const onProductSelect = (idx, productId) => {
|
||||||
|
const product = products.find((p) => p.id === productId);
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
items: f.items.map((item, i) =>
|
||||||
|
i === idx
|
||||||
|
? { ...item, product_id: productId, product_name: product?.name || "", unit_price: product?.price || 0 }
|
||||||
|
: item
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Computed pricing
|
||||||
|
const subtotal = form.items.reduce((sum, item) => {
|
||||||
|
return sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const discountAmount =
|
||||||
|
form.discount.type === "percentage"
|
||||||
|
? subtotal * ((Number(form.discount.value) || 0) / 100)
|
||||||
|
: Number(form.discount.value) || 0;
|
||||||
|
|
||||||
|
const total = Math.max(0, subtotal - discountAmount);
|
||||||
|
|
||||||
|
const filteredCustomers = customerSearch
|
||||||
|
? customers.filter((c) => {
|
||||||
|
const q = customerSearch.toLowerCase();
|
||||||
|
return c.name.toLowerCase().includes(q) || (c.organization || "").toLowerCase().includes(q);
|
||||||
|
})
|
||||||
|
: customers.slice(0, 20);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.customer_id) { setError("Please select a customer."); return; }
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
customer_id: form.customer_id,
|
||||||
|
order_number: form.order_number || undefined,
|
||||||
|
status: form.status,
|
||||||
|
currency: form.currency,
|
||||||
|
items: form.items.map((item) => ({
|
||||||
|
type: item.type,
|
||||||
|
product_id: item.product_id || null,
|
||||||
|
product_name: item.product_name || null,
|
||||||
|
description: item.description || null,
|
||||||
|
device_id: item.device_id || null,
|
||||||
|
label: item.label || null,
|
||||||
|
quantity: Number(item.quantity) || 1,
|
||||||
|
unit_price: Number(item.unit_price) || 0,
|
||||||
|
serial_numbers: item.serial_numbers
|
||||||
|
? item.serial_numbers.split(",").map((s) => s.trim()).filter(Boolean)
|
||||||
|
: [],
|
||||||
|
})),
|
||||||
|
subtotal,
|
||||||
|
discount: {
|
||||||
|
type: form.discount.type,
|
||||||
|
value: Number(form.discount.value) || 0,
|
||||||
|
reason: form.discount.reason || "",
|
||||||
|
},
|
||||||
|
total_price: total,
|
||||||
|
payment_status: form.payment_status,
|
||||||
|
invoice_path: form.invoice_path || "",
|
||||||
|
shipping: {
|
||||||
|
method: form.shipping.method || "",
|
||||||
|
carrier: form.shipping.carrier || "",
|
||||||
|
tracking_number: form.shipping.tracking_number || "",
|
||||||
|
destination: form.shipping.destination || "",
|
||||||
|
shipped_at: form.shipping.shipped_at || null,
|
||||||
|
delivered_at: form.shipping.delivered_at || null,
|
||||||
|
},
|
||||||
|
notes: form.notes || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit) {
|
||||||
|
await api.put(`/crm/orders/${id}`, payload);
|
||||||
|
navigate(`/crm/orders/${id}`);
|
||||||
|
} else {
|
||||||
|
const result = await api.post("/crm/orders", payload);
|
||||||
|
navigate(`/crm/orders/${result.id}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!window.confirm("Delete this order? This cannot be undone.")) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/crm/orders/${id}`);
|
||||||
|
navigate("/crm/orders");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canEdit) {
|
||||||
|
return <div className="text-sm p-3" style={{ color: "var(--danger-text)" }}>No permission to edit orders.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 900 }}>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
|
||||||
|
{isEdit ? "Edit Order" : "New Order"}
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(isEdit ? `/crm/orders/${id}` : "/crm/orders")}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md border cursor-pointer hover:opacity-80"
|
||||||
|
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="text-sm rounded-md p-3 mb-4 border"
|
||||||
|
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 1. Customer */}
|
||||||
|
<SectionCard title="Customer">
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<label style={labelStyle}>Customer *</label>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Search by name or organization..."
|
||||||
|
value={customerSearch}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCustomerSearch(e.target.value);
|
||||||
|
setShowCustomerDropdown(true);
|
||||||
|
if (!e.target.value) setField("customer_id", "");
|
||||||
|
}}
|
||||||
|
onFocus={() => setShowCustomerDropdown(true)}
|
||||||
|
onBlur={() => setTimeout(() => setShowCustomerDropdown(false), 150)}
|
||||||
|
/>
|
||||||
|
{showCustomerDropdown && filteredCustomers.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="absolute z-10 w-full mt-1 rounded-md border shadow-lg"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", maxHeight: 200, overflowY: "auto" }}
|
||||||
|
>
|
||||||
|
{filteredCustomers.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className="px-3 py-2 text-sm cursor-pointer hover:opacity-80"
|
||||||
|
style={{ color: "var(--text-primary)", borderBottom: "1px solid var(--border-secondary)" }}
|
||||||
|
onMouseDown={() => {
|
||||||
|
setField("customer_id", c.id);
|
||||||
|
setCustomerSearch(c.organization ? `${c.name} / ${c.organization}` : c.name);
|
||||||
|
setShowCustomerDropdown(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--text-heading)" }}>{c.name}</span>
|
||||||
|
{c.organization && (
|
||||||
|
<span className="ml-2 text-xs" style={{ color: "var(--text-muted)" }}>{c.organization}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* 2. Order Info */}
|
||||||
|
<SectionCard title="Order Info">
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16 }}>
|
||||||
|
<Field label="Order Number">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Auto-generated if empty"
|
||||||
|
value={form.order_number}
|
||||||
|
onChange={(e) => setField("order_number", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Status">
|
||||||
|
<select className={inputClass} style={inputStyle} value={form.status}
|
||||||
|
onChange={(e) => setField("status", e.target.value)}>
|
||||||
|
{ORDER_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>{s.replace("_", " ")}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Currency">
|
||||||
|
<select className={inputClass} style={inputStyle} value={form.currency}
|
||||||
|
onChange={(e) => setField("currency", e.target.value)}>
|
||||||
|
{CURRENCIES.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* 3. Items */}
|
||||||
|
<SectionCard title="Items">
|
||||||
|
{form.items.length === 0 ? (
|
||||||
|
<p className="text-sm mb-4" style={{ color: "var(--text-muted)" }}>No items yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
{form.items.map((item, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="rounded-md border p-4"
|
||||||
|
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-secondary)" }}
|
||||||
|
>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "140px 1fr 100px 120px auto", gap: 12, alignItems: "end" }}>
|
||||||
|
<Field label="Type">
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
value={item.type}
|
||||||
|
onChange={(e) => setItem(idx, "type", e.target.value)}
|
||||||
|
>
|
||||||
|
{ITEM_TYPES.map((t) => <option key={t} value={t}>{t.replace("_", " ")}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{item.type === "product" && (
|
||||||
|
<Field label="Product">
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
value={item.product_id}
|
||||||
|
onChange={(e) => onProductSelect(idx, e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select product...</option>
|
||||||
|
{products.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.type === "console_device" && (
|
||||||
|
<Field label="Device ID + Label">
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Device UID"
|
||||||
|
value={item.device_id}
|
||||||
|
onChange={(e) => setItem(idx, "device_id", e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Label"
|
||||||
|
value={item.label}
|
||||||
|
onChange={(e) => setItem(idx, "label", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.type === "freetext" && (
|
||||||
|
<Field label="Description">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Description..."
|
||||||
|
value={item.description}
|
||||||
|
onChange={(e) => setItem(idx, "description", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field label="Qty">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={(e) => setItem(idx, "quantity", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Unit Price">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
value={item.unit_price}
|
||||||
|
onChange={(e) => setItem(idx, "unit_price", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div style={{ paddingBottom: 2 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeItem(idx)}
|
||||||
|
className="px-3 py-2 text-sm rounded-md border cursor-pointer hover:opacity-80"
|
||||||
|
style={{ borderColor: "var(--danger)", color: "var(--danger-text)", whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<Field label="Serial Numbers (comma-separated)">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="SN001, SN002..."
|
||||||
|
value={item.serial_numbers}
|
||||||
|
onChange={(e) => setItem(idx, "serial_numbers", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addItem}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md border cursor-pointer hover:opacity-80"
|
||||||
|
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
+ Add Item
|
||||||
|
</button>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* 4. Pricing */}
|
||||||
|
<SectionCard title="Pricing">
|
||||||
|
<div className="flex gap-8 mb-4">
|
||||||
|
<div>
|
||||||
|
<div style={labelStyle}>Subtotal</div>
|
||||||
|
<div className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
|
||||||
|
{form.currency} {subtotal.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={labelStyle}>Total</div>
|
||||||
|
<div className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
|
||||||
|
{form.currency} {total.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "140px 160px 1fr", gap: 16 }}>
|
||||||
|
<Field label="Discount Type">
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.discount.type}
|
||||||
|
onChange={(e) => setDiscount("type", e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="percentage">Percentage (%)</option>
|
||||||
|
<option value="fixed">Fixed Amount</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label={form.discount.type === "percentage" ? "Discount %" : "Discount Amount"}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.discount.value}
|
||||||
|
onChange={(e) => setDiscount("value", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Discount Reason">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Optional reason..."
|
||||||
|
value={form.discount.reason}
|
||||||
|
onChange={(e) => setDiscount("reason", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{Number(form.discount.value) > 0 && (
|
||||||
|
<p className="text-xs mt-2" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Discount: {form.currency} {discountAmount.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* 5. Payment */}
|
||||||
|
<SectionCard title="Payment">
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
|
||||||
|
<Field label="Payment Status">
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.payment_status}
|
||||||
|
onChange={(e) => setField("payment_status", e.target.value)}
|
||||||
|
>
|
||||||
|
{PAYMENT_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Invoice Path (Nextcloud)">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="05_Customers/FOLDER/invoice.pdf"
|
||||||
|
value={form.invoice_path}
|
||||||
|
onChange={(e) => setField("invoice_path", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* 6. Shipping */}
|
||||||
|
<SectionCard title="Shipping">
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16, marginBottom: 16 }}>
|
||||||
|
<Field label="Method">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="e.g. courier, pickup"
|
||||||
|
value={form.shipping.method}
|
||||||
|
onChange={(e) => setShipping("method", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Carrier">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="e.g. ACS, DHL"
|
||||||
|
value={form.shipping.carrier}
|
||||||
|
onChange={(e) => setShipping("carrier", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Tracking Number">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.shipping.tracking_number}
|
||||||
|
onChange={(e) => setShipping("tracking_number", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Destination" style={{ gridColumn: "1 / -1" }}>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="City, Country"
|
||||||
|
value={form.shipping.destination}
|
||||||
|
onChange={(e) => setShipping("destination", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Shipped At">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.shipping.shipped_at}
|
||||||
|
onChange={(e) => setShipping("shipped_at", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Delivered At">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.shipping.delivered_at}
|
||||||
|
onChange={(e) => setShipping("delivered_at", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* 7. Notes */}
|
||||||
|
<SectionCard title="Notes">
|
||||||
|
<textarea
|
||||||
|
className={inputClass}
|
||||||
|
style={{ ...inputStyle, resize: "vertical", minHeight: 100 }}
|
||||||
|
placeholder="Internal notes..."
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setField("notes", e.target.value)}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-5 py-2 text-sm rounded-md cursor-pointer hover:opacity-90"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: saving ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : isEdit ? "Save Changes" : "Create Order"}
|
||||||
|
</button>
|
||||||
|
{isEdit && (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border cursor-pointer hover:opacity-80"
|
||||||
|
style={{ borderColor: "var(--danger)", color: "var(--danger-text)" }}
|
||||||
|
>
|
||||||
|
Delete Order
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
frontend/src/crm/orders/OrderList.jsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import api from "../../api/client";
|
||||||
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
draft: { bg: "var(--bg-card-hover)", color: "var(--text-secondary)" },
|
||||||
|
confirmed: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
|
||||||
|
in_production: { bg: "#fff7ed", color: "#9a3412" },
|
||||||
|
shipped: { bg: "#f5f3ff", color: "#6d28d9" },
|
||||||
|
delivered: { bg: "var(--success-bg)", color: "var(--success-text)" },
|
||||||
|
cancelled: { bg: "var(--danger-bg)", color: "var(--danger-text)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAYMENT_COLORS = {
|
||||||
|
pending: { bg: "#fef9c3", color: "#854d0e" },
|
||||||
|
partial: { bg: "#fff7ed", color: "#9a3412" },
|
||||||
|
paid: { bg: "var(--success-bg)", color: "var(--success-text)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
backgroundColor: "var(--bg-input)",
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ORDER_STATUSES = ["draft", "confirmed", "in_production", "shipped", "delivered", "cancelled"];
|
||||||
|
const PAYMENT_STATUSES = ["pending", "partial", "paid"];
|
||||||
|
|
||||||
|
export default function OrderList() {
|
||||||
|
const [orders, setOrders] = useState([]);
|
||||||
|
const [customerMap, setCustomerMap] = useState({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
|
const [paymentFilter, setPaymentFilter] = useState("");
|
||||||
|
const [hoveredRow, setHoveredRow] = useState(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
const canEdit = hasPermission("crm", "edit");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get("/crm/customers")
|
||||||
|
.then((data) => {
|
||||||
|
const map = {};
|
||||||
|
(data.customers || []).forEach((c) => { map[c.id] = c; });
|
||||||
|
setCustomerMap(map);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchOrders = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (statusFilter) params.set("status", statusFilter);
|
||||||
|
if (paymentFilter) params.set("payment_status", paymentFilter);
|
||||||
|
const qs = params.toString();
|
||||||
|
const data = await api.get(`/crm/orders${qs ? `?${qs}` : ""}`);
|
||||||
|
setOrders(data.orders || []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchOrders();
|
||||||
|
}, [statusFilter, paymentFilter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Orders</h1>
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/crm/orders/new")}
|
||||||
|
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-colors cursor-pointer"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
New Order
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mb-4">
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 text-sm rounded-md border"
|
||||||
|
style={inputStyle}
|
||||||
|
>
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
{ORDER_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>{s.replace("_", " ")}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={paymentFilter}
|
||||||
|
onChange={(e) => setPaymentFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 text-sm rounded-md border"
|
||||||
|
style={inputStyle}
|
||||||
|
>
|
||||||
|
<option value="">All Payment Statuses</option>
|
||||||
|
{PAYMENT_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="text-sm rounded-md p-3 mb-4 border"
|
||||||
|
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
|
||||||
|
) : orders.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="rounded-lg p-8 text-center text-sm border"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
No orders found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="rounded-lg overflow-hidden border"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Order #</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Customer</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Status</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Total</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Payment</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{orders.map((o, index) => {
|
||||||
|
const statusStyle = STATUS_COLORS[o.status] || STATUS_COLORS.draft;
|
||||||
|
const payStyle = PAYMENT_COLORS[o.payment_status] || PAYMENT_COLORS.pending;
|
||||||
|
const customer = customerMap[o.customer_id];
|
||||||
|
const customerName = customer
|
||||||
|
? customer.organization
|
||||||
|
? `${customer.name} / ${customer.organization}`
|
||||||
|
: customer.name
|
||||||
|
: o.customer_id || "—";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={o.id}
|
||||||
|
onClick={() => navigate(`/crm/orders/${o.id}`)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
style={{
|
||||||
|
borderBottom: index < orders.length - 1 ? "1px solid var(--border-secondary)" : "none",
|
||||||
|
backgroundColor: hoveredRow === o.id ? "var(--bg-card-hover)" : "transparent",
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHoveredRow(o.id)}
|
||||||
|
onMouseLeave={() => setHoveredRow(null)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs font-medium" style={{ color: "var(--text-heading)" }}>
|
||||||
|
{o.order_number}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{customerName}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 text-xs rounded-full capitalize"
|
||||||
|
style={{ backgroundColor: statusStyle.bg, color: statusStyle.color }}
|
||||||
|
>
|
||||||
|
{(o.status || "draft").replace("_", " ")}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{o.currency || "EUR"} {Number(o.total_price || 0).toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 text-xs rounded-full capitalize"
|
||||||
|
style={{ backgroundColor: payStyle.bg, color: payStyle.color }}
|
||||||
|
>
|
||||||
|
{o.payment_status || "pending"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{o.created_at ? new Date(o.created_at).toLocaleDateString() : "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
frontend/src/crm/orders/index.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as OrderList } from "./OrderList";
|
||||||
|
export { default as OrderForm } from "./OrderForm";
|
||||||
|
export { default as OrderDetail } from "./OrderDetail";
|
||||||
635
frontend/src/crm/products/ProductForm.jsx
Normal file
@@ -0,0 +1,635 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import api from "../../api/client";
|
||||||
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
|
|
||||||
|
const CATEGORY_LABELS = {
|
||||||
|
controller: "Controller",
|
||||||
|
striker: "Striker",
|
||||||
|
clock: "Clock",
|
||||||
|
part: "Part",
|
||||||
|
repair_service: "Repair / Service",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORIES = Object.keys(CATEGORY_LABELS);
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: "active", label: "Active", activeColor: "#31ee76", activeBg: "#14532d", inactiveBg: "var(--bg-input)", inactiveColor: "var(--text-secondary)" },
|
||||||
|
{ value: "discontinued", label: "Discontinued", activeColor: "#ef4444", activeBg: "#450a0a", inactiveBg: "var(--bg-input)", inactiveColor: "var(--text-secondary)" },
|
||||||
|
{ value: "planned", label: "Planned", activeColor: "#f59e0b", activeBg: "#451a03", inactiveBg: "var(--bg-input)", inactiveColor: "var(--text-secondary)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultForm = {
|
||||||
|
name: "",
|
||||||
|
sku: "",
|
||||||
|
category: "controller",
|
||||||
|
description: "",
|
||||||
|
price: "",
|
||||||
|
currency: "EUR",
|
||||||
|
status: "active",
|
||||||
|
costs: {
|
||||||
|
labor_hours: "",
|
||||||
|
labor_rate: "",
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
stock: {
|
||||||
|
on_hand: "",
|
||||||
|
reserved: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function numOr(v, fallback = 0) {
|
||||||
|
const n = parseFloat(v);
|
||||||
|
return isNaN(n) ? fallback : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeCostsTotal(costs, priceField = "price_last") {
|
||||||
|
const labor = numOr(costs.labor_hours) * numOr(costs.labor_rate);
|
||||||
|
const itemsTotal = (costs.items || []).reduce(
|
||||||
|
(sum, it) => sum + numOr(it.quantity, 1) * numOr(it[priceField] || it.price_last || it.price),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return labor + itemsTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass = "w-full px-3 py-2 text-sm rounded-md border";
|
||||||
|
const inputStyle = {
|
||||||
|
backgroundColor: "var(--bg-input)",
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
function Field({ label, children }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="ui-form-label">{label}</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionCard({ title, children, style }) {
|
||||||
|
return (
|
||||||
|
<div className="ui-section-card" style={style}>
|
||||||
|
{title && (
|
||||||
|
<div className="ui-section-card__title-row">
|
||||||
|
<h2 className="ui-section-card__header-title">{title}</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductForm() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const isEdit = Boolean(id);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
const canEdit = hasPermission("crm", "edit");
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
const [form, setForm] = useState(defaultForm);
|
||||||
|
const [loading, setLoading] = useState(isEdit);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [photoPreview, setPhotoPreview] = useState(null); // local blob URL for preview
|
||||||
|
const [photoFile, setPhotoFile] = useState(null); // File object pending upload
|
||||||
|
const [uploadingPhoto, setUploadingPhoto] = useState(false);
|
||||||
|
const [existingPhotoUrl, setExistingPhotoUrl] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEdit) return;
|
||||||
|
api.get(`/crm/products/${id}`)
|
||||||
|
.then((data) => {
|
||||||
|
setForm({
|
||||||
|
name: data.name || "",
|
||||||
|
sku: data.sku || "",
|
||||||
|
category: data.category || "controller",
|
||||||
|
description: data.description || "",
|
||||||
|
price: data.price != null ? String(data.price) : "",
|
||||||
|
currency: data.currency || "EUR",
|
||||||
|
status: data.status || (data.active !== false ? "active" : "discontinued"),
|
||||||
|
costs: {
|
||||||
|
labor_hours: data.costs?.labor_hours != null ? String(data.costs.labor_hours) : "",
|
||||||
|
labor_rate: data.costs?.labor_rate != null ? String(data.costs.labor_rate) : "",
|
||||||
|
items: (data.costs?.items || []).map((it) => ({
|
||||||
|
name: it.name || "",
|
||||||
|
quantity: String(it.quantity ?? 1),
|
||||||
|
price_last: String(it.price_last ?? it.price ?? ""),
|
||||||
|
price_min: String(it.price_min ?? ""),
|
||||||
|
price_max: String(it.price_max ?? ""),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
stock: {
|
||||||
|
on_hand: data.stock?.on_hand != null ? String(data.stock.on_hand) : "",
|
||||||
|
reserved: data.stock?.reserved != null ? String(data.stock.reserved) : "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (data.photo_url) {
|
||||||
|
setExistingPhotoUrl(`/api${data.photo_url}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id, isEdit]);
|
||||||
|
|
||||||
|
const set = (field, value) => setForm((f) => ({ ...f, [field]: value }));
|
||||||
|
const setCost = (field, value) => setForm((f) => ({ ...f, costs: { ...f.costs, [field]: value } }));
|
||||||
|
const setStock = (field, value) => setForm((f) => ({ ...f, stock: { ...f.stock, [field]: value } }));
|
||||||
|
|
||||||
|
const addCostItem = () =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
costs: { ...f.costs, items: [...f.costs.items, { name: "", quantity: "1", price_last: "", price_min: "", price_max: "" }] },
|
||||||
|
}));
|
||||||
|
const removeCostItem = (i) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
costs: { ...f.costs, items: f.costs.items.filter((_, idx) => idx !== i) },
|
||||||
|
}));
|
||||||
|
const setCostItem = (i, field, value) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
costs: {
|
||||||
|
...f.costs,
|
||||||
|
items: f.costs.items.map((it, idx) => idx === i ? { ...it, [field]: value } : it),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function handlePhotoChange(e) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setPhotoFile(file);
|
||||||
|
setPhotoPreview(URL.createObjectURL(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildPayload = () => ({
|
||||||
|
name: form.name.trim(),
|
||||||
|
sku: form.sku.trim() || null,
|
||||||
|
category: form.category,
|
||||||
|
description: form.description.trim() || null,
|
||||||
|
price: form.price !== "" ? parseFloat(form.price) : null,
|
||||||
|
currency: form.currency,
|
||||||
|
status: form.status,
|
||||||
|
active: form.status === "active",
|
||||||
|
costs: {
|
||||||
|
labor_hours: numOr(form.costs.labor_hours),
|
||||||
|
labor_rate: numOr(form.costs.labor_rate),
|
||||||
|
items: form.costs.items
|
||||||
|
.filter((it) => it.name.trim())
|
||||||
|
.map((it) => ({
|
||||||
|
name: it.name.trim(),
|
||||||
|
quantity: numOr(it.quantity, 1),
|
||||||
|
price: numOr(it.price_last || it.price),
|
||||||
|
price_last: numOr(it.price_last || it.price),
|
||||||
|
price_min: numOr(it.price_min),
|
||||||
|
price_max: numOr(it.price_max),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
stock: {
|
||||||
|
on_hand: parseInt(form.stock.on_hand, 10) || 0,
|
||||||
|
reserved: parseInt(form.stock.reserved, 10) || 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
setError("Product name is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
let savedProduct;
|
||||||
|
if (isEdit) {
|
||||||
|
savedProduct = await api.put(`/crm/products/${id}`, buildPayload());
|
||||||
|
} else {
|
||||||
|
savedProduct = await api.post("/crm/products", buildPayload());
|
||||||
|
}
|
||||||
|
// Upload photo if a new one was selected
|
||||||
|
if (photoFile && savedProduct?.id) {
|
||||||
|
setUploadingPhoto(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", photoFile);
|
||||||
|
await fetch(`/api/crm/products/${savedProduct.id}/photo`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
navigate("/crm/products");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
setUploadingPhoto(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api.delete(`/crm/products/${id}`);
|
||||||
|
navigate("/crm/products");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const costsTotalEst = computeCostsTotal(form.costs, "price_last");
|
||||||
|
const costsTotalMin = computeCostsTotal(form.costs, "price_min");
|
||||||
|
const costsTotalMax = computeCostsTotal(form.costs, "price_max");
|
||||||
|
const price = parseFloat(form.price) || 0;
|
||||||
|
const marginEst = price - costsTotalEst;
|
||||||
|
const marginMin = price - costsTotalMax; // highest cost = lowest margin
|
||||||
|
const marginMax = price - costsTotalMin; // lowest cost = highest margin
|
||||||
|
const stockAvailable = (parseInt(form.stock.on_hand, 10) || 0) - (parseInt(form.stock.reserved, 10) || 0);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPhoto = photoPreview || existingPhotoUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 1300, margin: "0 auto" }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
|
||||||
|
{isEdit ? "Edit Product" : "New Product"}
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isEdit && canEdit && !showDeleteConfirm && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border cursor-pointer hover:opacity-80"
|
||||||
|
style={{ borderColor: "var(--danger)", color: "var(--danger)", backgroundColor: "transparent" }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isEdit && canEdit && showDeleteConfirm && (
|
||||||
|
<>
|
||||||
|
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>Are you sure?</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 text-sm rounded-md cursor-pointer hover:opacity-80"
|
||||||
|
style={{ backgroundColor: "var(--danger)", color: "#fff", opacity: saving ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{saving ? "Deleting..." : "Yes, Delete"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border cursor-pointer"
|
||||||
|
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!showDeleteConfirm && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/crm/products")}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border cursor-pointer"
|
||||||
|
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-colors cursor-pointer"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: saving ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{saving ? (uploadingPhoto ? "Uploading photo..." : "Saving...") : "Save"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="text-sm rounded-md p-3 mb-4 border"
|
||||||
|
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 2-column layout */}
|
||||||
|
<div style={{ display: "flex", gap: 20, alignItems: "flex-start" }}>
|
||||||
|
|
||||||
|
{/* LEFT column */}
|
||||||
|
<div style={{ flex: "0 0 460px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
|
|
||||||
|
{/* Product Details */}
|
||||||
|
<SectionCard title="Product Details">
|
||||||
|
{/* Photo upload */}
|
||||||
|
<div style={{ display: "flex", gap: 16, alignItems: "flex-start", marginBottom: 16 }}>
|
||||||
|
<div
|
||||||
|
onClick={() => canEdit && fileInputRef.current?.click()}
|
||||||
|
style={{
|
||||||
|
width: 120, height: 120, borderRadius: 10, border: "2px dashed var(--border-primary)",
|
||||||
|
backgroundColor: "var(--bg-primary)", display: "flex", alignItems: "center",
|
||||||
|
justifyContent: "center", overflow: "hidden", flexShrink: 0,
|
||||||
|
cursor: canEdit ? "pointer" : "default", position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentPhoto ? (
|
||||||
|
<img src={currentPhoto} alt="" style={{ width: "100%", height: "100%", objectFit: "contain" }} />
|
||||||
|
) : (
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
<div style={{ fontSize: 24, opacity: 0.3 }}>📷</div>
|
||||||
|
<div style={{ fontSize: 10, color: "var(--text-muted)", marginTop: 4 }}>Photo</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="px-3 py-1.5 text-xs rounded-md border cursor-pointer hover:opacity-80"
|
||||||
|
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
{currentPhoto ? "Change Photo" : "Upload Photo"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={handlePhotoChange}
|
||||||
|
/>
|
||||||
|
{photoFile && (
|
||||||
|
<div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 6 }}>
|
||||||
|
{photoFile.name} — will upload on save
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 6 }}>
|
||||||
|
JPG, PNG, or WebP. Stored on server.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name + SKU */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 14 }}>
|
||||||
|
<Field label="Name *">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => set("name", e.target.value)}
|
||||||
|
placeholder="e.g. Vesper Plus"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="SKU">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.sku}
|
||||||
|
onChange={(e) => set("sku", e.target.value)}
|
||||||
|
placeholder="e.g. VSP-001"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<Field label="Category">
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.category}
|
||||||
|
onChange={(e) => set("category", e.target.value)}
|
||||||
|
>
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<option key={c} value={c}>{CATEGORY_LABELS[c]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<Field label="Description">
|
||||||
|
<textarea
|
||||||
|
className={inputClass}
|
||||||
|
style={{ ...inputStyle, resize: "vertical", minHeight: 72 }}
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => set("description", e.target.value)}
|
||||||
|
placeholder="Optional product description..."
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status toggle — color coded */}
|
||||||
|
<div>
|
||||||
|
<div className="ui-form-label">Status</div>
|
||||||
|
<div style={{ display: "flex", borderRadius: 6, overflow: "hidden", border: "1px solid var(--border-primary)" }}>
|
||||||
|
{STATUS_OPTIONS.map((opt, idx) => {
|
||||||
|
const isActive = form.status === opt.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => canEdit && set("status", opt.value)}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: "7px 0", fontSize: 12, fontWeight: 600,
|
||||||
|
border: "none", cursor: canEdit ? "pointer" : "default",
|
||||||
|
backgroundColor: isActive ? opt.activeBg : opt.inactiveBg,
|
||||||
|
color: isActive ? opt.activeColor : opt.inactiveColor,
|
||||||
|
borderRight: idx < STATUS_OPTIONS.length - 1 ? "1px solid var(--border-primary)" : "none",
|
||||||
|
transition: "background-color 0.15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Stock */}
|
||||||
|
<SectionCard title="Stock">
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 14 }}>
|
||||||
|
<Field label="On Hand">
|
||||||
|
<input
|
||||||
|
type="number" min="0" step="1"
|
||||||
|
className={inputClass} style={inputStyle}
|
||||||
|
value={form.stock.on_hand}
|
||||||
|
onChange={(e) => setStock("on_hand", e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Reserved">
|
||||||
|
<input
|
||||||
|
type="number" min="0" step="1"
|
||||||
|
className={inputClass} style={inputStyle}
|
||||||
|
value={form.stock.reserved}
|
||||||
|
onChange={(e) => setStock("reserved", e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Available">
|
||||||
|
<div
|
||||||
|
className="px-3 py-2 text-sm rounded-md border"
|
||||||
|
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)", color: "var(--text-heading)", fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
{stockAvailable}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT column */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
|
|
||||||
|
{/* Pricing & Mfg. Costs */}
|
||||||
|
<SectionCard title="Pricing & Mfg. Costs">
|
||||||
|
{/* Price */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 14 }}>
|
||||||
|
<Field label="Price (EUR)">
|
||||||
|
<input
|
||||||
|
type="number" min="0" step="0.01"
|
||||||
|
className={inputClass} style={inputStyle}
|
||||||
|
value={form.price}
|
||||||
|
onChange={(e) => set("price", e.target.value)}
|
||||||
|
onBlur={(e) => { const v = parseFloat(e.target.value); if (!isNaN(v)) set("price", v.toFixed(2)); }}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Labor */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 14 }}>
|
||||||
|
<Field label="Labor Hours">
|
||||||
|
<input type="number" min="0" step="0.5" className={inputClass} style={inputStyle}
|
||||||
|
value={form.costs.labor_hours} onChange={(e) => setCost("labor_hours", e.target.value)} placeholder="0" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Labor Rate (€/hr)">
|
||||||
|
<input type="number" min="0" step="0.01" className={inputClass} style={inputStyle}
|
||||||
|
value={form.costs.labor_rate} onChange={(e) => setCost("labor_rate", e.target.value)} placeholder="0.00" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cost line items */}
|
||||||
|
{form.costs.items.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 72px 85px 85px 85px 28px", gap: 6, marginBottom: 4 }}>
|
||||||
|
<div className="ui-form-label" style={{ marginBottom: 0 }}>Item Name</div>
|
||||||
|
<div className="ui-form-label" style={{ marginBottom: 0, textAlign: "center" }}>QTY</div>
|
||||||
|
<div className="ui-form-label" style={{ marginBottom: 0, textAlign: "center" }}>Min (€)</div>
|
||||||
|
<div className="ui-form-label" style={{ marginBottom: 0, textAlign: "center" }}>Max (€)</div>
|
||||||
|
<div className="ui-form-label" style={{ marginBottom: 0, textAlign: "center" }}>Est. (€)</div>
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
{form.costs.items.map((it, i) => (
|
||||||
|
<div key={i} style={{ display: "grid", gridTemplateColumns: "1fr 72px 85px 85px 85px 28px", gap: 6, marginBottom: 6 }}>
|
||||||
|
<input className={inputClass} style={inputStyle} value={it.name}
|
||||||
|
onChange={(e) => setCostItem(i, "name", e.target.value)} placeholder="e.g. PCB" />
|
||||||
|
<input type="number" min="0" step="1" className={inputClass}
|
||||||
|
style={{ ...inputStyle, textAlign: "center" }}
|
||||||
|
value={it.quantity}
|
||||||
|
onChange={(e) => setCostItem(i, "quantity", e.target.value)}
|
||||||
|
onBlur={(e) => { const v = parseFloat(e.target.value); if (!isNaN(v)) setCostItem(i, "quantity", v.toFixed(2)); }}
|
||||||
|
placeholder="1" />
|
||||||
|
<input type="number" min="0" step="0.01" className={inputClass}
|
||||||
|
style={{ ...inputStyle, textAlign: "center" }}
|
||||||
|
value={it.price_min}
|
||||||
|
onChange={(e) => setCostItem(i, "price_min", e.target.value)}
|
||||||
|
onBlur={(e) => { const v = parseFloat(e.target.value); if (!isNaN(v)) setCostItem(i, "price_min", v.toFixed(2)); }}
|
||||||
|
placeholder="0.00" title="Minimum expected price" />
|
||||||
|
<input type="number" min="0" step="0.01" className={inputClass}
|
||||||
|
style={{ ...inputStyle, textAlign: "center" }}
|
||||||
|
value={it.price_max}
|
||||||
|
onChange={(e) => setCostItem(i, "price_max", e.target.value)}
|
||||||
|
onBlur={(e) => { const v = parseFloat(e.target.value); if (!isNaN(v)) setCostItem(i, "price_max", v.toFixed(2)); }}
|
||||||
|
placeholder="0.00" title="Maximum expected price" />
|
||||||
|
<input type="number" min="0" step="0.01" className={inputClass}
|
||||||
|
style={{ ...inputStyle, textAlign: "center" }}
|
||||||
|
value={it.price_last}
|
||||||
|
onChange={(e) => setCostItem(i, "price_last", e.target.value)}
|
||||||
|
onBlur={(e) => { const v = parseFloat(e.target.value); if (!isNaN(v)) setCostItem(i, "price_last", v.toFixed(2)); }}
|
||||||
|
placeholder="0.00" title="Estimated / last paid price" />
|
||||||
|
<button type="button" onClick={() => removeCostItem(i)}
|
||||||
|
className="flex items-center justify-center text-base cursor-pointer hover:opacity-70"
|
||||||
|
style={{ color: "var(--danger)", background: "none", border: "none", padding: 0 }}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="button" onClick={addCostItem}
|
||||||
|
className="px-3 py-1.5 text-xs rounded-md border cursor-pointer hover:opacity-80 mb-4"
|
||||||
|
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}>
|
||||||
|
+ Add Cost Item
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Summary rows */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
{/* Mfg. Cost Total — three values */}
|
||||||
|
<div className="px-3 py-2 rounded-md text-sm"
|
||||||
|
style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-secondary)" }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Mfg. Cost Total</span>
|
||||||
|
<div className="flex items-center gap-2 font-mono text-xs">
|
||||||
|
<span style={{ color: "var(--text-more-muted)" }} title="Min–Max cost range">
|
||||||
|
€{costsTotalMin.toFixed(2)} – €{costsTotalMax.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "var(--border-primary)" }}>|</span>
|
||||||
|
<span className="font-semibold" style={{ color: "var(--text-heading)" }} title="Estimated cost">
|
||||||
|
est. €{costsTotalEst.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Margin — three values */}
|
||||||
|
<div className="px-3 py-2 rounded-md text-sm"
|
||||||
|
style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-secondary)" }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Margin (Price − Cost)</span>
|
||||||
|
{form.price ? (
|
||||||
|
<div className="flex items-center gap-2 font-mono text-xs">
|
||||||
|
<span style={{ color: (marginMin >= 0 && marginMax >= 0) ? "var(--text-more-muted)" : "var(--danger-text)" }} title="Margin range (min–max cost)">
|
||||||
|
€{marginMax.toFixed(2)} – €{marginMin.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "var(--border-primary)" }}>|</span>
|
||||||
|
<span className="font-semibold" style={{ color: marginEst >= 0 ? "var(--success-text)" : "var(--danger-text)" }} title="Estimated margin">
|
||||||
|
est. €{marginEst.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="font-mono font-semibold" style={{ color: "var(--text-muted)" }}>—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
frontend/src/crm/products/ProductList.jsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import api from "../../api/client";
|
||||||
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
|
|
||||||
|
const CATEGORY_LABELS = {
|
||||||
|
controller: "Controller",
|
||||||
|
striker: "Striker",
|
||||||
|
clock: "Clock",
|
||||||
|
part: "Part",
|
||||||
|
repair_service: "Repair / Service",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORIES = Object.keys(CATEGORY_LABELS);
|
||||||
|
|
||||||
|
export default function ProductList() {
|
||||||
|
const [products, setProducts] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState("");
|
||||||
|
const [hoveredRow, setHoveredRow] = useState(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
const canEdit = hasPermission("crm", "edit");
|
||||||
|
|
||||||
|
const fetchProducts = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (categoryFilter) params.set("category", categoryFilter);
|
||||||
|
const qs = params.toString();
|
||||||
|
const data = await api.get(`/crm/products${qs ? `?${qs}` : ""}`);
|
||||||
|
setProducts(data.products);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProducts();
|
||||||
|
}, [categoryFilter]);
|
||||||
|
|
||||||
|
const filtered = search
|
||||||
|
? products.filter(
|
||||||
|
(p) =>
|
||||||
|
(p.name || "").toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
(p.sku || "").toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
: products;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
|
||||||
|
Products
|
||||||
|
</h1>
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/crm/products/new")}
|
||||||
|
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-colors cursor-pointer"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
New Product
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex gap-3 mb-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name or SKU..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="flex-1 px-3 py-2 text-sm rounded-md border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-input)",
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 text-sm rounded-md border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-input)",
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<option key={c} value={c}>{CATEGORY_LABELS[c]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="text-sm rounded-md p-3 mb-4 border"
|
||||||
|
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="rounded-lg p-8 text-center text-sm border"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
No products found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="rounded-lg overflow-hidden border"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
|
||||||
|
<th className="px-3 py-3" style={{ width: 48 }} />
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Name</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>SKU</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Category</th>
|
||||||
|
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--text-secondary)" }}>Price</th>
|
||||||
|
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--text-secondary)" }}>Mfg. Cost</th>
|
||||||
|
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--text-secondary)" }}>Margin</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Stock</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((p, index) => {
|
||||||
|
const price = p.price != null ? Number(p.price) : null;
|
||||||
|
const mfgCost = p.costs?.total != null ? Number(p.costs.total) : null;
|
||||||
|
const margin = price != null && mfgCost != null ? price - mfgCost : null;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => navigate(`/crm/products/${p.id}`)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
style={{
|
||||||
|
borderBottom: index < filtered.length - 1 ? "1px solid var(--border-primary)" : "none",
|
||||||
|
backgroundColor: hoveredRow === p.id ? "var(--bg-card-hover)" : "transparent",
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHoveredRow(p.id)}
|
||||||
|
onMouseLeave={() => setHoveredRow(null)}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2" style={{ width: 48 }}>
|
||||||
|
{p.photo_url ? (
|
||||||
|
<img
|
||||||
|
src={`/api${p.photo_url}`}
|
||||||
|
alt=""
|
||||||
|
style={{ width: 40, height: 40, borderRadius: 6, objectFit: "contain", border: "1px solid var(--border-primary)" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 6, backgroundColor: "var(--bg-card-hover)", border: "1px solid var(--border-primary)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
<span style={{ fontSize: 18, opacity: 0.4 }}>📦</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-medium" style={{ color: "var(--text-heading)" }}>{p.name}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs" style={{ color: "var(--text-muted)" }}>{p.sku || "—"}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 text-xs rounded-full"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
{CATEGORY_LABELS[p.category] || p.category}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{price != null ? `€${price.toFixed(2)}` : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{mfgCost != null ? `€${mfgCost.toFixed(2)}` : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right" style={{ color: margin != null ? (margin >= 0 ? "var(--success-text)" : "var(--danger-text)") : "var(--text-muted)" }}>
|
||||||
|
{margin != null ? `€${margin.toFixed(2)}` : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{p.stock ? p.stock.available : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 text-xs rounded-full"
|
||||||
|
style={
|
||||||
|
p.active
|
||||||
|
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
|
||||||
|
: { backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{p.active ? "Active" : "Inactive"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
frontend/src/crm/products/index.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as ProductList } from "./ProductList";
|
||||||
|
export { default as ProductForm } from "./ProductForm";
|
||||||
1070
frontend/src/crm/quotations/QuotationForm.jsx
Normal file
438
frontend/src/crm/quotations/QuotationList.jsx
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import api from "../../api/client";
|
||||||
|
|
||||||
|
const STATUS_STYLES = {
|
||||||
|
draft: { bg: "var(--bg-card-hover)", color: "var(--text-secondary)" },
|
||||||
|
sent: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
|
||||||
|
accepted: { bg: "var(--success-bg)", color: "var(--success-text)" },
|
||||||
|
rejected: { bg: "var(--danger-bg)", color: "var(--danger-text)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmt(n) {
|
||||||
|
const f = parseFloat(n) || 0;
|
||||||
|
return f.toLocaleString("el-GR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
return iso.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PDF thumbnail via PDF.js ──────────────────────────────────────────────────
|
||||||
|
function loadPdfJs() {
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
if (window.pdfjsLib) { res(); return; }
|
||||||
|
if (document.getElementById("__pdfjs2__")) {
|
||||||
|
// Script already injected — wait for it
|
||||||
|
const check = setInterval(() => {
|
||||||
|
if (window.pdfjsLib) { clearInterval(check); res(); }
|
||||||
|
}, 50);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const s = document.createElement("script");
|
||||||
|
s.id = "__pdfjs2__";
|
||||||
|
s.src = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js";
|
||||||
|
s.onload = () => {
|
||||||
|
window.pdfjsLib.GlobalWorkerOptions.workerSrc =
|
||||||
|
"https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
|
||||||
|
res();
|
||||||
|
};
|
||||||
|
s.onerror = rej;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function PdfThumbnail({ quotationId, onClick }) {
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await loadPdfJs();
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
const url = `/api/crm/quotations/${quotationId}/pdf`;
|
||||||
|
const loadingTask = window.pdfjsLib.getDocument({
|
||||||
|
url,
|
||||||
|
httpHeaders: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
const pdf = await loadingTask.promise;
|
||||||
|
if (cancelled) return;
|
||||||
|
const page = await pdf.getPage(1);
|
||||||
|
if (cancelled) return;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const viewport = page.getViewport({ scale: 1 });
|
||||||
|
const scale = Math.min(72 / viewport.width, 96 / viewport.height);
|
||||||
|
const scaled = page.getViewport({ scale });
|
||||||
|
canvas.width = scaled.width;
|
||||||
|
canvas.height = scaled.height;
|
||||||
|
await page.render({ canvasContext: canvas.getContext("2d"), viewport: scaled }).promise;
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setFailed(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [quotationId]);
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
width: 72,
|
||||||
|
height: 96,
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: "hidden",
|
||||||
|
flexShrink: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
border: "1px solid var(--border-primary)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: "var(--bg-primary)",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
return (
|
||||||
|
<div style={style} onClick={onClick} title="Open PDF">
|
||||||
|
<span style={{ fontSize: 28 }}>📑</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={style} onClick={onClick} title="Open PDF">
|
||||||
|
<canvas ref={canvasRef} style={{ width: "100%", height: "100%", objectFit: "contain", display: "block" }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraftThumbnail() {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: 72, height: 96, borderRadius: 4, flexShrink: 0,
|
||||||
|
border: "1px dashed var(--border-primary)",
|
||||||
|
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
|
||||||
|
backgroundColor: "var(--bg-primary)", gap: 4,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 18 }}>📄</span>
|
||||||
|
<span style={{ fontSize: 9, fontWeight: 700, color: "var(--text-muted)", letterSpacing: "0.06em", textTransform: "uppercase" }}>
|
||||||
|
DRAFT
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PdfViewModal({ quotationId, quotationNumber, onClose }) {
|
||||||
|
const [blobUrl, setBlobUrl] = useState(null);
|
||||||
|
const [loadingPdf, setLoadingPdf] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let objectUrl = null;
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
fetch(`/api/crm/quotations/${quotationId}/pdf`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
})
|
||||||
|
.then(r => {
|
||||||
|
if (!r.ok) throw new Error("Failed to load PDF");
|
||||||
|
return r.blob();
|
||||||
|
})
|
||||||
|
.then(blob => {
|
||||||
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
setBlobUrl(objectUrl);
|
||||||
|
})
|
||||||
|
.catch(() => setError(true))
|
||||||
|
.finally(() => setLoadingPdf(false));
|
||||||
|
return () => { if (objectUrl) URL.revokeObjectURL(objectUrl); };
|
||||||
|
}, [quotationId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 1000,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.88)",
|
||||||
|
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-card)", borderRadius: 10, overflow: "hidden",
|
||||||
|
width: "80vw", height: "88vh", display: "flex", flexDirection: "column",
|
||||||
|
boxShadow: "0 24px 64px rgba(0,0,0,0.5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
padding: "10px 16px", borderBottom: "1px solid var(--border-primary)", flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: "var(--text-heading)" }}>{quotationNumber}</span>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
{blobUrl && (
|
||||||
|
<a
|
||||||
|
href={blobUrl}
|
||||||
|
download={`${quotationNumber}.pdf`}
|
||||||
|
style={{ padding: "4px 12px", fontSize: 12, borderRadius: 6, textDecoration: "none", backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{ background: "none", border: "none", color: "var(--text-muted)", fontSize: 22, cursor: "pointer", lineHeight: 1, padding: "0 4px" }}
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
{loadingPdf && <span style={{ color: "var(--text-muted)", fontSize: 13 }}>Loading PDF...</span>}
|
||||||
|
{error && <span style={{ color: "var(--danger-text)", fontSize: 13 }}>Failed to load PDF.</span>}
|
||||||
|
{blobUrl && (
|
||||||
|
<iframe
|
||||||
|
src={blobUrl}
|
||||||
|
style={{ width: "100%", height: "100%", border: "none" }}
|
||||||
|
title={quotationNumber}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QuotationList({ customerId, onSend }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [quotations, setQuotations] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [deleting, setDeleting] = useState(null);
|
||||||
|
const [regenerating, setRegenerating] = useState(null);
|
||||||
|
const [pdfPreview, setPdfPreview] = useState(null); // { id, number }
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!customerId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/crm/quotations/customer/${customerId}`);
|
||||||
|
setQuotations(res.quotations || []);
|
||||||
|
} catch {
|
||||||
|
setQuotations([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [customerId]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
async function handleDelete(q) {
|
||||||
|
if (!window.confirm(`Delete quotation ${q.quotation_number}? This cannot be undone.`)) return;
|
||||||
|
setDeleting(q.id);
|
||||||
|
try {
|
||||||
|
await api.delete(`/crm/quotations/${q.id}`);
|
||||||
|
setQuotations(prev => prev.filter(x => x.id !== q.id));
|
||||||
|
} catch {
|
||||||
|
alert("Failed to delete quotation");
|
||||||
|
} finally {
|
||||||
|
setDeleting(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegenerate(q) {
|
||||||
|
setRegenerating(q.id);
|
||||||
|
try {
|
||||||
|
const updated = await api.post(`/crm/quotations/${q.id}/regenerate-pdf`);
|
||||||
|
setQuotations(prev => prev.map(x => x.id === updated.id ? {
|
||||||
|
...x,
|
||||||
|
nextcloud_pdf_url: updated.nextcloud_pdf_url,
|
||||||
|
} : x));
|
||||||
|
} catch {
|
||||||
|
alert("PDF regeneration failed");
|
||||||
|
} finally {
|
||||||
|
setRegenerating(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPdfModal(q) {
|
||||||
|
setPdfPreview({ id: q.id, number: q.quotation_number });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid columns: thumbnail | number | title | date | status | total | actions
|
||||||
|
const GRID = "90px 120px minmax(0,1fr) 130px 130px 130px 120px";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* PDF Preview Modal */}
|
||||||
|
{pdfPreview && (
|
||||||
|
<PdfViewModal
|
||||||
|
quotationId={pdfPreview.id}
|
||||||
|
quotationNumber={pdfPreview.number}
|
||||||
|
onClose={() => setPdfPreview(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
|
||||||
|
<h2 style={{ fontSize: 15, fontWeight: 600, color: "var(--text-heading)" }}>Quotations</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/crm/quotations/new?customer_id=${customerId}`)}
|
||||||
|
style={{
|
||||||
|
padding: "7px 16px", fontSize: 13, fontWeight: 600, borderRadius: 6,
|
||||||
|
border: "none", cursor: "pointer", backgroundColor: "var(--accent)", color: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ New Quotation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div style={{ textAlign: "center", padding: 40, color: "var(--text-muted)", fontSize: 13 }}>Loading...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && quotations.length === 0 && (
|
||||||
|
<div style={{ textAlign: "center", padding: "40px 20px", backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)", borderRadius: 8 }}>
|
||||||
|
<div style={{ fontSize: 32, marginBottom: 10 }}>📄</div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 500, color: "var(--text-heading)", marginBottom: 6 }}>No quotations yet</div>
|
||||||
|
<div style={{ fontSize: 13, color: "var(--text-muted)", marginBottom: 16 }}>
|
||||||
|
Create a quotation to generate a professional PDF offer for this customer.
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/crm/quotations/new?customer_id=${customerId}`)}
|
||||||
|
style={{ padding: "8px 20px", fontSize: 13, fontWeight: 600, borderRadius: 6, border: "none", cursor: "pointer", backgroundColor: "var(--accent)", color: "#fff" }}
|
||||||
|
>
|
||||||
|
+ New Quotation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && quotations.length > 0 && (
|
||||||
|
<div style={{ borderRadius: 8, border: "1px solid var(--border-primary)", overflow: "hidden" }}>
|
||||||
|
{/* Table header */}
|
||||||
|
<div style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: GRID,
|
||||||
|
backgroundColor: "var(--bg-card)",
|
||||||
|
borderBottom: "1px solid var(--border-primary)",
|
||||||
|
padding: "8px 16px",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
}}>
|
||||||
|
<div />
|
||||||
|
{[
|
||||||
|
{ label: "Number", align: "left" },
|
||||||
|
{ label: "Title", align: "left" },
|
||||||
|
{ label: "Date", align: "center" },
|
||||||
|
{ label: "Status", align: "center" },
|
||||||
|
{ label: "Total", align: "right", paddingRight: 16 },
|
||||||
|
{ label: "Actions", align: "center" },
|
||||||
|
].map(({ label, align, paddingRight }) => (
|
||||||
|
<div key={label} style={{ fontSize: 11, fontWeight: 600, color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.04em", textAlign: align, paddingRight }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rows */}
|
||||||
|
{quotations.map(q => (
|
||||||
|
<div
|
||||||
|
key={q.id}
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: GRID,
|
||||||
|
gap: 12,
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderBottom: "1px solid var(--border-secondary)",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: 110,
|
||||||
|
backgroundColor: "var(--bg-card)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.backgroundColor = "var(--bg-card)"}
|
||||||
|
>
|
||||||
|
{/* Thumbnail — click opens modal if PDF exists */}
|
||||||
|
<div>
|
||||||
|
{q.nextcloud_pdf_url ? (
|
||||||
|
<PdfThumbnail quotationId={q.id} onClick={() => openPdfModal(q)} />
|
||||||
|
) : (
|
||||||
|
<DraftThumbnail />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Number */}
|
||||||
|
<div style={{ fontFamily: "monospace", fontSize: 13, fontWeight: 600, color: "var(--text-primary)" }}>
|
||||||
|
{q.quotation_number}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title + subtitle */}
|
||||||
|
<div style={{ overflow: "hidden", paddingRight: 8 }}>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 600, color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{q.title || <span style={{ color: "var(--text-muted)", fontStyle: "italic", fontWeight: 400 }}>Untitled</span>}
|
||||||
|
</div>
|
||||||
|
{q.subtitle && (
|
||||||
|
<div style={{ fontSize: 12, color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", marginTop: 2 }}>
|
||||||
|
{q.subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<div style={{ fontSize: 12, color: "var(--text-secondary)", textAlign: "center" }}>
|
||||||
|
{fmtDate(q.created_at)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status badge */}
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
<span style={{
|
||||||
|
display: "inline-block", padding: "2px 10px", borderRadius: 20,
|
||||||
|
fontSize: 11, fontWeight: 600,
|
||||||
|
backgroundColor: STATUS_STYLES[q.status]?.bg || "var(--bg-card-hover)",
|
||||||
|
color: STATUS_STYLES[q.status]?.color || "var(--text-secondary)",
|
||||||
|
}}>
|
||||||
|
{q.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total */}
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 600, color: "var(--success-text)", textAlign: "right", paddingRight: 16 }}>
|
||||||
|
{fmt(q.final_total)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions — Edit + Delete same width; Gen PDF if no PDF yet */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 5, alignItems: "stretch", paddingLeft: 25, paddingRight: 25 }}>
|
||||||
|
{!q.nextcloud_pdf_url && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRegenerate(q)}
|
||||||
|
disabled={regenerating === q.id}
|
||||||
|
style={{ padding: "4px 10px", fontSize: 11, fontWeight: 500, borderRadius: 5, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--text-muted)", whiteSpace: "nowrap", textAlign: "center" }}
|
||||||
|
>
|
||||||
|
{regenerating === q.id ? "..." : "Gen PDF"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/crm/quotations/${q.id}`)}
|
||||||
|
style={{ padding: "4px 10px", fontSize: 11, fontWeight: 500, borderRadius: 5, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--text-secondary)", whiteSpace: "nowrap", textAlign: "center" }}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onSend && onSend(q)}
|
||||||
|
style={{ padding: "4px 10px", fontSize: 11, fontWeight: 500, borderRadius: 5, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--accent)", whiteSpace: "nowrap", textAlign: "center" }}
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(q)}
|
||||||
|
disabled={deleting === q.id}
|
||||||
|
style={{ padding: "4px 10px", fontSize: 11, fontWeight: 500, borderRadius: 5, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--danger-text)", whiteSpace: "nowrap", textAlign: "center" }}
|
||||||
|
>
|
||||||
|
{deleting === q.id ? "..." : "Delete"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
frontend/src/crm/quotations/index.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as QuotationForm } from "./QuotationForm";
|
||||||
|
export { default as QuotationList } from "./QuotationList";
|
||||||
1490
frontend/src/developer/ApiReferencePage.jsx
Normal file
@@ -1599,6 +1599,7 @@ export default function DeviceDetail() {
|
|||||||
const [liveStrikeCounters, setLiveStrikeCounters] = useState(null);
|
const [liveStrikeCounters, setLiveStrikeCounters] = useState(null);
|
||||||
const [requestingStrikeCounters, setRequestingStrikeCounters] = useState(false);
|
const [requestingStrikeCounters, setRequestingStrikeCounters] = useState(false);
|
||||||
const lastStrikeRequestAtRef = useRef(0);
|
const lastStrikeRequestAtRef = useRef(0);
|
||||||
|
const [hwProduct, setHwProduct] = useState(null);
|
||||||
|
|
||||||
// --- Section edit modal open/close state ---
|
// --- Section edit modal open/close state ---
|
||||||
const [editingLocation, setEditingLocation] = useState(false);
|
const [editingLocation, setEditingLocation] = useState(false);
|
||||||
@@ -1641,6 +1642,25 @@ export default function DeviceDetail() {
|
|||||||
setDeviceUsers([]);
|
setDeviceUsers([]);
|
||||||
}).finally(() => setUsersLoading(false));
|
}).finally(() => setUsersLoading(false));
|
||||||
|
|
||||||
|
// Fetch manufacturing record + product catalog to resolve hw image
|
||||||
|
if (d.device_id) {
|
||||||
|
Promise.all([
|
||||||
|
api.get(`/manufacturing/devices/${d.device_id}`).catch(() => null),
|
||||||
|
api.get("/crm/products").catch(() => null),
|
||||||
|
]).then(([mfgItem, productsRes]) => {
|
||||||
|
const hwType = mfgItem?.hw_type || "";
|
||||||
|
if (!hwType) return;
|
||||||
|
const products = productsRes?.products || [];
|
||||||
|
const norm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||||
|
const normHw = norm(hwType);
|
||||||
|
const match = products.find(
|
||||||
|
(p) => norm(p.name) === normHw || norm(p.sku) === normHw ||
|
||||||
|
norm(p.name).includes(normHw) || normHw.includes(norm(p.name))
|
||||||
|
);
|
||||||
|
if (match) setHwProduct(match);
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
api.get(`/equipment/notes?device_id=${id}`).then((data) => {
|
api.get(`/equipment/notes?device_id=${id}`).then((data) => {
|
||||||
const issues = (data.notes || []).filter(
|
const issues = (data.notes || []).filter(
|
||||||
(n) => (n.category === "issue" || n.category === "action_item") && n.status !== "completed"
|
(n) => (n.category === "issue" || n.category === "action_item") && n.status !== "completed"
|
||||||
@@ -1809,9 +1829,8 @@ export default function DeviceDetail() {
|
|||||||
: null;
|
: null;
|
||||||
const randomPlaybacks = playbackPlaceholderForId(id || device.device_id || device.id);
|
const randomPlaybacks = playbackPlaceholderForId(id || device.device_id || device.id);
|
||||||
|
|
||||||
const hwImageMap = { VesperPlus: "/devices/VesperPlus.png" };
|
const hwVariant = hwProduct?.name || "VesperPlus";
|
||||||
const hwVariant = "VesperPlus";
|
const hwImage = hwProduct?.photo_url ? `/api${hwProduct.photo_url}` : "/devices/VesperPlus.png";
|
||||||
const hwImage = hwImageMap[hwVariant] || hwImageMap.VesperPlus;
|
|
||||||
|
|
||||||
const locationCard = (
|
const locationCard = (
|
||||||
<section className="device-section-card">
|
<section className="device-section-card">
|
||||||
|
|||||||
@@ -250,16 +250,13 @@ export default function DeviceForm() {
|
|||||||
{/* ===== Left Column ===== */}
|
{/* ===== Left Column ===== */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* --- Basic Info --- */}
|
{/* --- Basic Info --- */}
|
||||||
<section
|
<section className="ui-section-card">
|
||||||
className="rounded-lg border p-6"
|
<div className="ui-section-card__title-row">
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
<h2 className="ui-section-card__header-title">Basic Information</h2>
|
||||||
>
|
</div>
|
||||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
|
|
||||||
Basic Information
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Device Name *
|
Device Name *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -271,7 +268,7 @@ export default function DeviceForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Location
|
Location
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -283,7 +280,7 @@ export default function DeviceForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Location Coordinates
|
Location Coordinates
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -295,7 +292,7 @@ export default function DeviceForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Device Photo URL
|
Device Photo URL
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -320,13 +317,10 @@ export default function DeviceForm() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* --- Device Attributes --- */}
|
{/* --- Device Attributes --- */}
|
||||||
<section
|
<section className="ui-section-card">
|
||||||
className="rounded-lg border p-6"
|
<div className="ui-section-card__title-row">
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
<h2 className="ui-section-card__header-title">Device Attributes</h2>
|
||||||
>
|
</div>
|
||||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
|
|
||||||
Device Attributes
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="flex flex-wrap gap-4 md:col-span-2">
|
<div className="flex flex-wrap gap-4 md:col-span-2">
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
@@ -385,7 +379,7 @@ export default function DeviceForm() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Total Bells
|
Total Bells
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -397,7 +391,7 @@ export default function DeviceForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Device Locale
|
Device Locale
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -413,7 +407,7 @@ export default function DeviceForm() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Bell Outputs (comma-separated)
|
Bell Outputs (comma-separated)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -425,7 +419,7 @@ export default function DeviceForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Hammer Timings (comma-separated)
|
Hammer Timings (comma-separated)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -437,7 +431,7 @@ export default function DeviceForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Serial Log Level
|
Serial Log Level
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -449,7 +443,7 @@ export default function DeviceForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
SD Log Level
|
SD Log Level
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -464,16 +458,13 @@ export default function DeviceForm() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* --- Network Settings --- */}
|
{/* --- Network Settings --- */}
|
||||||
<section
|
<section className="ui-section-card">
|
||||||
className="rounded-lg border p-6"
|
<div className="ui-section-card__title-row">
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
<h2 className="ui-section-card__header-title">Network Settings</h2>
|
||||||
>
|
</div>
|
||||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
|
|
||||||
Network Settings
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Hostname
|
Hostname
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -496,7 +487,7 @@ export default function DeviceForm() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
WebSocket URL
|
WebSocket URL
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -507,7 +498,7 @@ export default function DeviceForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Church Assistant URL
|
Church Assistant URL
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -524,16 +515,13 @@ export default function DeviceForm() {
|
|||||||
{/* ===== Right Column ===== */}
|
{/* ===== Right Column ===== */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* --- Subscription --- */}
|
{/* --- Subscription --- */}
|
||||||
<section
|
<section className="ui-section-card">
|
||||||
className="rounded-lg border p-6"
|
<div className="ui-section-card__title-row">
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
<h2 className="ui-section-card__header-title">Subscription</h2>
|
||||||
>
|
</div>
|
||||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
|
|
||||||
Subscription
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Tier
|
Tier
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -549,7 +537,7 @@ export default function DeviceForm() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Start Date
|
Start Date
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -560,7 +548,7 @@ export default function DeviceForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Duration (months)
|
Duration (months)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -572,7 +560,7 @@ export default function DeviceForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Max Users
|
Max Users
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -584,7 +572,7 @@ export default function DeviceForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Max Outputs
|
Max Outputs
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -599,16 +587,13 @@ export default function DeviceForm() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* --- Clock Settings --- */}
|
{/* --- Clock Settings --- */}
|
||||||
<section
|
<section className="ui-section-card">
|
||||||
className="rounded-lg border p-6"
|
<div className="ui-section-card__title-row">
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
<h2 className="ui-section-card__header-title">Clock Settings</h2>
|
||||||
>
|
</div>
|
||||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
|
|
||||||
Clock Settings
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Ring Alerts
|
Ring Alerts
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -624,7 +609,7 @@ export default function DeviceForm() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Ring Intervals
|
Ring Intervals
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -645,7 +630,7 @@ export default function DeviceForm() {
|
|||||||
<span className="text-sm" style={{ color: "var(--text-primary)" }}>Ring Alerts Master On</span>
|
<span className="text-sm" style={{ color: "var(--text-primary)" }}>Ring Alerts Master On</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Clock Outputs (comma-separated)
|
Clock Outputs (comma-separated)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -656,7 +641,7 @@ export default function DeviceForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Clock Timings (comma-separated)
|
Clock Timings (comma-separated)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -667,7 +652,7 @@ export default function DeviceForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Hour Alerts Bell
|
Hour Alerts Bell
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -679,7 +664,7 @@ export default function DeviceForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Half-hour Alerts Bell
|
Half-hour Alerts Bell
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -691,7 +676,7 @@ export default function DeviceForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Quarter Alerts Bell
|
Quarter Alerts Bell
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -703,7 +688,7 @@ export default function DeviceForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Backlight Output
|
Backlight Output
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -788,16 +773,13 @@ export default function DeviceForm() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* --- Statistics --- */}
|
{/* --- Statistics --- */}
|
||||||
<section
|
<section className="ui-section-card">
|
||||||
className="rounded-lg border p-6"
|
<div className="ui-section-card__title-row">
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
<h2 className="ui-section-card__header-title">Statistics & Warranty</h2>
|
||||||
>
|
</div>
|
||||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
|
|
||||||
Statistics & Warranty
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Total Playbacks
|
Total Playbacks
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -809,7 +791,7 @@ export default function DeviceForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Total Hammer Strikes
|
Total Hammer Strikes
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -821,7 +803,7 @@ export default function DeviceForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Total Warnings Given
|
Total Warnings Given
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -833,7 +815,7 @@ export default function DeviceForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Per-Bell Strikes (comma-separated)
|
Per-Bell Strikes (comma-separated)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -853,7 +835,7 @@ export default function DeviceForm() {
|
|||||||
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>Warranty Active</span>
|
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>Warranty Active</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Warranty Start
|
Warranty Start
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -864,7 +846,7 @@ export default function DeviceForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Warranty Period (months)
|
Warranty Period (months)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -876,7 +858,7 @@ export default function DeviceForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Last Maintained On
|
Last Maintained On
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -887,7 +869,7 @@ export default function DeviceForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Maintenance Period (months)
|
Maintenance Period (months)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -20,12 +20,7 @@ const categoryStyle = (cat) => {
|
|||||||
function Field({ label, children }) {
|
function Field({ label, children }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<dt
|
<dt className="ui-field-label">{label}</dt>
|
||||||
className="text-xs font-medium uppercase tracking-wide"
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm" style={{ color: "var(--text-primary)" }}>
|
<dd className="mt-1 text-sm" style={{ color: "var(--text-primary)" }}>
|
||||||
{children || "-"}
|
{children || "-"}
|
||||||
</dd>
|
</dd>
|
||||||
@@ -158,16 +153,10 @@ export default function NoteDetail() {
|
|||||||
{/* Left Column */}
|
{/* Left Column */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Note Content */}
|
{/* Note Content */}
|
||||||
<section
|
<section className="ui-section-card">
|
||||||
className="rounded-lg border p-6"
|
<div className="ui-section-card__title-row">
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
<h2 className="ui-section-card__header-title">Content</h2>
|
||||||
>
|
</div>
|
||||||
<h2
|
|
||||||
className="text-lg font-semibold mb-4"
|
|
||||||
style={{ color: "var(--text-heading)" }}
|
|
||||||
>
|
|
||||||
Content
|
|
||||||
</h2>
|
|
||||||
<div
|
<div
|
||||||
className="text-sm whitespace-pre-wrap"
|
className="text-sm whitespace-pre-wrap"
|
||||||
style={{ color: "var(--text-primary)" }}
|
style={{ color: "var(--text-primary)" }}
|
||||||
@@ -177,16 +166,10 @@ export default function NoteDetail() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Timestamps */}
|
{/* Timestamps */}
|
||||||
<section
|
<section className="ui-section-card">
|
||||||
className="rounded-lg border p-6"
|
<div className="ui-section-card__title-row">
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
<h2 className="ui-section-card__header-title">Details</h2>
|
||||||
>
|
</div>
|
||||||
<h2
|
|
||||||
className="text-lg font-semibold mb-4"
|
|
||||||
style={{ color: "var(--text-heading)" }}
|
|
||||||
>
|
|
||||||
Details
|
|
||||||
</h2>
|
|
||||||
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
<Field label="Category">
|
<Field label="Category">
|
||||||
<span
|
<span
|
||||||
@@ -211,16 +194,10 @@ export default function NoteDetail() {
|
|||||||
{/* Right Column */}
|
{/* Right Column */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Linked Device */}
|
{/* Linked Device */}
|
||||||
<section
|
<section className="ui-section-card">
|
||||||
className="rounded-lg border p-6"
|
<div className="ui-section-card__title-row">
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
<h2 className="ui-section-card__header-title">Linked Device</h2>
|
||||||
>
|
</div>
|
||||||
<h2
|
|
||||||
className="text-lg font-semibold mb-4"
|
|
||||||
style={{ color: "var(--text-heading)" }}
|
|
||||||
>
|
|
||||||
Linked Device
|
|
||||||
</h2>
|
|
||||||
{note.device_id ? (
|
{note.device_id ? (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -247,16 +224,10 @@ export default function NoteDetail() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Linked User */}
|
{/* Linked User */}
|
||||||
<section
|
<section className="ui-section-card">
|
||||||
className="rounded-lg border p-6"
|
<div className="ui-section-card__title-row">
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
<h2 className="ui-section-card__header-title">Linked User</h2>
|
||||||
>
|
</div>
|
||||||
<h2
|
|
||||||
className="text-lg font-semibold mb-4"
|
|
||||||
style={{ color: "var(--text-heading)" }}
|
|
||||||
>
|
|
||||||
Linked User
|
|
||||||
</h2>
|
|
||||||
{note.user_id ? (
|
{note.user_id ? (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -132,16 +132,13 @@ export default function NoteForm() {
|
|||||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||||
{/* Left Column — Note Content */}
|
{/* Left Column — Note Content */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section
|
<section className="ui-section-card">
|
||||||
className="rounded-lg border p-6"
|
<div className="ui-section-card__title-row">
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
<h2 className="ui-section-card__header-title">Note Details</h2>
|
||||||
>
|
</div>
|
||||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
|
|
||||||
Note Details
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Title *
|
Title *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -159,7 +156,7 @@ export default function NoteForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Content *
|
Content *
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -178,7 +175,7 @@ export default function NoteForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Category
|
Category
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -204,16 +201,13 @@ export default function NoteForm() {
|
|||||||
|
|
||||||
{/* Right Column — Associations */}
|
{/* Right Column — Associations */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section
|
<section className="ui-section-card">
|
||||||
className="rounded-lg border p-6"
|
<div className="ui-section-card__title-row">
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
<h2 className="ui-section-card__header-title">Link To</h2>
|
||||||
>
|
</div>
|
||||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
|
|
||||||
Link To
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Device (optional)
|
Device (optional)
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -235,7 +229,7 @@ export default function NoteForm() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
User (optional)
|
User (optional)
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ import { useAuth } from "../auth/AuthContext";
|
|||||||
import api from "../api/client";
|
import api from "../api/client";
|
||||||
|
|
||||||
const BOARD_TYPES = [
|
const BOARD_TYPES = [
|
||||||
{ value: "vs", label: "Vesper (VS)" },
|
{ value: "vesper", label: "Vesper" },
|
||||||
{ value: "vp", label: "Vesper+ (VP)" },
|
{ value: "vesper_plus", label: "Vesper+" },
|
||||||
{ value: "vx", label: "VesperPro (VX)" },
|
{ value: "vesper_pro", label: "Vesper Pro" },
|
||||||
|
{ value: "chronos", label: "Chronos" },
|
||||||
|
{ value: "chronos_pro", label: "Chronos Pro" },
|
||||||
|
{ value: "agnus", label: "Agnus" },
|
||||||
|
{ value: "agnus_mini", label: "Agnus Mini" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CHANNELS = ["stable", "beta", "alpha", "testing"];
|
const CHANNELS = ["stable", "beta", "alpha", "testing"];
|
||||||
@@ -29,6 +33,24 @@ function ChannelBadge({ channel }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UPDATE_TYPE_STYLES = {
|
||||||
|
mandatory: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)", label: "Mandatory" },
|
||||||
|
emergency: { bg: "var(--danger-bg)", color: "var(--danger-text)", label: "Emergency" },
|
||||||
|
optional: { bg: "var(--bg-card-hover)", color: "var(--text-muted)", label: "Optional" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function UpdateTypeBadge({ type }) {
|
||||||
|
const style = UPDATE_TYPE_STYLES[type] || UPDATE_TYPE_STYLES.optional;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 text-xs rounded-full font-medium"
|
||||||
|
style={{ backgroundColor: style.bg, color: style.color }}
|
||||||
|
>
|
||||||
|
{style.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
function formatBytes(bytes) {
|
||||||
if (!bytes) return "—";
|
if (!bytes) return "—";
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
@@ -61,9 +83,11 @@ export default function FirmwareManager() {
|
|||||||
const [channelFilter, setChannelFilter] = useState("");
|
const [channelFilter, setChannelFilter] = useState("");
|
||||||
|
|
||||||
const [showUpload, setShowUpload] = useState(false);
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
const [uploadHwType, setUploadHwType] = useState("vs");
|
const [uploadHwType, setUploadHwType] = useState("vesper");
|
||||||
const [uploadChannel, setUploadChannel] = useState("stable");
|
const [uploadChannel, setUploadChannel] = useState("stable");
|
||||||
const [uploadVersion, setUploadVersion] = useState("");
|
const [uploadVersion, setUploadVersion] = useState("");
|
||||||
|
const [uploadUpdateType, setUploadUpdateType] = useState("mandatory");
|
||||||
|
const [uploadMinFw, setUploadMinFw] = useState("");
|
||||||
const [uploadNotes, setUploadNotes] = useState("");
|
const [uploadNotes, setUploadNotes] = useState("");
|
||||||
const [uploadFile, setUploadFile] = useState(null);
|
const [uploadFile, setUploadFile] = useState(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
@@ -104,6 +128,8 @@ export default function FirmwareManager() {
|
|||||||
formData.append("hw_type", uploadHwType);
|
formData.append("hw_type", uploadHwType);
|
||||||
formData.append("channel", uploadChannel);
|
formData.append("channel", uploadChannel);
|
||||||
formData.append("version", uploadVersion);
|
formData.append("version", uploadVersion);
|
||||||
|
formData.append("update_type", uploadUpdateType);
|
||||||
|
if (uploadMinFw) formData.append("min_fw_version", uploadMinFw);
|
||||||
if (uploadNotes) formData.append("notes", uploadNotes);
|
if (uploadNotes) formData.append("notes", uploadNotes);
|
||||||
formData.append("file", uploadFile);
|
formData.append("file", uploadFile);
|
||||||
|
|
||||||
@@ -120,6 +146,8 @@ export default function FirmwareManager() {
|
|||||||
|
|
||||||
setShowUpload(false);
|
setShowUpload(false);
|
||||||
setUploadVersion("");
|
setUploadVersion("");
|
||||||
|
setUploadUpdateType("mandatory");
|
||||||
|
setUploadMinFw("");
|
||||||
setUploadNotes("");
|
setUploadNotes("");
|
||||||
setUploadFile(null);
|
setUploadFile(null);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
@@ -145,7 +173,7 @@ export default function FirmwareManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const BOARD_TYPE_LABELS = { vs: "Vesper", vp: "Vesper+", vx: "VesperPro" };
|
const BOARD_TYPE_LABELS = { vesper: "Vesper", vesper_plus: "Vesper+", vesper_pro: "Vesper Pro", chronos: "Chronos", chronos_pro: "Chronos Pro", agnus: "Agnus", agnus_mini: "Agnus Mini" };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -173,58 +201,52 @@ export default function FirmwareManager() {
|
|||||||
{/* Upload form */}
|
{/* Upload form */}
|
||||||
{showUpload && (
|
{showUpload && (
|
||||||
<div
|
<div
|
||||||
className="rounded-lg border p-5 mb-5"
|
className="ui-section-card mb-5"
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
|
||||||
>
|
>
|
||||||
<h2 className="text-base font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
|
{/* Section title row */}
|
||||||
Upload New Firmware
|
<div className="ui-section-card__title-row">
|
||||||
</h2>
|
<h2 className="ui-section-card__title">Upload New Firmware</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
{uploadError && (
|
{uploadError && (
|
||||||
<div
|
<div
|
||||||
className="text-sm rounded-md p-3 mb-3 border"
|
className="text-sm rounded-md p-3 mb-4 border"
|
||||||
style={{
|
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
|
||||||
backgroundColor: "var(--danger-bg)",
|
|
||||||
borderColor: "var(--danger)",
|
|
||||||
color: "var(--danger-text)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{uploadError}
|
{uploadError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<form onSubmit={handleUpload} className="space-y-3">
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<form onSubmit={handleUpload}>
|
||||||
|
{/* 3-column panel layout — height driven by left panel */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "1.5rem", alignItems: "stretch" }}>
|
||||||
|
|
||||||
|
{/* ── LEFT: Config ── */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
|
{/* Board Type */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
|
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>Board Type</label>
|
||||||
Board Type
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
value={uploadHwType}
|
value={uploadHwType}
|
||||||
onChange={(e) => setUploadHwType(e.target.value)}
|
onChange={(e) => setUploadHwType(e.target.value)}
|
||||||
className="w-full px-3 py-2 rounded-md text-sm border"
|
className="w-full px-3 py-2 rounded-md text-sm border"
|
||||||
style={{
|
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
|
||||||
backgroundColor: "var(--bg-input)",
|
|
||||||
borderColor: "var(--border-input)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{BOARD_TYPES.map((bt) => (
|
{BOARD_TYPES.map((bt) => (
|
||||||
<option key={bt.value} value={bt.value}>{bt.label}</option>
|
<option key={bt.value} value={bt.value}>{bt.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Channel | Version | Min FW */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.625rem" }}>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
|
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>Channel</label>
|
||||||
Channel
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
value={uploadChannel}
|
value={uploadChannel}
|
||||||
onChange={(e) => setUploadChannel(e.target.value)}
|
onChange={(e) => setUploadChannel(e.target.value)}
|
||||||
className="w-full px-3 py-2 rounded-md text-sm border"
|
className="w-full px-3 py-2 rounded-md text-sm border"
|
||||||
style={{
|
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
|
||||||
backgroundColor: "var(--bg-input)",
|
|
||||||
borderColor: "var(--border-input)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{CHANNELS.map((c) => (
|
{CHANNELS.map((c) => (
|
||||||
<option key={c} value={c}>{c}</option>
|
<option key={c} value={c}>{c}</option>
|
||||||
@@ -232,56 +254,66 @@ export default function FirmwareManager() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
|
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>Version</label>
|
||||||
Version
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={uploadVersion}
|
value={uploadVersion}
|
||||||
onChange={(e) => setUploadVersion(e.target.value)}
|
onChange={(e) => setUploadVersion(e.target.value)}
|
||||||
placeholder="1.4.2"
|
placeholder="1.5"
|
||||||
required
|
required
|
||||||
className="w-full px-3 py-2 rounded-md text-sm border"
|
className="w-full px-3 py-2 rounded-md text-sm border"
|
||||||
style={{
|
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
|
||||||
backgroundColor: "var(--bg-input)",
|
|
||||||
borderColor: "var(--border-input)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
|
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>Min FW</label>
|
||||||
firmware.bin
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
type="text"
|
||||||
type="file"
|
value={uploadMinFw}
|
||||||
accept=".bin"
|
onChange={(e) => setUploadMinFw(e.target.value)}
|
||||||
required
|
placeholder="e.g. 1.2"
|
||||||
onChange={(e) => setUploadFile(e.target.files[0] || null)}
|
className="w-full px-3 py-2 rounded-md text-sm border"
|
||||||
className="w-full text-sm"
|
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
|
||||||
style={{ color: "var(--text-primary)" }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Update Type — 3 toggle buttons */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
|
<label className="block text-xs font-medium mb-1.5" style={{ color: "var(--text-muted)" }}>Update Type</label>
|
||||||
Release Notes (optional)
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.5rem" }}>
|
||||||
</label>
|
{[
|
||||||
<textarea
|
{ value: "mandatory", label: "Mandatory", desc: "Auto on reboot", active: { bg: "var(--badge-blue-bg)", border: "var(--badge-blue-text)", color: "var(--badge-blue-text)" } },
|
||||||
value={uploadNotes}
|
{ value: "emergency", label: "Emergency", desc: "Immediate push", active: { bg: "var(--danger-bg)", border: "var(--danger)", color: "var(--danger-text)" } },
|
||||||
onChange={(e) => setUploadNotes(e.target.value)}
|
{ value: "optional", label: "Optional", desc: "User-initiated", active: { bg: "var(--success-bg)", border: "var(--success-text)", color: "var(--success-text)" } },
|
||||||
rows={2}
|
].map((opt) => {
|
||||||
placeholder="What changed in this version?"
|
const isActive = uploadUpdateType === opt.value;
|
||||||
className="w-full px-3 py-2 rounded-md text-sm border resize-none"
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUploadUpdateType(opt.value)}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--bg-input)",
|
padding: "0.5rem 0.25rem",
|
||||||
borderColor: "var(--border-input)",
|
borderRadius: "0.5rem",
|
||||||
color: "var(--text-primary)",
|
border: `1px solid ${isActive ? opt.active.border : "var(--border-input)"}`,
|
||||||
|
backgroundColor: isActive ? opt.active.bg : "var(--bg-input)",
|
||||||
|
color: isActive ? opt.active.color : "var(--text-muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
textAlign: "center",
|
||||||
|
transition: "all 0.15s ease",
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<div style={{ fontSize: "0.75rem", fontWeight: 600 }}>{opt.label}</div>
|
||||||
|
<div style={{ fontSize: "0.65rem", marginTop: "0.15rem", opacity: 0.75 }}>{opt.desc}</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 pt-1">
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons sit at the bottom of the left panel */}
|
||||||
|
<div style={{ display: "flex", gap: "0.625rem", marginTop: "auto", paddingTop: "0.5rem" }}>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
@@ -299,6 +331,118 @@ export default function FirmwareManager() {
|
|||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── MIDDLE: Release Notes ── */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||||
|
<label className="block text-xs font-medium" style={{ color: "var(--text-muted)", marginBottom: "0.25rem" }}>Release Notes</label>
|
||||||
|
<textarea
|
||||||
|
value={uploadNotes}
|
||||||
|
onChange={(e) => setUploadNotes(e.target.value)}
|
||||||
|
placeholder="What changed in this version?"
|
||||||
|
className="w-full px-3 py-2 rounded-md text-sm border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-input)",
|
||||||
|
borderColor: "var(--border-input)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
flex: 1,
|
||||||
|
resize: "none",
|
||||||
|
minHeight: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── RIGHT: File drop + info ── */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||||
|
<label className="block text-xs font-medium" style={{ color: "var(--text-muted)", marginBottom: "0.25rem" }}>File Upload</label>
|
||||||
|
{/* Drop zone */}
|
||||||
|
<div
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const f = e.dataTransfer.files[0];
|
||||||
|
if (f && f.name.endsWith(".bin")) setUploadFile(f);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
border: `2px dashed ${uploadFile ? "var(--btn-primary)" : "var(--border-input)"}`,
|
||||||
|
borderRadius: "0.625rem",
|
||||||
|
backgroundColor: uploadFile ? "var(--badge-blue-bg)" : "var(--bg-input)",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.15s ease",
|
||||||
|
padding: "1rem",
|
||||||
|
minHeight: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".bin"
|
||||||
|
required
|
||||||
|
onChange={(e) => setUploadFile(e.target.files[0] || null)}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
{uploadFile ? (
|
||||||
|
<>
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--btn-primary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: "var(--badge-blue-text)", textAlign: "center", wordBreak: "break-all" }}>
|
||||||
|
{uploadFile.name}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
<span style={{ fontSize: "0.75rem", color: "var(--text-muted)", textAlign: "center" }}>
|
||||||
|
Click or drop <strong>.bin</strong> file here
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File info */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.375rem",
|
||||||
|
padding: "0.75rem",
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
border: "1px solid var(--border-secondary)",
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: "0.72rem", color: "var(--text-muted)" }}>
|
||||||
|
<span style={{ fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em" }}>Size</span>
|
||||||
|
{" "}
|
||||||
|
<span style={{ color: "var(--text-primary)", fontFamily: "monospace" }}>
|
||||||
|
{uploadFile ? formatBytes(uploadFile.size) : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{uploadFile?.lastModified && (
|
||||||
|
<div style={{ fontSize: "0.72rem", color: "var(--text-muted)" }}>
|
||||||
|
<span style={{ fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em" }}>Modified</span>
|
||||||
|
{" "}
|
||||||
|
<span style={{ color: "var(--text-primary)", fontFamily: "monospace" }}>
|
||||||
|
{formatDate(new Date(uploadFile.lastModified).toISOString())}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -362,6 +506,8 @@ export default function FirmwareManager() {
|
|||||||
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Type</th>
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Type</th>
|
||||||
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Channel</th>
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Channel</th>
|
||||||
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Version</th>
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Version</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Update Type</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Min FW</th>
|
||||||
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Size</th>
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Size</th>
|
||||||
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>SHA-256</th>
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>SHA-256</th>
|
||||||
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Uploaded</th>
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Uploaded</th>
|
||||||
@@ -374,13 +520,13 @@ export default function FirmwareManager() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={canDelete ? 8 : 7} className="px-4 py-8 text-center text-sm" style={{ color: "var(--text-muted)" }}>
|
<td colSpan={canDelete ? 10 : 9} className="px-4 py-8 text-center text-sm" style={{ color: "var(--text-muted)" }}>
|
||||||
Loading…
|
Loading…
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : firmware.length === 0 ? (
|
) : firmware.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={canDelete ? 8 : 7} className="px-4 py-8 text-center text-sm" style={{ color: "var(--text-muted)" }}>
|
<td colSpan={canDelete ? 10 : 9} className="px-4 py-8 text-center text-sm" style={{ color: "var(--text-muted)" }}>
|
||||||
No firmware versions found.{" "}
|
No firmware versions found.{" "}
|
||||||
{canAdd && (
|
{canAdd && (
|
||||||
<button
|
<button
|
||||||
@@ -408,6 +554,12 @@ export default function FirmwareManager() {
|
|||||||
<td className="px-4 py-3 font-mono text-xs" style={{ color: "var(--text-primary)" }}>
|
<td className="px-4 py-3 font-mono text-xs" style={{ color: "var(--text-primary)" }}>
|
||||||
{fw.version}
|
{fw.version}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<UpdateTypeBadge type={fw.update_type} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{fw.min_fw_version || "—"}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
|
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
{formatBytes(fw.size_bytes)}
|
{formatBytes(fw.size_bytes)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
--text-primary: #e3e5ea;
|
--text-primary: #e3e5ea;
|
||||||
--text-secondary: #9ca3af;
|
--text-secondary: #9ca3af;
|
||||||
--text-muted: #9ca3af;
|
--text-muted: #9ca3af;
|
||||||
|
--text-more-muted: #818da1bb;
|
||||||
--text-heading: #e3e5ea;
|
--text-heading: #e3e5ea;
|
||||||
--text-white: #ffffff;
|
--text-white: #ffffff;
|
||||||
--text-link: #589cfa;
|
--text-link: #589cfa;
|
||||||
@@ -28,6 +29,11 @@
|
|||||||
--btn-primary-hover: #82c91e;
|
--btn-primary-hover: #82c91e;
|
||||||
--btn-neutral: #778ca8;
|
--btn-neutral: #778ca8;
|
||||||
--btn-neutral-hover: #8a9dba;
|
--btn-neutral-hover: #8a9dba;
|
||||||
|
--mail-filter-green: #2f9e44;
|
||||||
|
--mail-filter-blue: #4dabf7;
|
||||||
|
--mail-filter-yellow: #f08c00;
|
||||||
|
--mail-filter-orange: #f76707;
|
||||||
|
--mail-filter-red: #e03131;
|
||||||
|
|
||||||
--danger: #f34b4b;
|
--danger: #f34b4b;
|
||||||
--danger-hover: #e53e3e;
|
--danger-hover: #e53e3e;
|
||||||
@@ -42,6 +48,48 @@
|
|||||||
|
|
||||||
--badge-blue-bg: #1e3a5f;
|
--badge-blue-bg: #1e3a5f;
|
||||||
--badge-blue-text: #63b3ed;
|
--badge-blue-text: #63b3ed;
|
||||||
|
|
||||||
|
/* ── Spacing tokens ── */
|
||||||
|
--section-padding: 2.25rem 2.5rem 2.25rem;
|
||||||
|
--section-padding-compact: 1.25rem 1.5rem;
|
||||||
|
--section-radius: 0.75rem;
|
||||||
|
--section-gap: 1.5rem;
|
||||||
|
|
||||||
|
/* ── Typography tokens ── */
|
||||||
|
--section-title-size: 0.78rem;
|
||||||
|
--section-title-weight: 700;
|
||||||
|
--section-title-tracking: 0.07em;
|
||||||
|
--font-size-label: 0.72rem;
|
||||||
|
--font-size-value: 0.92rem;
|
||||||
|
|
||||||
|
/* ── Section header title (larger, page-header style) ── */
|
||||||
|
--section-header-title-size: 1.0rem;
|
||||||
|
--section-header-title-weight: 600;
|
||||||
|
--section-header-title-tracking: 0.01em;
|
||||||
|
--section-header-title-color: var(--text-heading);
|
||||||
|
|
||||||
|
/* ── Field / item labels (secondary titles within sections) ── */
|
||||||
|
/* Display variant: small, uppercase, muted — used in <dt> / read-only views */
|
||||||
|
--field-label-size: 0.72rem;
|
||||||
|
--field-label-weight: 600;
|
||||||
|
--field-label-tracking: 0.02em;
|
||||||
|
--field-label-color: var(--text-more-muted);
|
||||||
|
/* Form variant: slightly larger, no uppercase — used in <label> / form inputs */
|
||||||
|
--form-label-size: 0.8rem;
|
||||||
|
--form-label-weight: 500;
|
||||||
|
--form-label-tracking: 0.01em;
|
||||||
|
--form-label-color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove number input spinners (arrows) in all browsers */
|
||||||
|
input[type="number"]::-webkit-outer-spin-button,
|
||||||
|
input[type="number"]::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
input[type="number"] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
appearance: textfield;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure all interactive elements show pointer cursor */
|
/* Ensure all interactive elements show pointer cursor */
|
||||||
@@ -624,12 +672,77 @@ input[type="range"]::-moz-range-thumb {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Section cards (used in all tabs) ── */
|
/* ── Universal section card — single source of truth for all pages ── */
|
||||||
|
.ui-section-card {
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--section-radius);
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
padding: var(--section-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-section-card--compact {
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--section-radius);
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
padding: var(--section-padding-compact);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-section-card__title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-section-card__title {
|
||||||
|
font-size: var(--section-title-size);
|
||||||
|
font-weight: var(--section-title-weight);
|
||||||
|
letter-spacing: var(--section-title-tracking);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Larger, white-ish section title for form/settings pages */
|
||||||
|
.ui-section-card__header-title {
|
||||||
|
font-size: var(--section-header-title-size);
|
||||||
|
font-weight: var(--section-header-title-weight);
|
||||||
|
letter-spacing: var(--section-header-title-tracking);
|
||||||
|
color: var(--section-header-title-color);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Field labels — secondary titles within section cards ── */
|
||||||
|
|
||||||
|
/* Display/read-only label (dt, small caps, muted) */
|
||||||
|
.ui-field-label {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--field-label-size);
|
||||||
|
font-weight: var(--field-label-weight);
|
||||||
|
letter-spacing: var(--field-label-tracking);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--field-label-color);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form label (label element, slightly larger, no uppercase) */
|
||||||
|
.ui-form-label {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--form-label-size);
|
||||||
|
font-weight: var(--form-label-weight);
|
||||||
|
letter-spacing: var(--form-label-tracking);
|
||||||
|
color: var(--form-label-color);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section cards (used in all tabs) — alias to ui-section-card ── */
|
||||||
.device-section-card {
|
.device-section-card {
|
||||||
border: 1px solid var(--border-primary);
|
border: 1px solid var(--border-primary);
|
||||||
border-radius: 0.75rem;
|
border-radius: var(--section-radius);
|
||||||
background-color: var(--bg-card);
|
background-color: var(--bg-card);
|
||||||
padding: 2.25rem 2.5rem 2.5rem;
|
padding: var(--section-padding);
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-section-card__title-row {
|
.device-section-card__title-row {
|
||||||
@@ -642,9 +755,9 @@ input[type="range"]::-moz-range-thumb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.device-section-card__title {
|
.device-section-card__title {
|
||||||
font-size: 0.78rem;
|
font-size: var(--section-title-size);
|
||||||
font-weight: 700;
|
font-weight: var(--section-title-weight);
|
||||||
letter-spacing: 0.07em;
|
letter-spacing: var(--section-title-tracking);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
@@ -729,7 +842,7 @@ input[type="range"]::-moz-range-thumb {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 2.5rem;
|
||||||
border-radius: 0.6rem;
|
border-radius: 0.6rem;
|
||||||
border: 1px solid var(--border-primary);
|
border: 1px solid var(--border-primary);
|
||||||
background-color: var(--bg-card);
|
background-color: var(--bg-card);
|
||||||
@@ -1105,6 +1218,17 @@ input[type="range"]::-moz-range-thumb {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Upload modal animations ── */
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
/* File input */
|
/* File input */
|
||||||
input[type="file"]::file-selector-button {
|
input[type="file"]::file-selector-button {
|
||||||
background-color: var(--bg-card) !important;
|
background-color: var(--bg-card) !important;
|
||||||
|
|||||||
@@ -1,4 +1,202 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import api from "../api/client";
|
||||||
|
|
||||||
|
// ── Breadcrumb label cache (in-memory, survives re-renders) ────────────────
|
||||||
|
const labelCache = {};
|
||||||
|
|
||||||
|
// ── Segment label rules ────────────────────────────────────────────────────
|
||||||
|
const STATIC_LABELS = {
|
||||||
|
"": "Home",
|
||||||
|
dashboard: "Dashboard",
|
||||||
|
devices: "Devices",
|
||||||
|
users: "App Users",
|
||||||
|
mqtt: "MQTT",
|
||||||
|
commands: "Commands",
|
||||||
|
logs: "Logs",
|
||||||
|
equipment: "Equipment",
|
||||||
|
notes: "Issues & Notes",
|
||||||
|
mail: "Mail",
|
||||||
|
crm: "CRM",
|
||||||
|
comms: "Activity Log",
|
||||||
|
customers: "Customers",
|
||||||
|
orders: "Orders",
|
||||||
|
products: "Products",
|
||||||
|
quotations: "Quotations",
|
||||||
|
new: "New",
|
||||||
|
edit: "Edit",
|
||||||
|
melodies: "Melodies",
|
||||||
|
archetypes: "Archetypes",
|
||||||
|
settings: "Settings",
|
||||||
|
composer: "Composer",
|
||||||
|
manufacturing: "Manufacturing",
|
||||||
|
batch: "Batch",
|
||||||
|
provision: "Provision Device",
|
||||||
|
firmware: "Firmware",
|
||||||
|
developer: "Developer",
|
||||||
|
api: "API Reference",
|
||||||
|
staff: "Staff",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Maps a parent path segment to an async entity resolver
|
||||||
|
const ENTITY_RESOLVERS = {
|
||||||
|
customers: { apiPath: (id) => `/crm/customers/${id}`, label: (d) => [d.name, d.surname].filter(Boolean).join(" ") || d.organization || null },
|
||||||
|
products: { apiPath: (id) => `/crm/products/${id}`, label: (d) => d.name || null },
|
||||||
|
quotations: { apiPath: (id) => `/crm/quotations/${id}`, label: (d) => d.quotation_number || null },
|
||||||
|
devices: { apiPath: (id) => `/devices/${id}`, label: (d) => d.name || d.device_id || null },
|
||||||
|
orders: { apiPath: (id) => `/crm/orders/${id}`, label: (d) => d.order_number || null },
|
||||||
|
melodies: { apiPath: (id) => `/melodies/${id}`, label: (d) => d.name || null },
|
||||||
|
archetypes: { apiPath: (id) => `/builder/melodies/${id}`,label: (d) => d.name || null },
|
||||||
|
users: { apiPath: (id) => `/users/${id}`, label: (d) => d.name || d.display_name || null },
|
||||||
|
notes: { apiPath: (id) => `/equipment/notes/${id}`, label: (d) => d.title || d.subject || null },
|
||||||
|
staff: { apiPath: (id) => `/staff/${id}`, label: (d) => d.name || null },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch entity name for a dynamic ID segment
|
||||||
|
async function fetchLabel(fetchType, id) {
|
||||||
|
const cacheKey = `${fetchType}:${id}`;
|
||||||
|
if (labelCache[cacheKey]) return labelCache[cacheKey];
|
||||||
|
|
||||||
|
const resolver = ENTITY_RESOLVERS[fetchType];
|
||||||
|
if (!resolver) return id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.get(resolver.apiPath(id));
|
||||||
|
const label = resolver.label(data);
|
||||||
|
if (label) {
|
||||||
|
const short = String(label).length > 28 ? String(label).slice(0, 26) + "…" : String(label);
|
||||||
|
labelCache[cacheKey] = short;
|
||||||
|
return short;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore — fall back to id
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Given a full path, return breadcrumb segments: [{ label, to }]
|
||||||
|
function parseSegments(pathname) {
|
||||||
|
const parts = pathname.split("/").filter(Boolean);
|
||||||
|
const segments = [{ label: "Home", to: "/" }];
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
while (i < parts.length) {
|
||||||
|
const part = parts[i];
|
||||||
|
const built = "/" + parts.slice(0, i + 1).join("/");
|
||||||
|
|
||||||
|
// Special multi-segment patterns
|
||||||
|
if (part === "crm") {
|
||||||
|
segments.push({ label: "CRM", to: "/crm/customers" });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (part === "manufacturing" && parts[i + 1] === "batch" && parts[i + 2] === "new") {
|
||||||
|
segments.push({ label: "Manufacturing", to: "/manufacturing" });
|
||||||
|
segments.push({ label: "New Batch", to: built + "/batch/new" });
|
||||||
|
i += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (part === "manufacturing" && parts[i + 1] === "provision") {
|
||||||
|
segments.push({ label: "Manufacturing", to: "/manufacturing" });
|
||||||
|
segments.push({ label: "Provision Device", to: built + "/provision" });
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (part === "manufacturing" && parts[i + 1] === "devices") {
|
||||||
|
segments.push({ label: "Manufacturing", to: "/manufacturing" });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// "devices" is handled by STATIC_LABELS below so the following ID segment
|
||||||
|
// can detect prevPart === "devices" for entity resolution.
|
||||||
|
// equipment/notes
|
||||||
|
if (part === "equipment" && parts[i + 1] === "notes") {
|
||||||
|
segments.push({ label: "Issues & Notes", to: "/equipment/notes" });
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const staticLabel = STATIC_LABELS[part];
|
||||||
|
if (staticLabel) {
|
||||||
|
segments.push({ label: staticLabel, to: built });
|
||||||
|
} else {
|
||||||
|
// Dynamic ID segment — determine type from previous path segment
|
||||||
|
const prevPart = parts[i - 1];
|
||||||
|
const fetchType = ENTITY_RESOLVERS[prevPart] ? prevPart : null;
|
||||||
|
|
||||||
|
// Use the raw id as placeholder — will be replaced asynchronously if fetchType is known
|
||||||
|
segments.push({ label: part, to: built, dynamicId: part, fetchType });
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Breadcrumb() {
|
||||||
|
const location = useLocation();
|
||||||
|
const [segments, setSegments] = useState(() => parseSegments(location.pathname));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const parsed = parseSegments(location.pathname);
|
||||||
|
setSegments(parsed);
|
||||||
|
|
||||||
|
// Resolve any dynamic segments asynchronously
|
||||||
|
const dynamics = parsed.filter((s) => s.fetchType && s.dynamicId);
|
||||||
|
if (dynamics.length === 0) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const resolved = [...parsed];
|
||||||
|
for (const seg of dynamics) {
|
||||||
|
const label = await fetchLabel(seg.fetchType, seg.dynamicId);
|
||||||
|
if (cancelled) return;
|
||||||
|
const idx = resolved.findIndex((s) => s.dynamicId === seg.dynamicId && s.fetchType === seg.fetchType);
|
||||||
|
if (idx !== -1) resolved[idx] = { ...resolved[idx], label };
|
||||||
|
}
|
||||||
|
if (!cancelled) setSegments([...resolved]);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
// Don't show breadcrumb for root
|
||||||
|
if (segments.length <= 1) return null;
|
||||||
|
|
||||||
|
// Remove "Home" from display if not on root
|
||||||
|
const display = segments.slice(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="Breadcrumb" style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 13 }}>
|
||||||
|
{/* Brand prefix */}
|
||||||
|
<span style={{ color: "var(--text-heading)", fontWeight: 700, fontSize: 13, letterSpacing: "0.01em", whiteSpace: "nowrap" }}>
|
||||||
|
BellSystems Console
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "var(--border-primary)", userSelect: "none", margin: "0 8px", fontSize: 15 }}>|</span>
|
||||||
|
|
||||||
|
{display.map((seg, i) => (
|
||||||
|
<span key={i} style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||||
|
{i > 0 && (
|
||||||
|
<span style={{ color: "var(--text-muted)", userSelect: "none", fontSize: 11 }}>›</span>
|
||||||
|
)}
|
||||||
|
{i === display.length - 1 ? (
|
||||||
|
<span style={{ color: "var(--text-heading)", fontWeight: 600 }}>{seg.label}</span>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to={seg.to}
|
||||||
|
style={{ color: "var(--text-secondary)", textDecoration: "none" }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.color = "var(--text-heading)")}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.color = "var(--text-secondary)")}
|
||||||
|
>
|
||||||
|
{seg.label}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
@@ -11,9 +209,7 @@ export default function Header() {
|
|||||||
borderColor: "var(--border-primary)",
|
borderColor: "var(--border-primary)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
|
<Breadcrumb />
|
||||||
BellCloud™ - Console
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
|
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
|
||||||
@@ -41,5 +237,3 @@ export default function Header() {
|
|||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* my test string */
|
|
||||||
@@ -8,7 +8,7 @@ const navItems = [
|
|||||||
label: "Melodies",
|
label: "Melodies",
|
||||||
permission: "melodies",
|
permission: "melodies",
|
||||||
children: [
|
children: [
|
||||||
{ to: "/melodies", label: "Main Editor" },
|
{ to: "/melodies", label: "Main Editor", exact: true },
|
||||||
{ to: "/melodies/archetypes", label: "Archetypes" },
|
{ to: "/melodies/archetypes", label: "Archetypes" },
|
||||||
{ to: "/melodies/settings", label: "Settings" },
|
{ to: "/melodies/settings", label: "Settings" },
|
||||||
{ to: "/melodies/composer", label: "Composer" },
|
{ to: "/melodies/composer", label: "Composer" },
|
||||||
@@ -20,17 +20,28 @@ const navItems = [
|
|||||||
label: "MQTT",
|
label: "MQTT",
|
||||||
permission: "mqtt",
|
permission: "mqtt",
|
||||||
children: [
|
children: [
|
||||||
{ to: "/mqtt", label: "Dashboard" },
|
{ to: "/mqtt", label: "Dashboard", exact: true },
|
||||||
{ to: "/mqtt/commands", label: "Commands" },
|
{ to: "/mqtt/commands", label: "Commands" },
|
||||||
{ to: "/mqtt/logs", label: "Logs" },
|
{ to: "/mqtt/logs", label: "Logs" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ to: "/equipment/notes", label: "Issues and Notes", permission: "equipment" },
|
{ to: "/equipment/notes", label: "Issues and Notes", permission: "equipment" },
|
||||||
|
{ to: "/mail", label: "Mail", permission: "crm" },
|
||||||
|
{
|
||||||
|
label: "CRM",
|
||||||
|
permission: "crm",
|
||||||
|
children: [
|
||||||
|
{ to: "/crm/comms", label: "Activity Log" },
|
||||||
|
{ to: "/crm/customers", label: "Customers" },
|
||||||
|
{ to: "/crm/orders", label: "Orders" },
|
||||||
|
{ to: "/crm/products", label: "Products" },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Manufacturing",
|
label: "Manufacturing",
|
||||||
permission: "manufacturing",
|
permission: "manufacturing",
|
||||||
children: [
|
children: [
|
||||||
{ to: "/manufacturing", label: "Device Inventory" },
|
{ to: "/manufacturing", label: "Device Inventory", exact: true },
|
||||||
{ to: "/manufacturing/batch/new", label: "New Batch" },
|
{ to: "/manufacturing/batch/new", label: "New Batch" },
|
||||||
{ to: "/manufacturing/provision", label: "Provision Device" },
|
{ to: "/manufacturing/provision", label: "Provision Device" },
|
||||||
{ to: "/firmware", label: "Firmware" },
|
{ to: "/firmware", label: "Firmware" },
|
||||||
@@ -47,17 +58,39 @@ const linkClass = (isActive, locked) =>
|
|||||||
: "text-[var(--text-secondary)] hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-heading)]"
|
: "text-[var(--text-secondary)] hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-heading)]"
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
|
function isGroupActive(children, pathname) {
|
||||||
|
return children.some((child) => {
|
||||||
|
if (child.exact) return pathname === child.to;
|
||||||
|
return pathname === child.to || pathname.startsWith(child.to + "/");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const { hasPermission, hasRole } = useAuth();
|
const { hasPermission, hasRole } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [openGroup, setOpenGroup] = useState(() => {
|
||||||
|
// Open the group that contains the current route on initial load
|
||||||
|
for (const item of navItems) {
|
||||||
|
if (item.children && isGroupActive(item.children, location.pathname)) {
|
||||||
|
return item.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
const canViewSection = (permission) => {
|
const canViewSection = (permission) => {
|
||||||
if (!permission) return true;
|
if (!permission) return true;
|
||||||
return hasPermission(permission, "view");
|
return hasPermission(permission, "view");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Settings visible only to sysadmin and admin
|
|
||||||
const canManageStaff = hasRole("sysadmin", "admin");
|
const canManageStaff = hasRole("sysadmin", "admin");
|
||||||
|
const canViewDeveloper = hasRole("sysadmin", "admin");
|
||||||
|
|
||||||
|
const handleGroupToggle = (label) => {
|
||||||
|
setOpenGroup((prev) => (prev === label ? null : label));
|
||||||
|
};
|
||||||
|
|
||||||
|
const settingsChildren = [{ to: "/settings/staff", label: "Staff" }];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-56 h-screen flex-shrink-0 p-4 border-r flex flex-col overflow-y-auto" style={{ backgroundColor: "var(--bg-sidebar)", borderColor: "var(--border-primary)" }}>
|
<aside className="w-56 h-screen flex-shrink-0 p-4 border-r flex flex-col overflow-y-auto" style={{ backgroundColor: "var(--bg-sidebar)", borderColor: "var(--border-primary)" }}>
|
||||||
@@ -78,6 +111,8 @@ export default function Sidebar() {
|
|||||||
children={item.children}
|
children={item.children}
|
||||||
currentPath={location.pathname}
|
currentPath={location.pathname}
|
||||||
locked={!hasAccess}
|
locked={!hasAccess}
|
||||||
|
open={openGroup === item.label}
|
||||||
|
onToggle={() => handleGroupToggle(item.label)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -85,7 +120,10 @@ export default function Sidebar() {
|
|||||||
to={hasAccess ? item.to : "#"}
|
to={hasAccess ? item.to : "#"}
|
||||||
end={item.to === "/"}
|
end={item.to === "/"}
|
||||||
className={({ isActive }) => linkClass(isActive && hasAccess, !hasAccess)}
|
className={({ isActive }) => linkClass(isActive && hasAccess, !hasAccess)}
|
||||||
onClick={(e) => !hasAccess && e.preventDefault()}
|
onClick={(e) => {
|
||||||
|
if (!hasAccess) { e.preventDefault(); return; }
|
||||||
|
setOpenGroup(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -100,16 +138,31 @@ export default function Sidebar() {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Developer section */}
|
||||||
|
{canViewDeveloper && (
|
||||||
|
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
|
<nav className="space-y-1">
|
||||||
|
<NavLink
|
||||||
|
to="/developer/api"
|
||||||
|
className={({ isActive }) => linkClass(isActive, false)}
|
||||||
|
onClick={() => setOpenGroup(null)}
|
||||||
|
>
|
||||||
|
API Reference
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Settings section at the bottom */}
|
{/* Settings section at the bottom */}
|
||||||
{canManageStaff && (
|
{canManageStaff && (
|
||||||
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
|
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
<nav className="space-y-1">
|
<nav className="space-y-1">
|
||||||
<CollapsibleGroup
|
<CollapsibleGroup
|
||||||
label="Settings"
|
label="Settings"
|
||||||
children={[
|
children={settingsChildren}
|
||||||
{ to: "/settings/staff", label: "Staff" },
|
|
||||||
]}
|
|
||||||
currentPath={location.pathname}
|
currentPath={location.pathname}
|
||||||
|
open={openGroup === "Settings"}
|
||||||
|
onToggle={() => handleGroupToggle("Settings")}
|
||||||
/>
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,25 +171,19 @@ export default function Sidebar() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CollapsibleGroup({ label, children, currentPath, locked = false }) {
|
function CollapsibleGroup({ label, children, currentPath, locked = false, open, onToggle }) {
|
||||||
const isChildActive = children.some(
|
const childActive = isGroupActive(children, currentPath);
|
||||||
(child) =>
|
const shouldBeOpen = open || childActive;
|
||||||
currentPath === child.to ||
|
|
||||||
(child.to !== "/" && currentPath.startsWith(child.to + "/"))
|
|
||||||
);
|
|
||||||
const [open, setOpen] = useState(isChildActive);
|
|
||||||
|
|
||||||
const shouldBeOpen = open || isChildActive;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => !locked && setOpen(!shouldBeOpen)}
|
onClick={() => !locked && onToggle()}
|
||||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors ${
|
||||||
locked
|
locked
|
||||||
? "opacity-40 cursor-not-allowed"
|
? "opacity-40 cursor-not-allowed"
|
||||||
: isChildActive
|
: childActive
|
||||||
? "text-[var(--text-heading)]"
|
? "text-[var(--text-heading)]"
|
||||||
: "text-[var(--text-secondary)] hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-heading)]"
|
: "text-[var(--text-secondary)] hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-heading)]"
|
||||||
}`}
|
}`}
|
||||||
@@ -145,7 +192,7 @@ function CollapsibleGroup({ label, children, currentPath, locked = false }) {
|
|||||||
{label}
|
{label}
|
||||||
{locked && (
|
{locked && (
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -166,7 +213,7 @@ function CollapsibleGroup({ label, children, currentPath, locked = false }) {
|
|||||||
<NavLink
|
<NavLink
|
||||||
key={child.to}
|
key={child.to}
|
||||||
to={child.to}
|
to={child.to}
|
||||||
end
|
end={child.exact === true}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`block pl-4 pr-3 py-1.5 rounded-md text-sm transition-colors ${
|
`block pl-4 pr-3 py-1.5 rounded-md text-sm transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
@@ -183,5 +230,3 @@ function CollapsibleGroup({ label, children, currentPath, locked = false }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import api from "../api/client";
|
import api from "../api/client";
|
||||||
|
|
||||||
const BOARD_TYPES = [
|
const BOARD_TYPES = [
|
||||||
{ value: "vx", name: "VESPER PRO", codename: "vesper-pro", desc: "Full-featured pro controller", family: "vesper" },
|
{ value: "vesper_pro", name: "VESPER PRO", codename: "vesper-pro", desc: "Full-featured pro controller", family: "vesper" },
|
||||||
{ value: "vp", name: "VESPER PLUS", codename: "vesper-plus", desc: "Extended output controller", family: "vesper" },
|
{ value: "vesper_plus", name: "VESPER PLUS", codename: "vesper-plus", desc: "Extended output controller", family: "vesper" },
|
||||||
{ value: "vs", name: "VESPER", codename: "vesper-basic", desc: "Standard bell controller", family: "vesper" },
|
{ value: "vesper", name: "VESPER", codename: "vesper-basic", desc: "Standard bell controller", family: "vesper" },
|
||||||
{ value: "ab", name: "AGNUS", codename: "agnus-basic", desc: "Standard carillon module", family: "agnus" },
|
{ value: "agnus", name: "AGNUS", codename: "agnus-basic", desc: "Standard carillon module", family: "agnus" },
|
||||||
{ value: "am", name: "AGNUS MINI", codename: "agnus-mini", desc: "Compact carillon module", family: "agnus" },
|
{ value: "agnus_mini", name: "AGNUS MINI", codename: "agnus-mini", desc: "Compact carillon module", family: "agnus" },
|
||||||
{ value: "cp", name: "CHRONOS PRO", codename: "chronos-pro", desc: "Pro clock controller", family: "chronos" },
|
{ value: "chronos_pro", name: "CHRONOS PRO", codename: "chronos-pro", desc: "Pro clock controller", family: "chronos" },
|
||||||
{ value: "cb", name: "CHRONOS", codename: "chronos-basic", desc: "Basic clock controller", family: "chronos" },
|
{ value: "chronos", name: "CHRONOS", codename: "chronos-basic", desc: "Basic clock controller", family: "chronos" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const BOARD_FAMILY_COLORS = {
|
const BOARD_FAMILY_COLORS = {
|
||||||
@@ -99,12 +99,11 @@ export default function BatchCreator() {
|
|||||||
|
|
||||||
{!result ? (
|
{!result ? (
|
||||||
<div
|
<div
|
||||||
className="rounded-lg border p-6"
|
className="ui-section-card"
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
|
||||||
>
|
>
|
||||||
<h2 className="text-base font-semibold mb-5" style={{ color: "var(--text-heading)" }}>
|
<div className="ui-section-card__title-row">
|
||||||
Batch Parameters
|
<h2 className="ui-section-card__title">Batch Parameters</h2>
|
||||||
</h2>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-sm rounded-md p-3 mb-4 border"
|
<div className="text-sm rounded-md p-3 mb-4 border"
|
||||||
@@ -116,7 +115,7 @@ export default function BatchCreator() {
|
|||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
{/* Board Type tiles — Vesper: 3 col, Agnus: 2 col, Chronos: 2 col */}
|
{/* Board Type tiles — Vesper: 3 col, Agnus: 2 col, Chronos: 2 col */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Board Type
|
Board Type
|
||||||
</label>
|
</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -147,7 +146,7 @@ export default function BatchCreator() {
|
|||||||
|
|
||||||
{/* Board Revision */}
|
{/* Board Revision */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Board Revision
|
Board Revision
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -169,7 +168,7 @@ export default function BatchCreator() {
|
|||||||
|
|
||||||
{/* Quantity */}
|
{/* Quantity */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}>
|
<label className="ui-form-label">
|
||||||
Quantity
|
Quantity
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -206,8 +205,7 @@ export default function BatchCreator() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="rounded-lg border p-6"
|
className="ui-section-card"
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import api from "../api/client";
|
|||||||
|
|
||||||
// Row layout: Vesper Pro / Vesper Plus / Vesper | Agnus / Agnus Mini | Chronos Pro / Chronos
|
// Row layout: Vesper Pro / Vesper Plus / Vesper | Agnus / Agnus Mini | Chronos Pro / Chronos
|
||||||
const BOARD_TYPES = [
|
const BOARD_TYPES = [
|
||||||
{ value: "vx", name: "VESPER PRO", codename: "vesper-pro", family: "vesper" },
|
{ value: "vesper_pro", name: "VESPER PRO", codename: "vesper-pro", family: "vesper" },
|
||||||
{ value: "vp", name: "VESPER PLUS", codename: "vesper-plus", family: "vesper" },
|
{ value: "vesper_plus", name: "VESPER PLUS", codename: "vesper-plus", family: "vesper" },
|
||||||
{ value: "vs", name: "VESPER", codename: "vesper-basic", family: "vesper" },
|
{ value: "vesper", name: "VESPER", codename: "vesper-basic", family: "vesper" },
|
||||||
{ value: "ab", name: "AGNUS", codename: "agnus-basic", family: "agnus" },
|
{ value: "agnus", name: "AGNUS", codename: "agnus-basic", family: "agnus" },
|
||||||
{ value: "am", name: "AGNUS MINI", codename: "agnus-mini", family: "agnus" },
|
{ value: "agnus_mini", name: "AGNUS MINI", codename: "agnus-mini", family: "agnus" },
|
||||||
{ value: "cp", name: "CHRONOS PRO", codename: "chronos-pro", family: "chronos" },
|
{ value: "chronos_pro", name: "CHRONOS PRO", codename: "chronos-pro", family: "chronos" },
|
||||||
{ value: "cb", name: "CHRONOS", codename: "chronos-basic", family: "chronos" },
|
{ value: "chronos", name: "CHRONOS", codename: "chronos-basic", family: "chronos" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const BOARD_FAMILY_COLORS = {
|
const BOARD_FAMILY_COLORS = {
|
||||||
@@ -589,7 +589,16 @@ export default function DeviceInventory() {
|
|||||||
|
|
||||||
const renderCell = (col, device) => {
|
const renderCell = (col, device) => {
|
||||||
switch (col.id) {
|
switch (col.id) {
|
||||||
case "serial": return <span className="font-mono text-xs" style={{ color: "var(--text-primary)" }}>{device.serial_number}</span>;
|
case "serial": return (
|
||||||
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<span className="font-mono text-xs" style={{ color: "var(--text-primary)" }}>{device.serial_number}</span>
|
||||||
|
{/^[A-Z0-9]{10,}$/.test(device.serial_number || "") && (
|
||||||
|
<span style={{ fontSize: 9, fontWeight: 700, padding: "1px 5px", borderRadius: 3, backgroundColor: "#2e1a00", color: "#fb923c", letterSpacing: "0.04em" }}>
|
||||||
|
LEGACY
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
case "type": return <span style={{ color: "var(--text-secondary)" }}>{BOARD_TYPE_LABELS[device.hw_type] || device.hw_type}</span>;
|
case "type": return <span style={{ color: "var(--text-secondary)" }}>{BOARD_TYPE_LABELS[device.hw_type] || device.hw_type}</span>;
|
||||||
case "version": return <span style={{ color: "var(--text-muted)" }}>{formatHwVersion(device.hw_version)}</span>;
|
case "version": return <span style={{ color: "var(--text-muted)" }}>{formatHwVersion(device.hw_version)}</span>;
|
||||||
case "status": return <StatusBadge status={device.mfg_status} />;
|
case "status": return <StatusBadge status={device.mfg_status} />;
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { useAuth } from "../auth/AuthContext";
|
|||||||
import api from "../api/client";
|
import api from "../api/client";
|
||||||
|
|
||||||
const BOARD_TYPE_LABELS = {
|
const BOARD_TYPE_LABELS = {
|
||||||
vs: "Vesper", vp: "Vesper Plus", vx: "Vesper Pro",
|
vesper: "Vesper", vesper_plus: "Vesper+", vesper_pro: "Vesper Pro",
|
||||||
cb: "Chronos", cp: "Chronos Pro", am: "Agnus Mini", ab: "Agnus",
|
chronos: "Chronos", chronos_pro: "Chronos Pro", agnus_mini: "Agnus Mini", agnus: "Agnus",
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_STYLES = {
|
const STATUS_STYLES = {
|
||||||
@@ -47,9 +47,7 @@ function StatusBadge({ status }) {
|
|||||||
function Field({ label, value, mono = false }) {
|
function Field({ label, value, mono = false }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>
|
<p className="ui-field-label mb-0.5">{label}</p>
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
<p className={`text-sm ${mono ? "font-mono" : ""}`} style={{ color: "var(--text-primary)" }}>
|
<p className={`text-sm ${mono ? "font-mono" : ""}`} style={{ color: "var(--text-primary)" }}>
|
||||||
{value || "—"}
|
{value || "—"}
|
||||||
</p>
|
</p>
|
||||||
@@ -320,11 +318,10 @@ export default function DeviceInventoryDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Identity card */}
|
{/* Identity card */}
|
||||||
<div className="rounded-lg border p-5 mb-4"
|
<div className="ui-section-card mb-4">
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
<div className="ui-section-card__title-row">
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: "var(--text-muted)" }}>
|
<h2 className="ui-section-card__title">Device Identity</h2>
|
||||||
Device Identity
|
</div>
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Field label="Serial Number" value={device?.serial_number} mono />
|
<Field label="Serial Number" value={device?.serial_number} mono />
|
||||||
<Field label="Board Type" value={BOARD_TYPE_LABELS[device?.hw_type] || device?.hw_type} />
|
<Field label="Board Type" value={BOARD_TYPE_LABELS[device?.hw_type] || device?.hw_type} />
|
||||||
@@ -339,12 +336,9 @@ export default function DeviceInventoryDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status card */}
|
{/* Status card */}
|
||||||
<div className="rounded-lg border p-5 mb-4"
|
<div className="ui-section-card mb-4">
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
<div className="ui-section-card__title-row">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<h2 className="ui-section-card__title">Status</h2>
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
|
|
||||||
Status
|
|
||||||
</h2>
|
|
||||||
{canEdit && !editingStatus && (
|
{canEdit && !editingStatus && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setNewStatus(device.mfg_status); setEditingStatus(true); }}
|
onClick={() => { setNewStatus(device.mfg_status); setEditingStatus(true); }}
|
||||||
@@ -404,11 +398,10 @@ export default function DeviceInventoryDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions card */}
|
{/* Actions card */}
|
||||||
<div className="rounded-lg border p-5 mb-4"
|
<div className="ui-section-card mb-4">
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
<div className="ui-section-card__title-row">
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: "var(--text-muted)" }}>
|
<h2 className="ui-section-card__title">Actions</h2>
|
||||||
Actions
|
</div>
|
||||||
</h2>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={downloadNvs}
|
onClick={downloadNvs}
|
||||||
@@ -429,11 +422,10 @@ export default function DeviceInventoryDetail() {
|
|||||||
|
|
||||||
{/* Assign to Customer card */}
|
{/* Assign to Customer card */}
|
||||||
{canEdit && ["provisioned", "flashed"].includes(device?.mfg_status) && (
|
{canEdit && ["provisioned", "flashed"].includes(device?.mfg_status) && (
|
||||||
<div className="rounded-lg border p-5"
|
<div className="ui-section-card">
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
<div className="ui-section-card__title-row">
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: "var(--text-muted)" }}>
|
<h2 className="ui-section-card__title">Assign to Customer</h2>
|
||||||
Assign to Customer
|
</div>
|
||||||
</h2>
|
|
||||||
{assignSuccess ? (
|
{assignSuccess ? (
|
||||||
<div className="text-sm rounded-md p-3 border"
|
<div className="text-sm rounded-md p-3 border"
|
||||||
style={{ backgroundColor: "#0a2e2a", borderColor: "#4dd6c8", color: "#4dd6c8" }}>
|
style={{ backgroundColor: "#0a2e2a", borderColor: "#4dd6c8", color: "#4dd6c8" }}>
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import api from "../api/client";
|
|||||||
|
|
||||||
// Row layout: Vesper Pro / Vesper Plus / Vesper | Agnus / Agnus Mini | Chronos Pro / Chronos
|
// Row layout: Vesper Pro / Vesper Plus / Vesper | Agnus / Agnus Mini | Chronos Pro / Chronos
|
||||||
const BOARD_TYPES = [
|
const BOARD_TYPES = [
|
||||||
{ value: "vx", name: "VESPER PRO", codename: "vesper-pro", desc: "Full-featured pro controller", family: "vesper" },
|
{ value: "vesper_pro", name: "VESPER PRO", codename: "vesper-pro", desc: "Full-featured pro controller", family: "vesper" },
|
||||||
{ value: "vp", name: "VESPER PLUS", codename: "vesper-plus", desc: "Extended output controller", family: "vesper" },
|
{ value: "vesper_plus", name: "VESPER PLUS", codename: "vesper-plus", desc: "Extended output controller", family: "vesper" },
|
||||||
{ value: "vs", name: "VESPER", codename: "vesper-basic", desc: "Standard bell controller", family: "vesper" },
|
{ value: "vesper", name: "VESPER", codename: "vesper-basic", desc: "Standard bell controller", family: "vesper" },
|
||||||
{ value: "ab", name: "AGNUS", codename: "agnus-basic", desc: "Standard carillon module", family: "agnus" },
|
{ value: "agnus", name: "AGNUS", codename: "agnus-basic", desc: "Standard carillon module", family: "agnus" },
|
||||||
{ value: "am", name: "AGNUS MINI", codename: "agnus-mini", desc: "Compact carillon module", family: "agnus" },
|
{ value: "agnus_mini", name: "AGNUS MINI", codename: "agnus-mini", desc: "Compact carillon module", family: "agnus" },
|
||||||
{ value: "cp", name: "CHRONOS PRO", codename: "chronos-pro", desc: "Pro clock controller", family: "chronos"},
|
{ value: "chronos_pro", name: "CHRONOS PRO", codename: "chronos-pro", desc: "Pro clock controller", family: "chronos" },
|
||||||
{ value: "cb", name: "CHRONOS", codename: "chronos-basic", desc: "Basic clock controller", family: "chronos"},
|
{ value: "chronos", name: "CHRONOS", codename: "chronos-basic", desc: "Basic clock controller", family: "chronos" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Color palette per board family (idle → selected → hover glow)
|
// Color palette per board family (idle → selected → hover glow)
|
||||||
|
|||||||
@@ -400,10 +400,7 @@ export default function MelodyComposer() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section
|
<section className="ui-section-card">
|
||||||
className="rounded-lg border p-4"
|
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<button type="button" onClick={addStep} className="px-3 py-1.5 text-sm rounded-md" style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}>+ Step</button>
|
<button type="button" onClick={addStep} className="px-3 py-1.5 text-sm rounded-md" style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}>+ Step</button>
|
||||||
<button type="button" onClick={removeStep} className="px-3 py-1.5 text-sm rounded-md" style={{ backgroundColor: "var(--btn-neutral)", color: "var(--text-white)" }}>- Step</button>
|
<button type="button" onClick={removeStep} className="px-3 py-1.5 text-sm rounded-md" style={{ backgroundColor: "var(--btn-neutral)", color: "var(--text-white)" }}>- Step</button>
|
||||||
@@ -485,10 +482,7 @@ export default function MelodyComposer() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section className="ui-section-card">
|
||||||
className="rounded-lg border"
|
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
|
||||||
>
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-max border-separate border-spacing-0">
|
<table className="min-w-max border-separate border-spacing-0">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -633,10 +627,7 @@ export default function MelodyComposer() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section className="ui-section-card">
|
||||||
className="rounded-lg border p-4"
|
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs mb-1" style={{ color: "var(--text-muted)" }}>Generated CSV notation</p>
|
<p className="text-xs mb-1" style={{ color: "var(--text-muted)" }}>Generated CSV notation</p>
|
||||||
|
|||||||
@@ -58,9 +58,7 @@ function normalizeFileUrl(url) {
|
|||||||
function Field({ label, children }) {
|
function Field({ label, children }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
|
<dt className="ui-field-label">{label}</dt>
|
||||||
{label}
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm" style={{ color: "var(--text-primary)" }}>{children || "-"}</dd>
|
<dd className="mt-1 text-sm" style={{ color: "var(--text-primary)" }}>{children || "-"}</dd>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -70,9 +68,7 @@ function UrlField({ label, value }) {
|
|||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
|
<dt className="ui-field-label mb-1">{label}</dt>
|
||||||
{label}
|
|
||||||
</dt>
|
|
||||||
<dd className="flex items-center gap-2">
|
<dd className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="text-sm font-mono flex-1 min-w-0"
|
className="text-sm font-mono flex-1 min-w-0"
|
||||||
@@ -354,12 +350,11 @@ export default function MelodyDetail() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Melody Information */}
|
{/* Melody Information */}
|
||||||
<section
|
<section
|
||||||
className="rounded-lg p-6 border"
|
className="ui-section-card"
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
|
||||||
>
|
>
|
||||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
|
<div className="ui-section-card__title-row">
|
||||||
Melody Information
|
<h2 className="ui-section-card__title">Melody Information</h2>
|
||||||
</h2>
|
</div>
|
||||||
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
<Field label="Color">
|
<Field label="Color">
|
||||||
{info.color ? (
|
{info.color ? (
|
||||||
@@ -422,12 +417,11 @@ export default function MelodyDetail() {
|
|||||||
|
|
||||||
{/* Identifiers */}
|
{/* Identifiers */}
|
||||||
<section
|
<section
|
||||||
className="rounded-lg p-6 border"
|
className="ui-section-card"
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
|
||||||
>
|
>
|
||||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
|
<div className="ui-section-card__title-row">
|
||||||
Identifiers
|
<h2 className="ui-section-card__title">Identifiers</h2>
|
||||||
</h2>
|
</div>
|
||||||
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
<Field label="Document ID">{melody.id}</Field>
|
<Field label="Document ID">{melody.id}</Field>
|
||||||
<Field label="PID (Playback ID)">{melody.pid}</Field>
|
<Field label="PID (Playback ID)">{melody.pid}</Field>
|
||||||
@@ -444,12 +438,11 @@ export default function MelodyDetail() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Default Settings */}
|
{/* Default Settings */}
|
||||||
<section
|
<section
|
||||||
className="rounded-lg p-6 border"
|
className="ui-section-card"
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
|
||||||
>
|
>
|
||||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
|
<div className="ui-section-card__title-row">
|
||||||
Default Settings
|
<h2 className="ui-section-card__title">Default Settings</h2>
|
||||||
</h2>
|
</div>
|
||||||
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
<Field label="Speed">
|
<Field label="Speed">
|
||||||
{settings.speed != null ? (
|
{settings.speed != null ? (
|
||||||
@@ -474,7 +467,7 @@ export default function MelodyDetail() {
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 md:col-span-3">
|
<div className="col-span-2 md:col-span-3">
|
||||||
<dt className="text-xs font-medium uppercase tracking-wide mb-2" style={{ color: "var(--text-muted)" }}>Note Assignments</dt>
|
<dt className="ui-field-label mb-2">Note Assignments</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{settings.noteAssignments?.length > 0 ? (
|
{settings.noteAssignments?.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
@@ -512,10 +505,11 @@ export default function MelodyDetail() {
|
|||||||
|
|
||||||
{/* Files */}
|
{/* Files */}
|
||||||
<section
|
<section
|
||||||
className="rounded-lg p-6 border"
|
className="ui-section-card"
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
|
||||||
>
|
>
|
||||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Files</h2>
|
<div className="ui-section-card__title-row">
|
||||||
|
<h2 className="ui-section-card__title">Files</h2>
|
||||||
|
</div>
|
||||||
<dl className="space-y-4">
|
<dl className="space-y-4">
|
||||||
<Field label="Available as Built-In">
|
<Field label="Available as Built-In">
|
||||||
<label className="inline-flex items-center gap-2">
|
<label className="inline-flex items-center gap-2">
|
||||||
@@ -681,12 +675,11 @@ export default function MelodyDetail() {
|
|||||||
{/* Firmware Code section — only shown if a built melody with PROGMEM code is assigned */}
|
{/* Firmware Code section — only shown if a built melody with PROGMEM code is assigned */}
|
||||||
{builtMelody?.progmem_code && (
|
{builtMelody?.progmem_code && (
|
||||||
<section
|
<section
|
||||||
className="rounded-lg p-6 border mt-6"
|
className="ui-section-card mt-6"
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="ui-section-card__title-row">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>Firmware Code</h2>
|
<h2 className="ui-section-card__title">Firmware Code</h2>
|
||||||
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
|
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||||
PROGMEM code for built-in firmware playback · PID: <span className="font-mono">{builtMelody.pid}</span>
|
PROGMEM code for built-in firmware playback · PID: <span className="font-mono">{builtMelody.pid}</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -723,10 +716,11 @@ export default function MelodyDetail() {
|
|||||||
{/* Metadata section */}
|
{/* Metadata section */}
|
||||||
{melody.metadata && (
|
{melody.metadata && (
|
||||||
<section
|
<section
|
||||||
className="rounded-lg p-6 border mt-6"
|
className="ui-section-card mt-6"
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
|
||||||
>
|
>
|
||||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>History</h2>
|
<div className="ui-section-card__title-row">
|
||||||
|
<h2 className="ui-section-card__title">History</h2>
|
||||||
|
</div>
|
||||||
<dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
{melody.metadata.dateCreated && (
|
{melody.metadata.dateCreated && (
|
||||||
<Field label="Date Created">
|
<Field label="Date Created">
|
||||||
@@ -750,10 +744,11 @@ export default function MelodyDetail() {
|
|||||||
|
|
||||||
{/* Admin Notes section */}
|
{/* Admin Notes section */}
|
||||||
<section
|
<section
|
||||||
className="rounded-lg p-6 border mt-6"
|
className="ui-section-card mt-6"
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
|
||||||
>
|
>
|
||||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Admin Notes</h2>
|
<div className="ui-section-card__title-row">
|
||||||
|
<h2 className="ui-section-card__title">Admin Notes</h2>
|
||||||
|
</div>
|
||||||
{(melody.metadata?.adminNotes?.length || 0) > 0 ? (
|
{(melody.metadata?.adminNotes?.length || 0) > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{melody.metadata.adminNotes.map((note, i) => (
|
{melody.metadata.adminNotes.map((note, i) => (
|
||||||
|
|||||||
@@ -46,12 +46,6 @@ const defaultSettings = {
|
|||||||
noteAssignments: [],
|
noteAssignments: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Dark-themed styles
|
|
||||||
const sectionStyle = {
|
|
||||||
backgroundColor: "var(--bg-card)",
|
|
||||||
borderColor: "var(--border-primary)",
|
|
||||||
};
|
|
||||||
const headingStyle = { color: "var(--text-heading)" };
|
|
||||||
const labelStyle = { color: "var(--text-secondary)" };
|
const labelStyle = { color: "var(--text-secondary)" };
|
||||||
const mutedStyle = { color: "var(--text-muted)" };
|
const mutedStyle = { color: "var(--text-muted)" };
|
||||||
|
|
||||||
@@ -408,7 +402,7 @@ export default function MelodyForm() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold" style={headingStyle}>
|
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
|
||||||
{isEdit ? "Edit Melody" : "Add Melody"}
|
{isEdit ? "Edit Melody" : "Add Melody"}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -533,13 +527,15 @@ export default function MelodyForm() {
|
|||||||
{/* ===== Left Column ===== */}
|
{/* ===== Left Column ===== */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* --- Melody Info Section --- */}
|
{/* --- Melody Info Section --- */}
|
||||||
<section className="rounded-lg p-6 border" style={sectionStyle}>
|
<section className="ui-section-card">
|
||||||
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Melody Information</h2>
|
<div className="ui-section-card__title-row">
|
||||||
|
<h2 className="ui-section-card__title">Melody Information</h2>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{/* Name (localized) */}
|
{/* Name (localized) */}
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<label className="block text-sm font-medium" style={labelStyle}>Name *</label>
|
<label className="ui-form-label">Name *</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTranslationModal({ open: true, field: "Name", fieldKey: "name", multiline: false })}
|
onClick={() => setTranslationModal({ open: true, field: "Name", fieldKey: "name", multiline: false })}
|
||||||
@@ -561,7 +557,7 @@ export default function MelodyForm() {
|
|||||||
{/* Description (localized) */}
|
{/* Description (localized) */}
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<label className="block text-sm font-medium" style={labelStyle}>Description</label>
|
<label className="ui-form-label">Description</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTranslationModal({ open: true, field: "Description", fieldKey: "description", multiline: true })}
|
onClick={() => setTranslationModal({ open: true, field: "Description", fieldKey: "description", multiline: true })}
|
||||||
@@ -576,31 +572,31 @@ export default function MelodyForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Melody Tone</label>
|
<label className="ui-form-label">Melody Tone</label>
|
||||||
<select value={information.melodyTone} onChange={(e) => updateInfo("melodyTone", e.target.value)} className={inputClass}>
|
<select value={information.melodyTone} onChange={(e) => updateInfo("melodyTone", e.target.value)} className={inputClass}>
|
||||||
{MELODY_TONES.map((t) => (<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>))}
|
{MELODY_TONES.map((t) => (<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Type</label>
|
<label className="ui-form-label">Type</label>
|
||||||
<select value={type} onChange={(e) => setType(e.target.value)} className={inputClass}>
|
<select value={type} onChange={(e) => setType(e.target.value)} className={inputClass}>
|
||||||
{MELODY_TYPES.map((t) => (<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>))}
|
{MELODY_TYPES.map((t) => (<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Total Archetype Notes</label>
|
<label className="ui-form-label">Total Archetype Notes</label>
|
||||||
<input type="number" min="1" max="16" value={information.totalNotes} onChange={(e) => { const val = parseInt(e.target.value, 10); updateInfo("totalNotes", Math.max(1, Math.min(16, val || 1))); }} className={inputClass} />
|
<input type="number" min="1" max="16" value={information.totalNotes} onChange={(e) => { const val = parseInt(e.target.value, 10); updateInfo("totalNotes", Math.max(1, Math.min(16, val || 1))); }} className={inputClass} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Steps</label>
|
<label className="ui-form-label">Steps</label>
|
||||||
<input type="number" min="0" value={information.steps} onChange={(e) => updateInfo("steps", parseInt(e.target.value, 10) || 0)} className={inputClass} />
|
<input type="number" min="0" value={information.steps} onChange={(e) => updateInfo("steps", parseInt(e.target.value, 10) || 0)} className={inputClass} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Min Speed</label>
|
<label className="ui-form-label">Min Speed</label>
|
||||||
<input type="number" min="0" value={information.minSpeed} onChange={(e) => updateInfo("minSpeed", parseInt(e.target.value, 10) || 0)} className={inputClass} />
|
<input type="number" min="0" value={information.minSpeed} onChange={(e) => updateInfo("minSpeed", parseInt(e.target.value, 10) || 0)} className={inputClass} />
|
||||||
{information.minSpeed > 0 && (
|
{information.minSpeed > 0 && (
|
||||||
<p className="text-xs mt-1" style={mutedStyle}>{minBpm} bpm · {information.minSpeed} ms</p>
|
<p className="text-xs mt-1" style={mutedStyle}>{minBpm} bpm · {information.minSpeed} ms</p>
|
||||||
@@ -608,7 +604,7 @@ export default function MelodyForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Max Speed</label>
|
<label className="ui-form-label">Max Speed</label>
|
||||||
<input type="number" min="0" value={information.maxSpeed} onChange={(e) => updateInfo("maxSpeed", parseInt(e.target.value, 10) || 0)} className={inputClass} />
|
<input type="number" min="0" value={information.maxSpeed} onChange={(e) => updateInfo("maxSpeed", parseInt(e.target.value, 10) || 0)} className={inputClass} />
|
||||||
{information.maxSpeed > 0 && (
|
{information.maxSpeed > 0 && (
|
||||||
<p className="text-xs mt-1" style={mutedStyle}>{maxBpm} bpm · {information.maxSpeed} ms</p>
|
<p className="text-xs mt-1" style={mutedStyle}>{maxBpm} bpm · {information.maxSpeed} ms</p>
|
||||||
@@ -617,7 +613,7 @@ export default function MelodyForm() {
|
|||||||
|
|
||||||
{/* Color */}
|
{/* Color */}
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Color</label>
|
<label className="ui-form-label">Color</label>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span className="w-8 h-8 rounded flex-shrink-0 border" style={{ backgroundColor: information.color ? normalizeColor(information.color) : "transparent", borderColor: "var(--border-primary)" }} />
|
<span className="w-8 h-8 rounded flex-shrink-0 border" style={{ backgroundColor: information.color ? normalizeColor(information.color) : "transparent", borderColor: "var(--border-primary)" }} />
|
||||||
<input type="text" value={information.color} onChange={(e) => updateInfo("color", e.target.value)} placeholder="e.g. #FF5733 or 0xFF5733" className="flex-1 px-3 py-2 rounded-md text-sm border" />
|
<input type="text" value={information.color} onChange={(e) => updateInfo("color", e.target.value)} placeholder="e.g. #FF5733 or 0xFF5733" className="flex-1 px-3 py-2 rounded-md text-sm border" />
|
||||||
@@ -645,7 +641,7 @@ export default function MelodyForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Custom Tags</label>
|
<label className="ui-form-label">Custom Tags</label>
|
||||||
<div className="flex gap-2 mb-2">
|
<div className="flex gap-2 mb-2">
|
||||||
<input type="text" value={tagInput} onChange={(e) => setTagInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addTag(); } }} placeholder="Add a tag and press Enter" className="flex-1 px-3 py-2 rounded-md text-sm border" />
|
<input type="text" value={tagInput} onChange={(e) => setTagInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addTag(); } }} placeholder="Add a tag and press Enter" className="flex-1 px-3 py-2 rounded-md text-sm border" />
|
||||||
<button type="button" onClick={addTag} className="px-3 py-2 text-sm rounded-md transition-colors" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}>Add</button>
|
<button type="button" onClick={addTag} className="px-3 py-2 text-sm rounded-md transition-colors" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}>Add</button>
|
||||||
@@ -665,16 +661,18 @@ export default function MelodyForm() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* --- Identifiers Section --- */}
|
{/* --- Identifiers Section --- */}
|
||||||
<section className="rounded-lg p-6 border" style={sectionStyle}>
|
<section className="ui-section-card">
|
||||||
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Identifiers</h2>
|
<div className="ui-section-card__title-row">
|
||||||
|
<h2 className="ui-section-card__title">Identifiers</h2>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>PID (Playback ID)</label>
|
<label className="ui-form-label">PID (Playback ID)</label>
|
||||||
<input type="text" value={pid} onChange={(e) => setPid(e.target.value)} placeholder="eg. builtin_festive_vesper" className={inputClass} />
|
<input type="text" value={pid} onChange={(e) => setPid(e.target.value)} placeholder="eg. builtin_festive_vesper" className={inputClass} />
|
||||||
</div>
|
</div>
|
||||||
{url && (
|
{url && (
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>URL (auto-set from binary upload)</label>
|
<label className="ui-form-label">URL (auto-set from binary upload)</label>
|
||||||
<input type="text" value={url} readOnly className={inputClass} style={{ opacity: 0.7 }} />
|
<input type="text" value={url} readOnly className={inputClass} style={{ opacity: 0.7 }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -685,11 +683,13 @@ export default function MelodyForm() {
|
|||||||
{/* ===== Right Column ===== */}
|
{/* ===== Right Column ===== */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* --- Default Settings Section --- */}
|
{/* --- Default Settings Section --- */}
|
||||||
<section className="rounded-lg p-6 border" style={sectionStyle}>
|
<section className="ui-section-card">
|
||||||
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Default Settings</h2>
|
<div className="ui-section-card__title-row">
|
||||||
|
<h2 className="ui-section-card__title">Default Settings</h2>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Speed</label>
|
<label className="ui-form-label">Speed</label>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<input type="range" min="1" max="100" value={settings.speed} onChange={(e) => updateSettings("speed", parseInt(e.target.value, 10))} className="flex-1 h-2 rounded-lg appearance-none cursor-pointer" />
|
<input type="range" min="1" max="100" value={settings.speed} onChange={(e) => updateSettings("speed", parseInt(e.target.value, 10))} className="flex-1 h-2 rounded-lg appearance-none cursor-pointer" />
|
||||||
<span className="text-sm font-medium w-12 text-right" style={labelStyle}>{settings.speed}%</span>
|
<span className="text-sm font-medium w-12 text-right" style={labelStyle}>{settings.speed}%</span>
|
||||||
@@ -700,7 +700,7 @@ export default function MelodyForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Duration</label>
|
<label className="ui-form-label">Duration</label>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<input type="range" min="0" max={Math.max(0, durationValues.length - 1)} value={currentDurationIdx} onChange={(e) => updateSettings("duration", durationValues[parseInt(e.target.value, 10)] ?? 0)} className="flex-1 h-2 rounded-lg appearance-none cursor-pointer" />
|
<input type="range" min="0" max={Math.max(0, durationValues.length - 1)} value={currentDurationIdx} onChange={(e) => updateSettings("duration", durationValues[parseInt(e.target.value, 10)] ?? 0)} className="flex-1 h-2 rounded-lg appearance-none cursor-pointer" />
|
||||||
<span className="text-sm font-medium w-24 text-right" style={labelStyle}>{formatDuration(settings.duration)}</span>
|
<span className="text-sm font-medium w-24 text-right" style={labelStyle}>{formatDuration(settings.duration)}</span>
|
||||||
@@ -709,12 +709,12 @@ export default function MelodyForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Total Run Duration</label>
|
<label className="ui-form-label">Total Run Duration</label>
|
||||||
<input type="number" min="0" value={settings.totalRunDuration} onChange={(e) => updateSettings("totalRunDuration", parseInt(e.target.value, 10) || 0)} className={inputClass} />
|
<input type="number" min="0" value={settings.totalRunDuration} onChange={(e) => updateSettings("totalRunDuration", parseInt(e.target.value, 10) || 0)} className={inputClass} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Pause Duration</label>
|
<label className="ui-form-label">Pause Duration</label>
|
||||||
<input type="number" min="0" value={settings.pauseDuration} onChange={(e) => updateSettings("pauseDuration", parseInt(e.target.value, 10) || 0)} className={inputClass} />
|
<input type="number" min="0" value={settings.pauseDuration} onChange={(e) => updateSettings("pauseDuration", parseInt(e.target.value, 10) || 0)} className={inputClass} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -724,13 +724,13 @@ export default function MelodyForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Echo Ring (comma-separated integers)</label>
|
<label className="ui-form-label">Echo Ring (comma-separated integers)</label>
|
||||||
<input type="text" value={settings.echoRing.join(", ")} onChange={(e) => updateSettings("echoRing", parseIntList(e.target.value))} placeholder="e.g. 0, 1, 0, 1" className={inputClass} />
|
<input type="text" value={settings.echoRing.join(", ")} onChange={(e) => updateSettings("echoRing", parseIntList(e.target.value))} placeholder="e.g. 0, 1, 0, 1" className={inputClass} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<label className="block text-sm font-medium" style={labelStyle}>Note Assignments</label>
|
<label className="ui-form-label">Note Assignments</label>
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
|
<span className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
|
||||||
{computeTotalActiveBells(settings.noteAssignments)} active bell{computeTotalActiveBells(settings.noteAssignments) !== 1 ? "s" : ""}
|
{computeTotalActiveBells(settings.noteAssignments)} active bell{computeTotalActiveBells(settings.noteAssignments) !== 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
@@ -752,8 +752,10 @@ export default function MelodyForm() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* --- File Upload Section --- */}
|
{/* --- File Upload Section --- */}
|
||||||
<section className="rounded-lg p-6 border" style={sectionStyle}>
|
<section className="ui-section-card">
|
||||||
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Files</h2>
|
<div className="ui-section-card__title-row">
|
||||||
|
<h2 className="ui-section-card__title">Files</h2>
|
||||||
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -768,7 +770,7 @@ export default function MelodyForm() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Binary File (.bsm)</label>
|
<label className="ui-form-label">Binary File (.bsm)</label>
|
||||||
{(() => {
|
{(() => {
|
||||||
const { effectiveUrl: binaryUrl, effectiveName: binaryName } = getEffectiveBinary();
|
const { effectiveUrl: binaryUrl, effectiveName: binaryName } = getEffectiveBinary();
|
||||||
const missingArchetype = Boolean(pid) && !builtMelody?.id;
|
const missingArchetype = Boolean(pid) && !builtMelody?.id;
|
||||||
@@ -867,7 +869,7 @@ export default function MelodyForm() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Audio Preview (.mp3)</label>
|
<label className="ui-form-label">Audio Preview (.mp3)</label>
|
||||||
{normalizeFileUrl(existingFiles.preview_url) ? (
|
{normalizeFileUrl(existingFiles.preview_url) ? (
|
||||||
<div className="mb-2 space-y-1">
|
<div className="mb-2 space-y-1">
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -916,8 +918,10 @@ export default function MelodyForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* --- Admin Notes Section --- */}
|
{/* --- Admin Notes Section --- */}
|
||||||
<section className="rounded-lg p-6 border mt-6" style={sectionStyle}>
|
<section className="ui-section-card mt-6">
|
||||||
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Admin Notes</h2>
|
<div className="ui-section-card__title-row">
|
||||||
|
<h2 className="ui-section-card__title">Admin Notes</h2>
|
||||||
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{adminNotes.map((note, i) => (
|
{adminNotes.map((note, i) => (
|
||||||
<div key={i} className="flex items-start gap-3 rounded-lg p-3 border" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
|
<div key={i} className="flex items-start gap-3 rounded-lg p-3 border" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
|
||||||
|
|||||||