From d6e522deb8770c433bc81d8110bb7f3d96540163 Mon Sep 17 00:00:00 2001 From: bonamin Date: Tue, 17 Feb 2026 23:57:23 +0200 Subject: [PATCH] Phase 6 Complete by Claude Code --- backend/equipment/models.py | 43 +++- backend/equipment/router.py | 59 ++++- backend/equipment/service.py | 169 ++++++++++++++- backend/main.py | 2 + frontend/src/App.jsx | 7 + frontend/src/devices/DeviceDetail.jsx | 4 + frontend/src/equipment/NoteDetail.jsx | 296 ++++++++++++++++++++++++++ frontend/src/equipment/NoteForm.jsx | 266 +++++++++++++++++++++++ frontend/src/equipment/NoteList.jsx | 241 +++++++++++++++++++++ frontend/src/equipment/NotesPanel.jsx | 271 +++++++++++++++++++++++ frontend/src/layout/Sidebar.jsx | 1 + frontend/src/users/UserDetail.jsx | 4 + 12 files changed, 1360 insertions(+), 3 deletions(-) create mode 100644 frontend/src/equipment/NoteDetail.jsx create mode 100644 frontend/src/equipment/NoteForm.jsx create mode 100644 frontend/src/equipment/NoteList.jsx create mode 100644 frontend/src/equipment/NotesPanel.jsx diff --git a/backend/equipment/models.py b/backend/equipment/models.py index ca1a72c..95dba3c 100644 --- a/backend/equipment/models.py +++ b/backend/equipment/models.py @@ -1 +1,42 @@ -# TODO: Equipment Pydantic schemas +from pydantic import BaseModel +from typing import List, Optional + + +# --- Request / Response schemas --- + +class NoteCreate(BaseModel): + """Create a new equipment note/log entry.""" + title: str + content: str + category: str = "general" # general, maintenance, installation, issue, other + device_id: Optional[str] = None # Firestore doc ID of linked device + user_id: Optional[str] = None # Firestore doc ID of linked user + + +class NoteUpdate(BaseModel): + """Update an existing note. Only provided fields are updated.""" + title: Optional[str] = None + content: Optional[str] = None + category: Optional[str] = None + device_id: Optional[str] = None + user_id: Optional[str] = None + + +class NoteInDB(BaseModel): + """Note as stored in Firestore.""" + id: str + title: str = "" + content: str = "" + category: str = "general" + device_id: str = "" + user_id: str = "" + device_name: str = "" + user_name: str = "" + created_by: str = "" + created_at: str = "" + updated_at: str = "" + + +class NoteListResponse(BaseModel): + notes: List[NoteInDB] + total: int diff --git a/backend/equipment/router.py b/backend/equipment/router.py index ca4f407..91942a0 100644 --- a/backend/equipment/router.py +++ b/backend/equipment/router.py @@ -1 +1,58 @@ -# TODO: Complementary devices / notes endpoints +from fastapi import APIRouter, Depends, Query +from typing import Optional +from auth.models import TokenPayload +from auth.dependencies import require_device_access, require_viewer +from equipment.models import ( + NoteCreate, NoteUpdate, NoteInDB, NoteListResponse, +) +from equipment import service + +router = APIRouter(prefix="/api/equipment/notes", tags=["equipment"]) + + +@router.get("", response_model=NoteListResponse) +async def list_notes( + search: Optional[str] = Query(None), + category: Optional[str] = Query(None), + device_id: Optional[str] = Query(None), + user_id: Optional[str] = Query(None), + _user: TokenPayload = Depends(require_viewer), +): + notes = service.list_notes( + search=search, category=category, + device_id=device_id, user_id=user_id, + ) + return NoteListResponse(notes=notes, total=len(notes)) + + +@router.get("/{note_id}", response_model=NoteInDB) +async def get_note( + note_id: str, + _user: TokenPayload = Depends(require_viewer), +): + return service.get_note(note_id) + + +@router.post("", response_model=NoteInDB, status_code=201) +async def create_note( + body: NoteCreate, + _user: TokenPayload = Depends(require_device_access), +): + return service.create_note(body, created_by=_user.name or _user.email) + + +@router.put("/{note_id}", response_model=NoteInDB) +async def update_note( + note_id: str, + body: NoteUpdate, + _user: TokenPayload = Depends(require_device_access), +): + return service.update_note(note_id, body) + + +@router.delete("/{note_id}", status_code=204) +async def delete_note( + note_id: str, + _user: TokenPayload = Depends(require_device_access), +): + service.delete_note(note_id) diff --git a/backend/equipment/service.py b/backend/equipment/service.py index 52a18e9..3086ad5 100644 --- a/backend/equipment/service.py +++ b/backend/equipment/service.py @@ -1 +1,168 @@ -# TODO: Equipment Firestore operations +from datetime import datetime, timezone + +from shared.firebase import get_db +from shared.exceptions import NotFoundError +from equipment.models import NoteCreate, NoteUpdate, NoteInDB + +COLLECTION = "equipment_notes" + +VALID_CATEGORIES = {"general", "maintenance", "installation", "issue", "other"} + + +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") + return val + + +def _sanitize_dict(d: dict) -> dict: + """Recursively convert Firestore-native types in a dict to plain strings.""" + result = {} + for k, v in d.items(): + if isinstance(v, dict): + result[k] = _sanitize_dict(v) + elif isinstance(v, list): + result[k] = [ + _sanitize_dict(item) if isinstance(item, dict) + else _convert_firestore_value(item) + for item in v + ] + else: + result[k] = _convert_firestore_value(v) + return result + + +def _doc_to_note(doc) -> NoteInDB: + """Convert a Firestore document snapshot to a NoteInDB model.""" + data = _sanitize_dict(doc.to_dict()) + return NoteInDB(id=doc.id, **data) + + +def _resolve_names(db, device_id: str | None, user_id: str | None) -> tuple[str, str]: + """Look up device_name and user_name from their IDs.""" + device_name = "" + user_name = "" + + if device_id: + device_doc = db.collection("devices").document(device_id).get() + if device_doc.exists: + device_name = device_doc.to_dict().get("device_name", "") + + if user_id: + user_doc = db.collection("users").document(user_id).get() + if user_doc.exists: + user_doc_data = user_doc.to_dict() + user_name = user_doc_data.get("display_name", "") or user_doc_data.get("email", "") + + return device_name, user_name + + +def list_notes( + search: str | None = None, + category: str | None = None, + device_id: str | None = None, + user_id: str | None = None, +) -> list[NoteInDB]: + """List notes with optional filters.""" + db = get_db() + ref = db.collection(COLLECTION) + query = ref + + if category and category in VALID_CATEGORIES: + query = query.where("category", "==", category) + + if device_id: + query = query.where("device_id", "==", device_id) + + if user_id: + query = query.where("user_id", "==", user_id) + + query = query.order_by("created_at", direction="DESCENDING") + + docs = query.stream() + results = [] + + for doc in docs: + note = _doc_to_note(doc) + + if search: + search_lower = search.lower() + title_match = search_lower in (note.title or "").lower() + content_match = search_lower in (note.content or "").lower() + if not (title_match or content_match): + continue + + results.append(note) + + return results + + +def get_note(note_id: str) -> NoteInDB: + """Get a single note by Firestore document ID.""" + db = get_db() + doc = db.collection(COLLECTION).document(note_id).get() + if not doc.exists: + raise NotFoundError("Note") + return _doc_to_note(doc) + + +def create_note(data: NoteCreate, created_by: str = "") -> NoteInDB: + """Create a new note document in Firestore.""" + db = get_db() + now = datetime.now(timezone.utc).strftime("%d %B %Y at %H:%M:%S UTC") + + device_name, user_name = _resolve_names(db, data.device_id, data.user_id) + + doc_data = data.model_dump() + doc_data["device_id"] = data.device_id or "" + doc_data["user_id"] = data.user_id or "" + doc_data["device_name"] = device_name + doc_data["user_name"] = user_name + doc_data["created_by"] = created_by + doc_data["created_at"] = now + doc_data["updated_at"] = now + + _, doc_ref = db.collection(COLLECTION).add(doc_data) + + return NoteInDB(id=doc_ref.id, **doc_data) + + +def update_note(note_id: str, data: NoteUpdate) -> NoteInDB: + """Update an existing note. Only provided fields are updated.""" + db = get_db() + doc_ref = db.collection(COLLECTION).document(note_id) + doc = doc_ref.get() + if not doc.exists: + raise NotFoundError("Note") + + update_data = data.model_dump(exclude_none=True) + + # Re-resolve names if device_id or user_id changed + existing = doc.to_dict() + new_device_id = update_data.get("device_id", existing.get("device_id", "")) + new_user_id = update_data.get("user_id", existing.get("user_id", "")) + + if "device_id" in update_data or "user_id" in update_data: + device_name, user_name = _resolve_names(db, new_device_id, new_user_id) + if "device_id" in update_data: + update_data["device_name"] = device_name + if "user_id" in update_data: + update_data["user_name"] = user_name + + update_data["updated_at"] = datetime.now(timezone.utc).strftime("%d %B %Y at %H:%M:%S UTC") + doc_ref.update(update_data) + + updated_doc = doc_ref.get() + return _doc_to_note(updated_doc) + + +def delete_note(note_id: str) -> None: + """Delete a note document from Firestore.""" + db = get_db() + doc_ref = db.collection(COLLECTION).document(note_id) + doc = doc_ref.get() + if not doc.exists: + raise NotFoundError("Note") + + doc_ref.delete() diff --git a/backend/main.py b/backend/main.py index e8d0b31..c01f98d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,6 +9,7 @@ from devices.router import router as devices_router from settings.router import router as settings_router 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 mqtt.client import mqtt_manager from mqtt import database as mqtt_db @@ -33,6 +34,7 @@ app.include_router(devices_router) app.include_router(settings_router) app.include_router(users_router) app.include_router(mqtt_router) +app.include_router(equipment_router) @app.on_event("startup") diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8aa684a..574dada 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,6 +15,9 @@ import UserForm from "./users/UserForm"; import MqttDashboard from "./mqtt/MqttDashboard"; import CommandPanel from "./mqtt/CommandPanel"; import LogViewer from "./mqtt/LogViewer"; +import NoteList from "./equipment/NoteList"; +import NoteDetail from "./equipment/NoteDetail"; +import NoteForm from "./equipment/NoteForm"; function ProtectedRoute({ children }) { const { user, loading } = useAuth(); @@ -75,6 +78,10 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> diff --git a/frontend/src/devices/DeviceDetail.jsx b/frontend/src/devices/DeviceDetail.jsx index 80b1ff0..c51d1ab 100644 --- a/frontend/src/devices/DeviceDetail.jsx +++ b/frontend/src/devices/DeviceDetail.jsx @@ -3,6 +3,7 @@ import { useParams, useNavigate } from "react-router-dom"; import api from "../api/client"; import { useAuth } from "../auth/AuthContext"; import ConfirmDialog from "../components/ConfirmDialog"; +import NotesPanel from "../equipment/NotesPanel"; function Field({ label, children }) { return ( @@ -379,6 +380,9 @@ export default function DeviceDetail() { + + {/* Equipment Notes */} + diff --git a/frontend/src/equipment/NoteDetail.jsx b/frontend/src/equipment/NoteDetail.jsx new file mode 100644 index 0000000..2b2a4aa --- /dev/null +++ b/frontend/src/equipment/NoteDetail.jsx @@ -0,0 +1,296 @@ +import { useState, useEffect } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import api from "../api/client"; +import { useAuth } from "../auth/AuthContext"; +import ConfirmDialog from "../components/ConfirmDialog"; + +const categoryStyle = (cat) => { + switch (cat) { + case "issue": + return { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }; + case "maintenance": + return { backgroundColor: "var(--warning-bg, rgba(245,158,11,0.15))", color: "var(--warning-text, #f59e0b)" }; + case "installation": + return { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }; + default: + return { backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }; + } +}; + +function Field({ label, children }) { + return ( +
+
+ {label} +
+
+ {children || "-"} +
+
+ ); +} + +export default function NoteDetail() { + const { id } = useParams(); + const navigate = useNavigate(); + const { hasRole } = useAuth(); + const canEdit = hasRole("superadmin", "device_manager"); + + const [note, setNote] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [showDelete, setShowDelete] = useState(false); + + useEffect(() => { + loadNote(); + }, [id]); + + const loadNote = async () => { + setLoading(true); + try { + const data = await api.get(`/equipment/notes/${id}`); + setNote(data); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const handleDelete = async () => { + try { + await api.delete(`/equipment/notes/${id}`); + navigate("/equipment/notes"); + } catch (err) { + setError(err.message); + setShowDelete(false); + } + }; + + if (loading) { + return ( +
+ Loading... +
+ ); + } + + if (error && !note) { + return ( +
+ {error} +
+ ); + } + + if (!note) return null; + + return ( +
+
+
+ +
+

+ {note.title || "Untitled Note"} +

+ + {note.category || "general"} + +
+
+ {canEdit && ( +
+ + +
+ )} +
+ + {error && ( +
+ {error} +
+ )} + +
+ {/* Left Column */} +
+ {/* Note Content */} +
+

+ Content +

+
+ {note.content || "No content."} +
+
+ + {/* Timestamps */} +
+

+ Details +

+
+ + + {note.category || "general"} + + + {note.created_by} + + + {note.id} + + + {note.created_at} + {note.updated_at} +
+
+
+ + {/* Right Column */} +
+ {/* Linked Device */} +
+

+ Linked Device +

+ {note.device_id ? ( +
+
+

+ {note.device_name || "Unnamed Device"} +

+

+ {note.device_id} +

+
+ +
+ ) : ( +

+ No device linked. +

+ )} +
+ + {/* Linked User */} +
+

+ Linked User +

+ {note.user_id ? ( +
+
+

+ {note.user_name || "Unnamed User"} +

+

+ {note.user_id} +

+
+ +
+ ) : ( +

+ No user linked. +

+ )} +
+
+
+ + setShowDelete(false)} + /> +
+ ); +} diff --git a/frontend/src/equipment/NoteForm.jsx b/frontend/src/equipment/NoteForm.jsx new file mode 100644 index 0000000..24d1356 --- /dev/null +++ b/frontend/src/equipment/NoteForm.jsx @@ -0,0 +1,266 @@ +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"]; + +export default function NoteForm() { + const { id } = useParams(); + const isEdit = Boolean(id); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + const [category, setCategory] = useState("general"); + const [deviceId, setDeviceId] = useState(searchParams.get("device_id") || ""); + const [userId, setUserId] = useState(searchParams.get("user_id") || ""); + + const [devices, setDevices] = useState([]); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + loadOptions(); + if (isEdit) loadNote(); + }, [id]); + + const loadOptions = async () => { + try { + const [devData, usrData] = await Promise.all([ + api.get("/devices"), + api.get("/users"), + ]); + setDevices(devData.devices || []); + setUsers(usrData.users || []); + } catch { + // Non-critical โ€” dropdowns will just be empty + } + }; + + const loadNote = async () => { + setLoading(true); + try { + const note = await api.get(`/equipment/notes/${id}`); + setTitle(note.title || ""); + setContent(note.content || ""); + setCategory(note.category || "general"); + setDeviceId(note.device_id || ""); + setUserId(note.user_id || ""); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setSaving(true); + setError(""); + + try { + const body = { + title, + content, + category, + device_id: deviceId || null, + user_id: userId || null, + }; + + let noteId = id; + if (isEdit) { + await api.put(`/equipment/notes/${id}`, body); + } else { + const created = await api.post("/equipment/notes", body); + noteId = created.id; + } + + navigate(`/equipment/notes/${noteId}`); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + }; + + if (loading) { + return
Loading...
; + } + + const inputClass = "w-full px-3 py-2 rounded-md text-sm border"; + + return ( +
+
+

+ {isEdit ? "Edit Note" : "Add Note"} +

+
+ + +
+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ {/* Left Column โ€” Note Content */} +
+
+

+ Note Details +

+
+
+ + setTitle(e.target.value)} + placeholder="Brief description of the note" + className={inputClass} + style={{ + backgroundColor: "var(--bg-primary)", + color: "var(--text-primary)", + borderColor: "var(--border-primary)", + }} + /> +
+
+ +