From f09979c653b3971cd8991d52e1182f2a66d2036f Mon Sep 17 00:00:00 2001 From: bonamin Date: Thu, 19 Feb 2026 06:30:57 +0200 Subject: [PATCH] Major overhaul to the Notes/Issues. Minor tweaks to the UI. Added Profile photos --- backend/devices/models.py | 1 + backend/devices/service.py | 5 + backend/equipment/models.py | 5 +- backend/equipment/router.py | 9 + backend/equipment/service.py | 11 +- backend/helpdesk/__init__.py | 0 backend/helpdesk/models.py | 24 ++ backend/helpdesk/router.py | 39 +++ backend/helpdesk/service.py | 169 +++++++++++ backend/main.py | 2 + backend/users/router.py | 14 +- backend/users/service.py | 28 +- frontend/src/api/client.js | 7 + frontend/src/devices/DeviceDetail.jsx | 132 +++++---- frontend/src/equipment/NoteForm.jsx | 4 +- frontend/src/equipment/NoteList.jsx | 412 ++++++++++++++++++-------- frontend/src/equipment/NotesPanel.jsx | 309 ++++++++++++++----- frontend/src/layout/Sidebar.jsx | 2 +- frontend/src/settings/StaffDetail.jsx | 2 +- frontend/src/settings/StaffForm.jsx | 2 +- frontend/src/users/UserDetail.jsx | 119 ++++++-- 21 files changed, 988 insertions(+), 308 deletions(-) create mode 100644 backend/helpdesk/__init__.py create mode 100644 backend/helpdesk/models.py create mode 100644 backend/helpdesk/router.py create mode 100644 backend/helpdesk/service.py diff --git a/backend/devices/models.py b/backend/devices/models.py index a506b1d..23f513c 100644 --- a/backend/devices/models.py +++ b/backend/devices/models.py @@ -162,6 +162,7 @@ class DeviceUserInfo(BaseModel): display_name: str = "" email: str = "" role: str = "" + photo_url: str = "" class DeviceUsersResponse(BaseModel): diff --git a/backend/devices/service.py b/backend/devices/service.py index f49acdb..d3bbac9 100644 --- a/backend/devices/service.py +++ b/backend/devices/service.py @@ -191,6 +191,7 @@ def get_device_users(device_doc_id: str) -> list[dict]: user_id = "" display_name = "" email = "" + photo_url = "" if isinstance(user_ref, DocumentReference): try: @@ -200,6 +201,7 @@ def get_device_users(device_doc_id: str) -> list[dict]: user_id = user_doc.id display_name = user_data.get("display_name", "") email = user_data.get("email", "") + photo_url = user_data.get("photo_url", "") except Exception as e: print(f"[devices] Error resolving user reference: {e}") continue @@ -212,6 +214,7 @@ def get_device_users(device_doc_id: str) -> list[dict]: user_id = ref_doc.id display_name = user_data.get("display_name", "") email = user_data.get("email", "") + photo_url = user_data.get("photo_url", "") except Exception as e: print(f"[devices] Error resolving user path: {e}") continue @@ -221,6 +224,7 @@ def get_device_users(device_doc_id: str) -> list[dict]: "display_name": display_name, "email": email, "role": role, + "photo_url": photo_url, }) else: # Fallback to user_list field @@ -246,6 +250,7 @@ def get_device_users(device_doc_id: str) -> list[dict]: "display_name": user_data.get("display_name", ""), "email": user_data.get("email", ""), "role": "", + "photo_url": user_data.get("photo_url", ""), }) except Exception as e: print(f"[devices] Error resolving user_list entry: {e}") diff --git a/backend/equipment/models.py b/backend/equipment/models.py index 95dba3c..695fc8d 100644 --- a/backend/equipment/models.py +++ b/backend/equipment/models.py @@ -8,9 +8,10 @@ class NoteCreate(BaseModel): """Create a new equipment note/log entry.""" title: str content: str - category: str = "general" # general, maintenance, installation, issue, other + category: str = "general" # general, maintenance, installation, issue, action_item, other device_id: Optional[str] = None # Firestore doc ID of linked device user_id: Optional[str] = None # Firestore doc ID of linked user + status: str = "" # "", "completed" class NoteUpdate(BaseModel): @@ -20,6 +21,7 @@ class NoteUpdate(BaseModel): category: Optional[str] = None device_id: Optional[str] = None user_id: Optional[str] = None + status: Optional[str] = None class NoteInDB(BaseModel): @@ -35,6 +37,7 @@ class NoteInDB(BaseModel): created_by: str = "" created_at: str = "" updated_at: str = "" + status: str = "" class NoteListResponse(BaseModel): diff --git a/backend/equipment/router.py b/backend/equipment/router.py index 650fbc8..0ad8c9f 100644 --- a/backend/equipment/router.py +++ b/backend/equipment/router.py @@ -50,6 +50,15 @@ async def update_note( return service.update_note(note_id, body) +@router.patch("/{note_id}/status", response_model=NoteInDB) +async def toggle_note_status( + note_id: str, + body: NoteUpdate, + _user: TokenPayload = Depends(require_permission("equipment", "edit")), +): + return service.update_note(note_id, body) + + @router.delete("/{note_id}", status_code=204) async def delete_note( note_id: str, diff --git a/backend/equipment/service.py b/backend/equipment/service.py index e13edb4..eb09ffa 100644 --- a/backend/equipment/service.py +++ b/backend/equipment/service.py @@ -6,7 +6,7 @@ from equipment.models import NoteCreate, NoteUpdate, NoteInDB COLLECTION = "equipment_notes" -VALID_CATEGORIES = {"general", "maintenance", "installation", "issue", "other"} +VALID_CATEGORIES = {"general", "maintenance", "installation", "issue", "action_item", "other"} def _convert_firestore_value(val): @@ -81,7 +81,10 @@ def list_notes( if user_id: query = query.where("user_id", "==", user_id) - query = query.order_by("created_at", direction="DESCENDING") + # Only use order_by when no field filters are applied (avoids composite index requirement) + has_field_filters = bool(category or device_id or user_id) + if not has_field_filters: + query = query.order_by("created_at", direction="DESCENDING") docs = query.stream() results = [] @@ -98,6 +101,10 @@ def list_notes( results.append(note) + # Sort client-side when we couldn't use order_by + if has_field_filters: + results.sort(key=lambda n: n.created_at or "", reverse=True) + return results diff --git a/backend/helpdesk/__init__.py b/backend/helpdesk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/helpdesk/models.py b/backend/helpdesk/models.py new file mode 100644 index 0000000..3d584b9 --- /dev/null +++ b/backend/helpdesk/models.py @@ -0,0 +1,24 @@ +from pydantic import BaseModel +from typing import List, Optional + + +class HelpdeskMessage(BaseModel): + """Helpdesk message as stored in Firestore.""" + id: str + sender_id: str = "" + sender_name: str = "" + type: str = "" # Problem, Suggestion, Question, Other + date_sent: str = "" + subject: str = "" + message: str = "" + phone: str = "" + device_id: str = "" + device_name: str = "" + acknowledged: bool = False + acknowledged_by: str = "" + acknowledged_at: str = "" + + +class HelpdeskListResponse(BaseModel): + messages: List[HelpdeskMessage] + total: int diff --git a/backend/helpdesk/router.py b/backend/helpdesk/router.py new file mode 100644 index 0000000..5f80f31 --- /dev/null +++ b/backend/helpdesk/router.py @@ -0,0 +1,39 @@ +from fastapi import APIRouter, Depends, Query +from typing import Optional +from auth.models import TokenPayload +from auth.dependencies import require_permission +from helpdesk.models import HelpdeskMessage, HelpdeskListResponse +from helpdesk import service + +router = APIRouter(prefix="/api/helpdesk", tags=["helpdesk"]) + + +@router.get("", response_model=HelpdeskListResponse) +async def list_messages( + user_id: Optional[str] = Query(None), + device_id: Optional[str] = Query(None), + type: Optional[str] = Query(None), + _user: TokenPayload = Depends(require_permission("equipment", "view")), +): + messages = service.list_messages( + user_id=user_id, device_id=device_id, msg_type=type, + ) + return HelpdeskListResponse(messages=messages, total=len(messages)) + + +@router.get("/{message_id}", response_model=HelpdeskMessage) +async def get_message( + message_id: str, + _user: TokenPayload = Depends(require_permission("equipment", "view")), +): + return service.get_message(message_id) + + +@router.patch("/{message_id}/acknowledge", response_model=HelpdeskMessage) +async def toggle_acknowledged( + message_id: str, + _user: TokenPayload = Depends(require_permission("equipment", "edit")), +): + return service.toggle_acknowledged( + message_id, acknowledged_by=_user.name or _user.email, + ) diff --git a/backend/helpdesk/service.py b/backend/helpdesk/service.py new file mode 100644 index 0000000..be0ffa9 --- /dev/null +++ b/backend/helpdesk/service.py @@ -0,0 +1,169 @@ +from datetime import datetime, timezone + +from google.cloud.firestore_v1 import DocumentReference + +from shared.firebase import get_db +from shared.exceptions import NotFoundError +from helpdesk.models import HelpdeskMessage + +COLLECTION = "helpdesk" + + +def _convert_firestore_value(val): + """Convert Firestore-specific types to strings.""" + if isinstance(val, datetime): + return val.strftime("%d %B %Y at %H:%M:%S UTC%z") + if isinstance(val, DocumentReference): + return val.path + return val + + +def _resolve_sender(db, sender_ref) -> tuple[str, str]: + """Resolve a sender DocumentReference to (sender_id, sender_name).""" + sender_id = "" + sender_name = "" + + try: + if isinstance(sender_ref, DocumentReference): + doc = sender_ref.get() + if doc.exists: + data = doc.to_dict() + sender_id = doc.id + sender_name = data.get("display_name", "") or data.get("email", "") + elif isinstance(sender_ref, str) and sender_ref.strip(): + # String path like "users/abc123" + doc = db.document(sender_ref).get() + if doc.exists: + data = doc.to_dict() + sender_id = doc.id + sender_name = data.get("display_name", "") or data.get("email", "") + except Exception as e: + print(f"[helpdesk] Error resolving sender: {e}") + + return sender_id, sender_name + + +def _resolve_device_name(db, device_id: str) -> str: + """Look up device_name from device_id.""" + if not device_id: + return "" + try: + doc = db.collection("devices").document(device_id.strip()).get() + if doc.exists: + return doc.to_dict().get("device_name", "") + except Exception as e: + print(f"[helpdesk] Error resolving device name: {e}") + return "" + + +def _doc_to_message(db, doc) -> HelpdeskMessage: + """Convert a Firestore document snapshot to a HelpdeskMessage.""" + data = doc.to_dict() + + # Resolve sender reference + sender_ref = data.get("sender") + sender_id, sender_name = _resolve_sender(db, sender_ref) + + # Handle date_sent (could be Firestore Timestamp) + date_sent = data.get("date_sent", "") + if isinstance(date_sent, datetime): + date_sent = date_sent.strftime("%d %B %Y at %H:%M:%S UTC%z") + + # Resolve device name if device_id present + device_id = data.get("device_id", "") + device_name = "" + if device_id: + device_name = _resolve_device_name(db, device_id) + + # Handle acknowledged_at + acknowledged_at = data.get("acknowledged_at", "") + if isinstance(acknowledged_at, datetime): + acknowledged_at = acknowledged_at.strftime("%d %B %Y at %H:%M:%S UTC%z") + + return HelpdeskMessage( + id=doc.id, + sender_id=sender_id, + sender_name=sender_name, + type=data.get("type", ""), + date_sent=date_sent, + subject=data.get("subject", ""), + message=data.get("message", ""), + phone=data.get("phone", ""), + device_id=device_id, + device_name=device_name, + acknowledged=data.get("acknowledged", False), + acknowledged_by=data.get("acknowledged_by", ""), + acknowledged_at=acknowledged_at, + ) + + +def list_messages( + user_id: str | None = None, + device_id: str | None = None, + msg_type: str | None = None, +) -> list[HelpdeskMessage]: + """List helpdesk messages with optional filters.""" + db = get_db() + ref = db.collection(COLLECTION) + query = ref + + if msg_type: + query = query.where("type", "==", msg_type) + + if device_id: + query = query.where("device_id", "==", device_id) + + docs = list(query.stream()) + results = [] + + for doc in docs: + msg = _doc_to_message(db, doc) + + # Filter by sender user_id client-side (sender is a DocumentReference) + if user_id and msg.sender_id != user_id: + continue + + results.append(msg) + + # Sort by date_sent descending + results.sort(key=lambda m: m.date_sent or "", reverse=True) + + return results + + +def get_message(message_id: str) -> HelpdeskMessage: + """Get a single helpdesk message by ID.""" + db = get_db() + doc = db.collection(COLLECTION).document(message_id).get() + if not doc.exists: + raise NotFoundError("Helpdesk message") + return _doc_to_message(db, doc) + + +def toggle_acknowledged(message_id: str, acknowledged_by: str = "") -> HelpdeskMessage: + """Toggle the acknowledged status of a helpdesk message.""" + db = get_db() + doc_ref = db.collection(COLLECTION).document(message_id) + doc = doc_ref.get() + if not doc.exists: + raise NotFoundError("Helpdesk message") + + data = doc.to_dict() + currently_acknowledged = data.get("acknowledged", False) + now = datetime.now(timezone.utc).strftime("%d %B %Y at %H:%M:%S UTC") + + if currently_acknowledged: + doc_ref.update({ + "acknowledged": False, + "acknowledged_by": "", + "acknowledged_at": "", + }) + else: + doc_ref.update({ + "acknowledged": True, + "acknowledged_by": acknowledged_by, + "acknowledged_at": now, + }) + + updated_doc = doc_ref.get() + return _doc_to_message(db, updated_doc) diff --git a/backend/main.py b/backend/main.py index ef30f90..0d8fbf9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,6 +11,7 @@ from users.router import router as users_router from mqtt.router import router as mqtt_router from equipment.router import router as equipment_router from staff.router import router as staff_router +from helpdesk.router import router as helpdesk_router from mqtt.client import mqtt_manager from mqtt import database as mqtt_db from melodies import service as melody_service @@ -37,6 +38,7 @@ app.include_router(settings_router) app.include_router(users_router) app.include_router(mqtt_router) app.include_router(equipment_router) +app.include_router(helpdesk_router) app.include_router(staff_router) diff --git a/backend/users/router.py b/backend/users/router.py index d66f24d..ddb7e41 100644 --- a/backend/users/router.py +++ b/backend/users/router.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, UploadFile, File from typing import Optional, List from auth.models import TokenPayload from auth.dependencies import require_permission @@ -93,3 +93,15 @@ async def unassign_device( _user: TokenPayload = Depends(require_permission("app_users", "edit")), ): return service.unassign_device(user_id, device_id) + + +@router.post("/{user_id}/photo") +async def upload_photo( + user_id: str, + file: UploadFile = File(...), + _user: TokenPayload = Depends(require_permission("app_users", "edit")), +): + contents = await file.read() + content_type = file.content_type or "image/jpeg" + url = service.upload_photo(user_id, contents, file.filename, content_type) + return {"photo_url": url} diff --git a/backend/users/service.py b/backend/users/service.py index 50b0178..0845efa 100644 --- a/backend/users/service.py +++ b/backend/users/service.py @@ -2,7 +2,7 @@ from datetime import datetime from google.cloud.firestore_v1 import DocumentReference -from shared.firebase import get_db +from shared.firebase import get_db, get_bucket from shared.exceptions import NotFoundError from users.models import UserCreate, UserUpdate, UserInDB @@ -250,3 +250,29 @@ def get_user_devices(user_doc_id: str) -> list[dict]: break return devices + + +def upload_photo(user_doc_id: str, file_bytes: bytes, filename: str, content_type: str) -> str: + """Upload a profile photo to Firebase Storage and update the user's photo_url.""" + db = get_db() + bucket = get_bucket() + if not bucket: + raise RuntimeError("Firebase Storage not initialized") + + # Verify user exists + doc_ref = db.collection(COLLECTION).document(user_doc_id) + doc = doc_ref.get() + if not doc.exists: + raise NotFoundError("User") + + ext = filename.rsplit(".", 1)[-1] if "." in filename else "jpg" + storage_path = f"users/{user_doc_id}/uploads/profile.{ext}" + + blob = bucket.blob(storage_path) + blob.upload_from_string(file_bytes, content_type=content_type) + blob.make_public() + + photo_url = blob.public_url + doc_ref.update({"photo_url": photo_url}) + + return photo_url diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 7c7fc49..332201d 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -54,6 +54,13 @@ class ApiClient { }); } + patch(endpoint, data) { + return this.request(endpoint, { + method: "PATCH", + body: JSON.stringify(data), + }); + } + delete(endpoint) { return this.request(endpoint, { method: "DELETE" }); } diff --git a/frontend/src/devices/DeviceDetail.jsx b/frontend/src/devices/DeviceDetail.jsx index 9d972d3..cf52919 100644 --- a/frontend/src/devices/DeviceDetail.jsx +++ b/frontend/src/devices/DeviceDetail.jsx @@ -485,37 +485,13 @@ export default function DeviceDetail() {

