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}
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/equipment/NoteList.jsx b/frontend/src/equipment/NoteList.jsx
new file mode 100644
index 0000000..5829ea7
--- /dev/null
+++ b/frontend/src/equipment/NoteList.jsx
@@ -0,0 +1,241 @@
+import { useState, useEffect } from "react";
+import { useNavigate, useSearchParams } from "react-router-dom";
+import api from "../api/client";
+import { useAuth } from "../auth/AuthContext";
+import SearchBar from "../components/SearchBar";
+import ConfirmDialog from "../components/ConfirmDialog";
+
+const CATEGORY_OPTIONS = ["", "general", "maintenance", "installation", "issue", "other"];
+
+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)" };
+ }
+};
+
+export default function NoteList() {
+ const [notes, setNotes] = useState([]);
+ const [total, setTotal] = useState(0);
+ 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 navigate = useNavigate();
+ const { hasRole } = useAuth();
+ const canEdit = hasRole("superadmin", "device_manager");
+ const [searchParams] = useSearchParams();
+
+ const deviceIdFilter = searchParams.get("device_id") || "";
+ const userIdFilter = searchParams.get("user_id") || "";
+
+ const fetchNotes = async () => {
+ setLoading(true);
+ setError("");
+ 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 {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchNotes();
+ }, [search, categoryFilter, deviceIdFilter, userIdFilter]);
+
+ const handleDelete = async () => {
+ if (!deleteTarget) return;
+ try {
+ await api.delete(`/equipment/notes/${deleteTarget.id}`);
+ setDeleteTarget(null);
+ fetchNotes();
+ } catch (err) {
+ setError(err.message);
+ setDeleteTarget(null);
+ }
+ };
+
+ return (
+
+
+
Equipment Notes
+ {canEdit && (
+
+ )}
+
+
+
+
+
+
+
+ {total} {total === 1 ? "note" : "notes"}
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {loading ? (
+
Loading...
+ ) : notes.length === 0 ? (
+
+ No notes found.
+
+ ) : (
+
+
+
+
+
+ | Category |
+ Title |
+ Device |
+ User |
+ Created |
+ {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)}
+ >
+ |
+
+ {note.category || "general"}
+
+ |
+
+ {note.title || "Untitled"}
+ |
+
+ {note.device_name || "-"}
+ |
+
+ {note.user_name || "-"}
+ |
+
+ {note.created_at || "-"}
+ |
+ {canEdit && (
+
+ e.stopPropagation()}>
+
+
+
+ |
+ )}
+
+ ))}
+
+
+
+
+ )}
+
+
setDeleteTarget(null)}
+ />
+
+ );
+}
diff --git a/frontend/src/equipment/NotesPanel.jsx b/frontend/src/equipment/NotesPanel.jsx
new file mode 100644
index 0000000..eebbb10
--- /dev/null
+++ b/frontend/src/equipment/NotesPanel.jsx
@@ -0,0 +1,271 @@
+import { useState, useEffect } from "react";
+import { 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)" };
+ }
+};
+
+const CATEGORIES = ["general", "maintenance", "installation", "issue", "other"];
+
+export default function NotesPanel({ deviceId, userId }) {
+ const [notes, setNotes] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [showForm, setShowForm] = useState(false);
+ const [title, setTitle] = useState("");
+ const [content, setContent] = useState("");
+ const [category, setCategory] = useState("general");
+ const [saving, setSaving] = useState(false);
+ const navigate = useNavigate();
+ const { hasRole } = useAuth();
+ const canEdit = hasRole("superadmin", "device_manager");
+
+ const fetchNotes = async () => {
+ setLoading(true);
+ setError("");
+ 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(`/equipment/notes${qs ? `?${qs}` : ""}`);
+ setNotes(data.notes);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchNotes();
+ }, [deviceId, userId]);
+
+ const handleCreate = async (e) => {
+ e.preventDefault();
+ setSaving(true);
+ setError("");
+ try {
+ await api.post("/equipment/notes", {
+ title,
+ content,
+ category,
+ device_id: deviceId || null,
+ user_id: userId || null,
+ });
+ setTitle("");
+ setContent("");
+ setCategory("general");
+ setShowForm(false);
+ fetchNotes();
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleDelete = async () => {
+ if (!deleteTarget) return;
+ try {
+ await api.delete(`/equipment/notes/${deleteTarget.id}`);
+ setDeleteTarget(null);
+ fetchNotes();
+ } catch (err) {
+ setError(err.message);
+ setDeleteTarget(null);
+ }
+ };
+
+ const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
+
+ return (
+
+
+
+ Notes ({notes.length})
+
+
+ {canEdit && (
+
+ )}
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {showForm && (
+
+ )}
+
+ {loading ? (
+ Loading...
+ ) : notes.length === 0 ? (
+
+ No notes yet.
+
+ ) : (
+
+ {notes.map((note) => (
+
navigate(`/equipment/notes/${note.id}`)}
+ >
+
+
+
+
+ {note.category}
+
+
+ {note.title}
+
+
+
+ {note.content}
+
+
+ {note.created_by && `${note.created_by} ยท `}{note.created_at}
+
+
+ {canEdit && (
+
+ )}
+
+
+ ))}
+
+ )}
+
+ setDeleteTarget(null)}
+ />
+
+ );
+}
diff --git a/frontend/src/layout/Sidebar.jsx b/frontend/src/layout/Sidebar.jsx
index 4d6d1b4..43eb59a 100644
--- a/frontend/src/layout/Sidebar.jsx
+++ b/frontend/src/layout/Sidebar.jsx
@@ -23,6 +23,7 @@ const navItems = [
{ to: "/mqtt/logs", label: "Logs" },
],
},
+ { to: "/equipment/notes", label: "Equipment Notes", roles: ["superadmin", "device_manager", "viewer"] },
];
const linkClass = (isActive) =>
diff --git a/frontend/src/users/UserDetail.jsx b/frontend/src/users/UserDetail.jsx
index a11ab3b..cefb7b9 100644
--- a/frontend/src/users/UserDetail.jsx
+++ b/frontend/src/users/UserDetail.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 (
@@ -473,6 +474,9 @@ export default function UserDetail() {
)}
+
+ {/* Equipment Notes */}
+