chore: untrack .claude/ folder and update .gitignore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
|
||||||
@@ -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`
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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.
|
|
||||||
@@ -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 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
|
|
||||||
@@ -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 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
|
|
||||||
@@ -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 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)
|
|
||||||
@@ -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 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)
|
|
||||||
@@ -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 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
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -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 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
|
|
||||||
@@ -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 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.
|
|
||||||
@@ -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 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
|
|
||||||
@@ -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
3
.gitignore
vendored
@@ -33,3 +33,6 @@ Thumbs.db
|
|||||||
.MAIN-APP-REFERENCE/
|
.MAIN-APP-REFERENCE/
|
||||||
|
|
||||||
.project-vesper-plan.md
|
.project-vesper-plan.md
|
||||||
|
|
||||||
|
# claude
|
||||||
|
.claude/
|
||||||
Reference in New Issue
Block a user