Device Information

- {/* Col 1: Status on top, device image below */} -
-
-
- -
-
-
Status
-
- {isOnline ? "Online" : "Offline"} - {mqttStatus && ( - - {mqttStatus.seconds_since_heartbeat}s ago - - )} -
-
-
-
- {hwVariant} -
+ {/* Col 1: Device image */} +
+ {hwVariant}
{/* Col 2: Serial Number, Hardware Variant, Document ID */} @@ -785,23 +761,35 @@ export default function DeviceDetail() { {attr.bellOutputs.map((output, i) => (
- - {i + 1} - -
-
- Output {output} +
+
+ {i + 1}
-
- {attr.hammerTimings?.[i] != null ? ( - <>{attr.hammerTimings[i]} ms - ) : "-"} +
+
+ Output {output} +
+
+ {attr.hammerTimings?.[i] != null ? ( + <>Timing {attr.hammerTimings[i]} ms + ) : "Timing -"} +
@@ -920,17 +908,28 @@ export default function DeviceDetail() { style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)" }} onClick={() => user.user_id && navigate(`/users/${user.user_id}`)} > -
-
-

- {user.display_name || user.email || "Unknown User"} -

- {user.email && user.display_name && ( -

{user.email}

- )} - {user.user_id && ( -

{user.user_id}

- )} +
+
+
+ {user.photo_url ? ( + + ) : ( +
+ {(user.display_name || user.email || "?").charAt(0).toUpperCase()} +
+ )} +
+
+

+ {user.display_name || user.email || "Unknown User"} +

+ {user.email && user.display_name && ( +

{user.email}

+ )} +
{user.role && ( @@ -1026,11 +1025,20 @@ export default function DeviceDetail() {

{device.device_name || "Unnamed Device"}

- +
+ + + {isOnline ? "Online" : "Offline"} + {mqttStatus && ( + + {mqttStatus.seconds_since_heartbeat}s ago + + )} + +
{canEdit && ( diff --git a/frontend/src/equipment/NoteForm.jsx b/frontend/src/equipment/NoteForm.jsx index 24d1356..d0d751c 100644 --- a/frontend/src/equipment/NoteForm.jsx +++ b/frontend/src/equipment/NoteForm.jsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import api from "../api/client"; -const CATEGORIES = ["general", "maintenance", "installation", "issue", "other"]; +const CATEGORIES = ["general", "maintenance", "installation", "issue", "action_item", "other"]; export default function NoteForm() { const { id } = useParams(); @@ -193,7 +193,7 @@ export default function NoteForm() { > {CATEGORIES.map((c) => ( ))} diff --git a/frontend/src/equipment/NoteList.jsx b/frontend/src/equipment/NoteList.jsx index 2f62c2c..6207ce3 100644 --- a/frontend/src/equipment/NoteList.jsx +++ b/frontend/src/equipment/NoteList.jsx @@ -5,12 +5,15 @@ import { useAuth } from "../auth/AuthContext"; import SearchBar from "../components/SearchBar"; import ConfirmDialog from "../components/ConfirmDialog"; -const CATEGORY_OPTIONS = ["", "general", "maintenance", "installation", "issue", "other"]; +const NOTE_CATEGORIES = ["general", "maintenance", "installation", "other"]; +const ISSUE_CATEGORIES = ["issue", "action_item"]; const categoryStyle = (cat) => { switch (cat) { case "issue": return { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }; + case "action_item": + return { backgroundColor: "var(--badge-blue-bg, rgba(59,130,246,0.15))", color: "var(--badge-blue-text, #3b82f6)" }; case "maintenance": return { backgroundColor: "var(--warning-bg, rgba(245,158,11,0.15))", color: "var(--warning-text, #f59e0b)" }; case "installation": @@ -20,15 +23,30 @@ const categoryStyle = (cat) => { } }; +const helpdeskTypeStyle = (type) => { + switch (type?.toLowerCase()) { + case "problem": + return { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }; + case "suggestion": + return { backgroundColor: "var(--badge-blue-bg, rgba(59,130,246,0.15))", color: "var(--badge-blue-text, #3b82f6)" }; + case "question": + return { backgroundColor: "var(--warning-bg, rgba(245,158,11,0.15))", color: "var(--warning-text, #f59e0b)" }; + default: + return { backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }; + } +}; + +const formatLabel = (s) => s ? s.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()) : ""; + export default function NoteList() { const [notes, setNotes] = useState([]); - const [total, setTotal] = useState(0); + const [helpdeskMessages, setHelpdeskMessages] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [search, setSearch] = useState(""); - const [categoryFilter, setCategoryFilter] = useState(""); const [deleteTarget, setDeleteTarget] = useState(null); const [hoveredRow, setHoveredRow] = useState(null); + const [activeTab, setActiveTab] = useState("notes"); const navigate = useNavigate(); const { hasPermission } = useAuth(); const canEdit = hasPermission("equipment", "edit"); @@ -43,13 +61,11 @@ export default function NoteList() { try { const params = new URLSearchParams(); if (search) params.set("search", search); - if (categoryFilter) params.set("category", categoryFilter); if (deviceIdFilter) params.set("device_id", deviceIdFilter); if (userIdFilter) params.set("user_id", userIdFilter); const qs = params.toString(); const data = await api.get(`/equipment/notes${qs ? `?${qs}` : ""}`); setNotes(data.notes); - setTotal(data.total); } catch (err) { setError(err.message); } finally { @@ -57,9 +73,23 @@ export default function NoteList() { } }; + const fetchHelpdesk = async () => { + try { + const params = new URLSearchParams(); + if (deviceIdFilter) params.set("device_id", deviceIdFilter); + if (userIdFilter) params.set("user_id", userIdFilter); + const qs = params.toString(); + const data = await api.get(`/helpdesk${qs ? `?${qs}` : ""}`); + setHelpdeskMessages(data.messages); + } catch { + // Silently fail for helpdesk if not available + } + }; + useEffect(() => { fetchNotes(); - }, [search, categoryFilter, deviceIdFilter, userIdFilter]); + fetchHelpdesk(); + }, [search, deviceIdFilter, userIdFilter]); const handleDelete = async () => { if (!deleteTarget) return; @@ -73,11 +103,233 @@ export default function NoteList() { } }; + const handleToggleStatus = async (note) => { + try { + const newStatus = note.status === "completed" ? "" : "completed"; + await api.patch(`/equipment/notes/${note.id}/status`, { status: newStatus }); + fetchNotes(); + } catch (err) { + setError(err.message); + } + }; + + const handleToggleAcknowledged = async (msg) => { + try { + await api.patch(`/helpdesk/${msg.id}/acknowledge`); + fetchHelpdesk(); + } catch (err) { + setError(err.message); + } + }; + + // Split notes by tab + const noteItems = notes.filter(n => NOTE_CATEGORIES.includes(n.category)); + const issueItems = notes.filter(n => ISSUE_CATEGORIES.includes(n.category)); + + const tabs = [ + { key: "notes", label: "Notes", count: noteItems.length }, + { key: "issues", label: "Issues & Action Items", count: issueItems.length }, + { key: "messages", label: "Client Messages", count: helpdeskMessages.length }, + ]; + + const renderNotesTable = (items, showStatus = false) => { + if (items.length === 0) { + return ( +
+ No items found. +
+ ); + } + + return ( +
+
+ + + + {showStatus && ( + + + + + + {canEdit && ( + + + + {items.map((note, index) => ( + navigate(`/equipment/notes/${note.id}`)} + className="cursor-pointer" + style={{ + borderBottom: index < items.length - 1 ? "1px solid var(--border-primary)" : "none", + backgroundColor: hoveredRow === note.id ? "var(--bg-card-hover)" : "transparent", + opacity: note.status === "completed" ? 0.6 : 1, + }} + onMouseEnter={() => setHoveredRow(note.id)} + onMouseLeave={() => setHoveredRow(null)} + > + {showStatus && ( + + )} + + + + + + {canEdit && ( + + )} + + ))} + +
+ )} + CategoryTitleDeviceUserCreated + )} +
e.stopPropagation()}> + {canEdit ? ( + + ) : ( + + {note.status === "completed" && "✓"} + + )} + + + {formatLabel(note.category) || "General"} + + + {note.title || "Untitled"} + {note.device_name || "-"}{note.user_name || "-"}{note.created_at || "-"} +
e.stopPropagation()}> + + +
+
+
+
+ ); + }; + + const renderHelpdeskMessages = () => { + const filtered = search + ? helpdeskMessages.filter(m => + (m.subject || "").toLowerCase().includes(search.toLowerCase()) || + (m.message || "").toLowerCase().includes(search.toLowerCase()) + ) + : helpdeskMessages; + + if (filtered.length === 0) { + return ( +
+ No client messages found. +
+ ); + } + + return ( +
+
+ + + + + + + + + + + + {filtered.map((msg, index) => ( + setHoveredRow(msg.id)} + onMouseLeave={() => setHoveredRow(null)} + > + + + + + + + + ))} + +
+ TypeSubjectFromPhoneDate
+ {canEdit && ( + + )} + + + {msg.type || "Other"} + + +
{msg.subject || "No subject"}
+
{msg.message}
+
{msg.sender_name || "-"}{msg.phone || "-"}{msg.date_sent || "-"}
+
+
+ ); + }; + return (
-

Equipment Notes

- {canEdit && ( +

Issues and Notes

+ {canEdit && activeTab !== "messages" && (
-
- -
- - - {total} {total === 1 ? "note" : "notes"} - -
+ {tab.label} + ({tab.count}) + + ))} +
+ +
+
{error && (
{error}
@@ -135,98 +383,12 @@ export default function NoteList() { {loading ? (
Loading...
- ) : notes.length === 0 ? ( -
- No notes found. -
) : ( -
-
- - - - - - - - - {canEdit && ( - - - - {notes.map((note, index) => ( - navigate(`/equipment/notes/${note.id}`)} - className="cursor-pointer" - style={{ - borderBottom: index < notes.length - 1 ? "1px solid var(--border-primary)" : "none", - backgroundColor: hoveredRow === note.id ? "var(--bg-card-hover)" : "transparent", - }} - onMouseEnter={() => setHoveredRow(note.id)} - onMouseLeave={() => setHoveredRow(null)} - > - - - - - - {canEdit && ( - - )} - - ))} - -
CategoryTitleDeviceUserCreated - )} -
- - {note.category || "general"} - - - {note.title || "Untitled"} - - {note.device_name || "-"} - - {note.user_name || "-"} - - {note.created_at || "-"} - -
e.stopPropagation()}> - - -
-
-
-
+ <> + {activeTab === "notes" && renderNotesTable(noteItems, false)} + {activeTab === "issues" && renderNotesTable(issueItems, true)} + {activeTab === "messages" && renderHelpdeskMessages()} + )} { switch (cat) { case "issue": return { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }; + case "action_item": + return { backgroundColor: "var(--badge-blue-bg, rgba(59,130,246,0.15))", color: "var(--badge-blue-text, #3b82f6)" }; case "maintenance": return { backgroundColor: "var(--warning-bg, rgba(245,158,11,0.15))", color: "var(--warning-text, #f59e0b)" }; case "installation": @@ -17,10 +23,24 @@ const categoryStyle = (cat) => { } }; -const CATEGORIES = ["general", "maintenance", "installation", "issue", "other"]; +const helpdeskTypeStyle = (type) => { + switch (type?.toLowerCase()) { + case "problem": + return { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }; + case "suggestion": + return { backgroundColor: "var(--badge-blue-bg, rgba(59,130,246,0.15))", color: "var(--badge-blue-text, #3b82f6)" }; + case "question": + return { backgroundColor: "var(--warning-bg, rgba(245,158,11,0.15))", color: "var(--warning-text, #f59e0b)" }; + default: + return { backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }; + } +}; + +const formatLabel = (s) => s ? s.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()) : ""; export default function NotesPanel({ deviceId, userId }) { const [notes, setNotes] = useState([]); + const [helpdeskMessages, setHelpdeskMessages] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [deleteTarget, setDeleteTarget] = useState(null); @@ -29,6 +49,7 @@ export default function NotesPanel({ deviceId, userId }) { const [content, setContent] = useState(""); const [category, setCategory] = useState("general"); const [saving, setSaving] = useState(false); + const [activeTab, setActiveTab] = useState("notes"); const navigate = useNavigate(); const { hasPermission } = useAuth(); const canEdit = hasPermission("equipment", "edit"); @@ -50,8 +71,22 @@ export default function NotesPanel({ deviceId, userId }) { } }; + const fetchHelpdesk = async () => { + try { + const params = new URLSearchParams(); + if (deviceId) params.set("device_id", deviceId); + if (userId) params.set("user_id", userId); + const qs = params.toString(); + const data = await api.get(`/helpdesk${qs ? `?${qs}` : ""}`); + setHelpdeskMessages(data.messages); + } catch { + // Silently fail + } + }; + useEffect(() => { fetchNotes(); + fetchHelpdesk(); }, [deviceId, userId]); const handleCreate = async (e) => { @@ -90,22 +125,158 @@ export default function NotesPanel({ deviceId, userId }) { } }; + const handleToggleStatus = async (note) => { + try { + const newStatus = note.status === "completed" ? "" : "completed"; + await api.patch(`/equipment/notes/${note.id}/status`, { status: newStatus }); + fetchNotes(); + } catch (err) { + setError(err.message); + } + }; + + const handleToggleAcknowledged = async (msg) => { + try { + await api.patch(`/helpdesk/${msg.id}/acknowledge`); + fetchHelpdesk(); + } catch (err) { + setError(err.message); + } + }; + + // Split notes + const noteItems = notes.filter(n => NOTE_CATEGORIES.includes(n.category)); + const issueItems = notes.filter(n => ISSUE_CATEGORIES.includes(n.category)); + + const tabs = [ + { key: "notes", label: "Notes", count: noteItems.length }, + { key: "issues", label: "Issues", count: issueItems.length }, + { key: "messages", label: "Messages", count: helpdeskMessages.length }, + ]; + + const totalCount = notes.length + helpdeskMessages.length; + const inputClass = "w-full px-3 py-2 rounded-md text-sm border"; + const renderNoteItem = (note, showStatus = false) => ( +
navigate(`/equipment/notes/${note.id}`)} + > +
+
+ {showStatus && ( + + )} +
+
+ + {formatLabel(note.category)} + + + {note.title} + +
+

{note.content}

+

+ {note.created_by && `${note.created_by} · `}{note.created_at} +

+
+
+ {canEdit && ( + + )} +
+
+ ); + + const renderHelpdeskItem = (msg) => ( +
+
+
+ {canEdit && ( + + )} +
+
+ + {msg.type || "Other"} + + + {msg.subject || "No subject"} + +
+

{msg.message}

+

+ {msg.sender_name && `${msg.sender_name} · `}{msg.phone && `${msg.phone} · `}{msg.date_sent} +

+
+
+
+
+ ); + return (
-
-

- Notes ({notes.length}) +
+

+ Issues & Notes ({totalCount})

- {canEdit && ( + {canEdit && activeTab !== "messages" && (
+ {/* Compact tabs */} +
+ {tabs.map((tab) => ( + + ))} +
+ {error && (
{error}
)} - {showForm && ( + {showForm && activeTab !== "messages" && (
@@ -154,27 +339,17 @@ export default function NotesPanel({ deviceId, userId }) { onChange={(e) => setTitle(e.target.value)} placeholder="Note title" className={inputClass} - style={{ - backgroundColor: "var(--bg-card)", - color: "var(--text-primary)", - borderColor: "var(--border-primary)", - }} + style={{ backgroundColor: "var(--bg-card)", color: "var(--text-primary)", borderColor: "var(--border-primary)" }} />
@@ -185,12 +360,7 @@ export default function NotesPanel({ deviceId, userId }) { rows={3} placeholder="Note content..." className={inputClass} - style={{ - backgroundColor: "var(--bg-card)", - color: "var(--text-primary)", - borderColor: "var(--border-primary)", - resize: "vertical", - }} + style={{ backgroundColor: "var(--bg-card)", color: "var(--text-primary)", borderColor: "var(--border-primary)", resize: "vertical" }} />
- )} -
-

- ))} -
+ <> + {activeTab === "notes" && ( + noteItems.length === 0 ? ( +

No notes yet.

+ ) : ( +
{noteItems.map(n => renderNoteItem(n, false))}
+ ) + )} + {activeTab === "issues" && ( + issueItems.length === 0 ? ( +

No issues or action items.

+ ) : ( +
{issueItems.map(n => renderNoteItem(n, true))}
+ ) + )} + {activeTab === "messages" && ( + helpdeskMessages.length === 0 ? ( +

No client messages.

+ ) : ( +
{helpdeskMessages.map(renderHelpdeskItem)}
+ ) + )} + )} diff --git a/frontend/src/settings/StaffDetail.jsx b/frontend/src/settings/StaffDetail.jsx index 9dba74b..49db07b 100644 --- a/frontend/src/settings/StaffDetail.jsx +++ b/frontend/src/settings/StaffDetail.jsx @@ -15,7 +15,7 @@ const SECTIONS = [ { key: "melodies", label: "Melodies" }, { key: "devices", label: "Devices" }, { key: "app_users", label: "App Users" }, - { key: "equipment", label: "Equipment Notes" }, + { key: "equipment", label: "Issues and Notes" }, ]; const ACTIONS = ["view", "add", "edit", "delete"]; diff --git a/frontend/src/settings/StaffForm.jsx b/frontend/src/settings/StaffForm.jsx index 5446dd2..29f0896 100644 --- a/frontend/src/settings/StaffForm.jsx +++ b/frontend/src/settings/StaffForm.jsx @@ -7,7 +7,7 @@ const SECTIONS = [ { key: "melodies", label: "Melodies" }, { key: "devices", label: "Devices" }, { key: "app_users", label: "App Users" }, - { key: "equipment", label: "Equipment Notes" }, + { key: "equipment", label: "Issues and Notes" }, ]; const ACTIONS = ["view", "add", "edit", "delete"]; diff --git a/frontend/src/users/UserDetail.jsx b/frontend/src/users/UserDetail.jsx index 8a47f06..cdb667c 100644 --- a/frontend/src/users/UserDetail.jsx +++ b/frontend/src/users/UserDetail.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { useParams, useNavigate } from "react-router-dom"; import api from "../api/client"; import { useAuth } from "../auth/AuthContext"; @@ -37,6 +37,8 @@ export default function UserDetail() { const [assigningDevice, setAssigningDevice] = useState(false); const [selectedDeviceId, setSelectedDeviceId] = useState(""); const [showAssignPanel, setShowAssignPanel] = useState(false); + const [uploadingPhoto, setUploadingPhoto] = useState(false); + const photoInputRef = useRef(null); useEffect(() => { loadData(); @@ -126,6 +128,22 @@ export default function UserDetail() { loadAllDevices(); }; + const handlePhotoUpload = async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + setUploadingPhoto(true); + setError(""); + try { + const result = await api.upload(`/users/${id}/photo`, file); + setUser((prev) => ({ ...prev, photo_url: result.photo_url })); + } catch (err) { + setError(err.message); + } finally { + setUploadingPhoto(false); + if (photoInputRef.current) photoInputRef.current.value = ""; + } + }; + if (loading) { return (
@@ -247,33 +265,77 @@ export default function UserDetail() { > Account Information -
- - - {user.id} - - - - {user.uid} - - - + {/* Profile Photo */} +
+
- {user.status || "unknown"} - - - {user.email} - {user.phone_number} - {user.userTitle} -
+ {user.photo_url ? ( + {user.display_name + ) : ( +
+ {(user.display_name || user.email || "?").charAt(0).toUpperCase()} +
+ )} +
+ {canEdit && ( + <> + + + + )} +
+ {/* Fields */} +
+ + + {user.id} + + + + {user.uid} + + + + {user.status || "unknown"} + + + {user.email} + {user.phone_number} + {user.userTitle} +
+
{/* Profile */} @@ -289,7 +351,6 @@ export default function UserDetail() {
{user.bio} - {user.photo_url}
@@ -475,7 +536,7 @@ export default function UserDetail() { )} - {/* Equipment Notes */} + {/* Issues and Notes */}