Compare commits

..

2 Commits

Author SHA1 Message Date
8c15c932b6 chore: untrack .claude/ folder and update .gitignore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 11:39:42 +02:00
c62188fda6 update: Major Overhaul to all subsystems 2026-03-07 11:36:46 +02:00
16 changed files with 4 additions and 1314 deletions

View File

@@ -1,153 +0,0 @@
# 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

View File

@@ -1,243 +0,0 @@
# 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`

View File

@@ -1,49 +0,0 @@
# 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

View File

@@ -1,61 +0,0 @@
# 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)

View File

@@ -1,60 +0,0 @@
# 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.

View File

@@ -1,96 +0,0 @@
# CRM Step 04 — Backend: Comms Log + Media (SQLite)
## Context
Read `.claude/crm-build-plan.md` for full schema, conventions, and IMPORTANT NOTES.
Steps 0103 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

View File

@@ -1,55 +0,0 @@
# CRM Step 05 — Frontend: Products Module
## Context
Read `.claude/crm-build-plan.md` for full context and IMPORTANT NOTES.
Backend Steps 0104 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

View File

@@ -1,84 +0,0 @@
# 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 0104 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)

View File

@@ -1,71 +0,0 @@
# CRM Step 07 — Frontend: Orders Module
## Context
Read `.claude/crm-build-plan.md` for full context, data models, and IMPORTANT NOTES.
Steps 0106 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)

View File

@@ -1,53 +0,0 @@
# CRM Step 08 — Frontend: Comms Log + Media (Manual Entry Polish)
## Context
Read `.claude/crm-build-plan.md` for full context and IMPORTANT NOTES.
Steps 0107 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

View File

@@ -1,92 +0,0 @@
# CRM Step 09 — Integration: Nextcloud WebDAV
## Context
Read `.claude/crm-build-plan.md` for full context and IMPORTANT NOTES.
Steps 0108 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

View File

@@ -1,102 +0,0 @@
# CRM Step 10 — Integration: IMAP/SMTP Email
## Context
Read `.claude/crm-build-plan.md` for full context and IMPORTANT NOTES.
Steps 0109 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

View File

@@ -1,81 +0,0 @@
# CRM Step 11 — Integration: WhatsApp Business API
## Context
Read `.claude/crm-build-plan.md` for full context and IMPORTANT NOTES.
Steps 0110 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.

View File

@@ -1,97 +0,0 @@
# CRM Step 12 — Integration: FreePBX AMI Call Logging
## Context
Read `.claude/crm-build-plan.md` for full context and IMPORTANT NOTES.
Steps 0111 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

View File

@@ -1,16 +0,0 @@
{
"permissions": {
"allow": [
"Bash(npm create:*)",
"Bash(npm install:*)",
"Bash(npm run build:*)",
"Bash(python -c:*)",
"Bash(npx vite build:*)",
"Bash(wc:*)",
"Bash(ls:*)",
"Bash(node -c:*)",
"Bash(npm run lint:*)",
"Bash(python:*)"
]
}
}

3
.gitignore vendored
View File

@@ -33,3 +33,6 @@ Thumbs.db
.MAIN-APP-REFERENCE/
.project-vesper-plan.md
# claude
.claude/