Major overhaul to the Notes/Issues. Minor tweaks to the UI. Added Profile photos

This commit is contained in:
2026-02-19 06:30:57 +02:00
parent a9a1531d57
commit f09979c653
21 changed files with 988 additions and 308 deletions

View File

@@ -162,6 +162,7 @@ class DeviceUserInfo(BaseModel):
display_name: str = "" display_name: str = ""
email: str = "" email: str = ""
role: str = "" role: str = ""
photo_url: str = ""
class DeviceUsersResponse(BaseModel): class DeviceUsersResponse(BaseModel):

View File

@@ -191,6 +191,7 @@ def get_device_users(device_doc_id: str) -> list[dict]:
user_id = "" user_id = ""
display_name = "" display_name = ""
email = "" email = ""
photo_url = ""
if isinstance(user_ref, DocumentReference): if isinstance(user_ref, DocumentReference):
try: try:
@@ -200,6 +201,7 @@ def get_device_users(device_doc_id: str) -> list[dict]:
user_id = user_doc.id user_id = user_doc.id
display_name = user_data.get("display_name", "") display_name = user_data.get("display_name", "")
email = user_data.get("email", "") email = user_data.get("email", "")
photo_url = user_data.get("photo_url", "")
except Exception as e: except Exception as e:
print(f"[devices] Error resolving user reference: {e}") print(f"[devices] Error resolving user reference: {e}")
continue continue
@@ -212,6 +214,7 @@ def get_device_users(device_doc_id: str) -> list[dict]:
user_id = ref_doc.id user_id = ref_doc.id
display_name = user_data.get("display_name", "") display_name = user_data.get("display_name", "")
email = user_data.get("email", "") email = user_data.get("email", "")
photo_url = user_data.get("photo_url", "")
except Exception as e: except Exception as e:
print(f"[devices] Error resolving user path: {e}") print(f"[devices] Error resolving user path: {e}")
continue continue
@@ -221,6 +224,7 @@ def get_device_users(device_doc_id: str) -> list[dict]:
"display_name": display_name, "display_name": display_name,
"email": email, "email": email,
"role": role, "role": role,
"photo_url": photo_url,
}) })
else: else:
# Fallback to user_list field # 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", ""), "display_name": user_data.get("display_name", ""),
"email": user_data.get("email", ""), "email": user_data.get("email", ""),
"role": "", "role": "",
"photo_url": user_data.get("photo_url", ""),
}) })
except Exception as e: except Exception as e:
print(f"[devices] Error resolving user_list entry: {e}") print(f"[devices] Error resolving user_list entry: {e}")

View File

@@ -8,9 +8,10 @@ class NoteCreate(BaseModel):
"""Create a new equipment note/log entry.""" """Create a new equipment note/log entry."""
title: str title: str
content: 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 device_id: Optional[str] = None # Firestore doc ID of linked device
user_id: Optional[str] = None # Firestore doc ID of linked user user_id: Optional[str] = None # Firestore doc ID of linked user
status: str = "" # "", "completed"
class NoteUpdate(BaseModel): class NoteUpdate(BaseModel):
@@ -20,6 +21,7 @@ class NoteUpdate(BaseModel):
category: Optional[str] = None category: Optional[str] = None
device_id: Optional[str] = None device_id: Optional[str] = None
user_id: Optional[str] = None user_id: Optional[str] = None
status: Optional[str] = None
class NoteInDB(BaseModel): class NoteInDB(BaseModel):
@@ -35,6 +37,7 @@ class NoteInDB(BaseModel):
created_by: str = "" created_by: str = ""
created_at: str = "" created_at: str = ""
updated_at: str = "" updated_at: str = ""
status: str = ""
class NoteListResponse(BaseModel): class NoteListResponse(BaseModel):

View File

@@ -50,6 +50,15 @@ async def update_note(
return service.update_note(note_id, body) 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) @router.delete("/{note_id}", status_code=204)
async def delete_note( async def delete_note(
note_id: str, note_id: str,

View File

@@ -6,7 +6,7 @@ from equipment.models import NoteCreate, NoteUpdate, NoteInDB
COLLECTION = "equipment_notes" COLLECTION = "equipment_notes"
VALID_CATEGORIES = {"general", "maintenance", "installation", "issue", "other"} VALID_CATEGORIES = {"general", "maintenance", "installation", "issue", "action_item", "other"}
def _convert_firestore_value(val): def _convert_firestore_value(val):
@@ -81,7 +81,10 @@ def list_notes(
if user_id: if user_id:
query = query.where("user_id", "==", 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() docs = query.stream()
results = [] results = []
@@ -98,6 +101,10 @@ def list_notes(
results.append(note) 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 return results

View File

View File

@@ -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

View File

@@ -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,
)

169
backend/helpdesk/service.py Normal file
View File

@@ -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)

View File

@@ -11,6 +11,7 @@ from users.router import router as users_router
from mqtt.router import router as mqtt_router from mqtt.router import router as mqtt_router
from equipment.router import router as equipment_router from equipment.router import router as equipment_router
from staff.router import router as staff_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.client import mqtt_manager
from mqtt import database as mqtt_db from mqtt import database as mqtt_db
from melodies import service as melody_service from melodies import service as melody_service
@@ -37,6 +38,7 @@ app.include_router(settings_router)
app.include_router(users_router) app.include_router(users_router)
app.include_router(mqtt_router) app.include_router(mqtt_router)
app.include_router(equipment_router) app.include_router(equipment_router)
app.include_router(helpdesk_router)
app.include_router(staff_router) app.include_router(staff_router)

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query, UploadFile, File
from typing import Optional, List from typing import Optional, List
from auth.models import TokenPayload from auth.models import TokenPayload
from auth.dependencies import require_permission from auth.dependencies import require_permission
@@ -93,3 +93,15 @@ async def unassign_device(
_user: TokenPayload = Depends(require_permission("app_users", "edit")), _user: TokenPayload = Depends(require_permission("app_users", "edit")),
): ):
return service.unassign_device(user_id, device_id) 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}

View File

@@ -2,7 +2,7 @@ from datetime import datetime
from google.cloud.firestore_v1 import DocumentReference 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 shared.exceptions import NotFoundError
from users.models import UserCreate, UserUpdate, UserInDB from users.models import UserCreate, UserUpdate, UserInDB
@@ -250,3 +250,29 @@ def get_user_devices(user_doc_id: str) -> list[dict]:
break break
return devices 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

View File

@@ -54,6 +54,13 @@ class ApiClient {
}); });
} }
patch(endpoint, data) {
return this.request(endpoint, {
method: "PATCH",
body: JSON.stringify(data),
});
}
delete(endpoint) { delete(endpoint) {
return this.request(endpoint, { method: "DELETE" }); return this.request(endpoint, { method: "DELETE" });
} }

View File

@@ -485,37 +485,13 @@ export default function DeviceDetail() {
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}> <section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Device Information</h2> <h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Device Information</h2>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "1rem", alignItems: "start" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "1rem", alignItems: "start" }}>
{/* Col 1: Status on top, device image below */} {/* Col 1: Device image */}
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem", height: "100%" }}> <div style={{ display: "flex", alignItems: "center", height: "100%" }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}> <img
<div src={hwImage}
className="w-10 h-10 rounded-full flex items-center justify-center shrink-0" alt={hwVariant}
style={{ backgroundColor: isOnline ? "var(--success-bg)" : "var(--bg-card-hover)" }} style={{ maxHeight: 120, maxWidth: "100%", objectFit: "contain", opacity: 0.85 }}
> />
<span
className="w-3 h-3 rounded-full inline-block"
style={{ backgroundColor: isOnline ? "var(--success-text)" : "var(--text-muted)" }}
/>
</div>
<div>
<div className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Status</div>
<div className="text-sm font-semibold" style={{ color: isOnline ? "var(--success-text)" : "var(--text-muted)" }}>
{isOnline ? "Online" : "Offline"}
{mqttStatus && (
<span className="ml-2 text-xs font-normal" style={{ color: "var(--text-muted)" }}>
{mqttStatus.seconds_since_heartbeat}s ago
</span>
)}
</div>
</div>
</div>
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
<img
src={hwImage}
alt={hwVariant}
style={{ maxHeight: 80, maxWidth: "100%", objectFit: "contain", opacity: 0.85 }}
/>
</div>
</div> </div>
{/* Col 2: Serial Number, Hardware Variant, Document ID */} {/* Col 2: Serial Number, Hardware Variant, Document ID */}
@@ -785,23 +761,35 @@ export default function DeviceDetail() {
{attr.bellOutputs.map((output, i) => ( {attr.bellOutputs.map((output, i) => (
<div <div
key={i} key={i}
className="relative rounded-md border px-4 py-3 text-center overflow-hidden" className="rounded-md border overflow-hidden"
style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)", minWidth: 90 }} style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)", minWidth: 120 }}
> >
<span <div style={{ display: "flex", alignItems: "stretch" }}>
className="absolute inset-0 flex items-center justify-center font-bold pointer-events-none select-none" <div
style={{ fontSize: "3rem", color: "var(--text-heading)", opacity: 0.06 }} style={{
> display: "flex",
{i + 1} alignItems: "center",
</span> justifyContent: "center",
<div className="relative"> width: 44,
<div className="text-xs" style={{ color: "var(--text-muted)" }}> fontSize: "1.5rem",
Output <span style={{ color: "var(--text-primary)" }}>{output}</span> fontWeight: 700,
color: "var(--text-heading)",
opacity: 0.15,
borderRight: "1px solid var(--border-primary)",
flexShrink: 0,
}}
>
{i + 1}
</div> </div>
<div className="text-xs mt-1" style={{ color: "var(--text-muted)" }}> <div style={{ padding: "0.5rem 0.75rem" }}>
{attr.hammerTimings?.[i] != null ? ( <div className="text-xs" style={{ color: "var(--text-muted)" }}>
<><span style={{ color: "var(--text-primary)" }}>{attr.hammerTimings[i]}</span> ms</> Output <span style={{ color: "var(--text-primary)" }}>{output}</span>
) : "-"} </div>
<div className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
{attr.hammerTimings?.[i] != null ? (
<>Timing <span style={{ color: "var(--text-primary)" }}>{attr.hammerTimings[i]}</span> ms</>
) : "Timing -"}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -920,17 +908,28 @@ export default function DeviceDetail() {
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)" }} style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)" }}
onClick={() => user.user_id && navigate(`/users/${user.user_id}`)} onClick={() => user.user_id && navigate(`/users/${user.user_id}`)}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-3">
<div className="min-w-0"> <div className="flex items-center gap-3 min-w-0">
<p className="text-sm font-medium truncate" style={{ color: "var(--text-heading)" }}> <div
{user.display_name || user.email || "Unknown User"} className="w-8 h-8 rounded-full overflow-hidden shrink-0"
</p> style={{ backgroundColor: "var(--bg-card-hover)" }}
{user.email && user.display_name && ( >
<p className="text-xs truncate" style={{ color: "var(--text-muted)" }}>{user.email}</p> {user.photo_url ? (
)} <img src={user.photo_url} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
{user.user_id && ( ) : (
<p className="text-xs font-mono" style={{ color: "var(--text-muted)" }}>{user.user_id}</p> <div className="w-full h-full flex items-center justify-center text-xs font-bold" style={{ color: "var(--text-muted)" }}>
)} {(user.display_name || user.email || "?").charAt(0).toUpperCase()}
</div>
)}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate" style={{ color: "var(--text-heading)" }}>
{user.display_name || user.email || "Unknown User"}
</p>
{user.email && user.display_name && (
<p className="text-xs truncate" style={{ color: "var(--text-muted)", opacity: 0.7 }}>{user.email}</p>
)}
</div>
</div> </div>
{user.role && ( {user.role && (
<span className="px-2 py-0.5 text-xs rounded-full capitalize shrink-0 ml-2" style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}> <span className="px-2 py-0.5 text-xs rounded-full capitalize shrink-0 ml-2" style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
@@ -1026,11 +1025,20 @@ export default function DeviceDetail() {
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}> <h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
{device.device_name || "Unnamed Device"} {device.device_name || "Unnamed Device"}
</h1> </h1>
<span <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
className={`inline-block w-3 h-3 rounded-full ${isOnline ? "bg-green-500" : ""}`} <span
style={!isOnline ? { backgroundColor: "var(--border-primary)" } : undefined} className="w-3 h-3 rounded-full inline-block"
title={isOnline ? "Online" : "Offline"} style={{ backgroundColor: isOnline ? "var(--success-text)" : "var(--text-muted)" }}
/> />
<span className="text-sm font-semibold" style={{ color: isOnline ? "var(--success-text)" : "var(--text-muted)" }}>
{isOnline ? "Online" : "Offline"}
{mqttStatus && (
<span className="ml-2 text-xs font-normal" style={{ color: "var(--text-muted)" }}>
{mqttStatus.seconds_since_heartbeat}s ago
</span>
)}
</span>
</div>
</div> </div>
</div> </div>
{canEdit && ( {canEdit && (

View File

@@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import api from "../api/client"; 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() { export default function NoteForm() {
const { id } = useParams(); const { id } = useParams();
@@ -193,7 +193,7 @@ export default function NoteForm() {
> >
{CATEGORIES.map((c) => ( {CATEGORIES.map((c) => (
<option key={c} value={c}> <option key={c} value={c}>
{c.charAt(0).toUpperCase() + c.slice(1)} {c.replace(/_/g, " ").replace(/\b\w/g, ch => ch.toUpperCase())}
</option> </option>
))} ))}
</select> </select>

View File

@@ -5,12 +5,15 @@ import { useAuth } from "../auth/AuthContext";
import SearchBar from "../components/SearchBar"; import SearchBar from "../components/SearchBar";
import ConfirmDialog from "../components/ConfirmDialog"; 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) => { const categoryStyle = (cat) => {
switch (cat) { switch (cat) {
case "issue": case "issue":
return { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }; 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": case "maintenance":
return { backgroundColor: "var(--warning-bg, rgba(245,158,11,0.15))", color: "var(--warning-text, #f59e0b)" }; return { backgroundColor: "var(--warning-bg, rgba(245,158,11,0.15))", color: "var(--warning-text, #f59e0b)" };
case "installation": 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() { export default function NoteList() {
const [notes, setNotes] = useState([]); const [notes, setNotes] = useState([]);
const [total, setTotal] = useState(0); const [helpdeskMessages, setHelpdeskMessages] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [categoryFilter, setCategoryFilter] = useState("");
const [deleteTarget, setDeleteTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null);
const [hoveredRow, setHoveredRow] = useState(null); const [hoveredRow, setHoveredRow] = useState(null);
const [activeTab, setActiveTab] = useState("notes");
const navigate = useNavigate(); const navigate = useNavigate();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const canEdit = hasPermission("equipment", "edit"); const canEdit = hasPermission("equipment", "edit");
@@ -43,13 +61,11 @@ export default function NoteList() {
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (search) params.set("search", search); if (search) params.set("search", search);
if (categoryFilter) params.set("category", categoryFilter);
if (deviceIdFilter) params.set("device_id", deviceIdFilter); if (deviceIdFilter) params.set("device_id", deviceIdFilter);
if (userIdFilter) params.set("user_id", userIdFilter); if (userIdFilter) params.set("user_id", userIdFilter);
const qs = params.toString(); const qs = params.toString();
const data = await api.get(`/equipment/notes${qs ? `?${qs}` : ""}`); const data = await api.get(`/equipment/notes${qs ? `?${qs}` : ""}`);
setNotes(data.notes); setNotes(data.notes);
setTotal(data.total);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } 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(() => { useEffect(() => {
fetchNotes(); fetchNotes();
}, [search, categoryFilter, deviceIdFilter, userIdFilter]); fetchHelpdesk();
}, [search, deviceIdFilter, userIdFilter]);
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteTarget) return; 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 (
<div
className="rounded-lg p-8 text-center text-sm border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
>
No items found.
</div>
);
}
return (
<div
className="rounded-lg overflow-hidden border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
{showStatus && (
<th className="px-4 py-3 text-left font-medium w-12" style={{ color: "var(--text-secondary)" }} />
)}
<th className="px-4 py-3 text-left font-medium w-28" style={{ color: "var(--text-secondary)" }}>Category</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Title</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Device</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>User</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Created</th>
{canEdit && (
<th className="px-4 py-3 text-left font-medium w-24" style={{ color: "var(--text-secondary)" }} />
)}
</tr>
</thead>
<tbody>
{items.map((note, index) => (
<tr
key={note.id}
onClick={() => 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 && (
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
{canEdit ? (
<button
onClick={() => handleToggleStatus(note)}
className="w-5 h-5 rounded border-2 flex items-center justify-center cursor-pointer transition-colors"
style={{
borderColor: note.status === "completed" ? "var(--success-text)" : "var(--border-primary)",
backgroundColor: note.status === "completed" ? "var(--success-bg)" : "transparent",
color: "var(--success-text)",
}}
title={note.status === "completed" ? "Mark as open" : "Mark as completed"}
>
{note.status === "completed" && "✓"}
</button>
) : (
<span
className="w-5 h-5 rounded border-2 flex items-center justify-center"
style={{
borderColor: note.status === "completed" ? "var(--success-text)" : "var(--border-primary)",
backgroundColor: note.status === "completed" ? "var(--success-bg)" : "transparent",
color: "var(--success-text)",
}}
>
{note.status === "completed" && "✓"}
</span>
)}
</td>
)}
<td className="px-4 py-3">
<span className="px-2 py-0.5 text-xs rounded-full" style={categoryStyle(note.category)}>
{formatLabel(note.category) || "General"}
</span>
</td>
<td className="px-4 py-3 font-medium" style={{
color: "var(--text-heading)",
textDecoration: note.status === "completed" ? "line-through" : "none",
}}>
{note.title || "Untitled"}
</td>
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{note.device_name || "-"}</td>
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{note.user_name || "-"}</td>
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{note.created_at || "-"}</td>
{canEdit && (
<td className="px-4 py-3">
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<button onClick={() => navigate(`/equipment/notes/${note.id}/edit`)} className="hover:opacity-80 text-xs cursor-pointer" style={{ color: "var(--text-link)" }}>Edit</button>
<button onClick={() => setDeleteTarget(note)} className="hover:opacity-80 text-xs cursor-pointer" style={{ color: "var(--danger)" }}>Delete</button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
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 (
<div
className="rounded-lg p-8 text-center text-sm border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
>
No client messages found.
</div>
);
}
return (
<div
className="rounded-lg overflow-hidden border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-4 py-3 text-left font-medium w-12" style={{ color: "var(--text-secondary)" }} />
<th className="px-4 py-3 text-left font-medium w-28" style={{ color: "var(--text-secondary)" }}>Type</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Subject</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>From</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Phone</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Date</th>
</tr>
</thead>
<tbody>
{filtered.map((msg, index) => (
<tr
key={msg.id}
style={{
borderBottom: index < filtered.length - 1 ? "1px solid var(--border-primary)" : "none",
backgroundColor: hoveredRow === msg.id ? "var(--bg-card-hover)" : "transparent",
opacity: msg.acknowledged ? 0.6 : 1,
}}
onMouseEnter={() => setHoveredRow(msg.id)}
onMouseLeave={() => setHoveredRow(null)}
>
<td className="px-4 py-3">
{canEdit && (
<button
onClick={(e) => { e.stopPropagation(); handleToggleAcknowledged(msg); }}
className="w-5 h-5 rounded border-2 flex items-center justify-center cursor-pointer transition-colors"
style={{
borderColor: msg.acknowledged ? "var(--success-text)" : "var(--border-primary)",
backgroundColor: msg.acknowledged ? "var(--success-bg)" : "transparent",
color: "var(--success-text)",
}}
title={msg.acknowledged ? `Acknowledged by ${msg.acknowledged_by}` : "Mark as acknowledged"}
>
{msg.acknowledged && "✓"}
</button>
)}
</td>
<td className="px-4 py-3">
<span className="px-2 py-0.5 text-xs rounded-full" style={helpdeskTypeStyle(msg.type)}>
{msg.type || "Other"}
</span>
</td>
<td className="px-4 py-3">
<div className="font-medium" style={{ color: "var(--text-heading)" }}>{msg.subject || "No subject"}</div>
<div className="text-xs truncate mt-0.5" style={{ color: "var(--text-muted)", maxWidth: 300 }}>{msg.message}</div>
</td>
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{msg.sender_name || "-"}</td>
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{msg.phone || "-"}</td>
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{msg.date_sent || "-"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Equipment Notes</h1> <h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Issues and Notes</h1>
{canEdit && ( {canEdit && activeTab !== "messages" && (
<button <button
onClick={() => { onClick={() => {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -94,40 +346,36 @@ export default function NoteList() {
)} )}
</div> </div>
<div className="mb-4 space-y-3"> {/* Tabs */}
<SearchBar onSearch={setSearch} placeholder="Search by title or content..." /> <div className="flex gap-1 mb-4 border-b" style={{ borderColor: "var(--border-primary)" }}>
<div className="flex flex-wrap gap-3 items-center"> {tabs.map((tab) => (
<select <button
value={categoryFilter} key={tab.key}
onChange={(e) => setCategoryFilter(e.target.value)} onClick={() => setActiveTab(tab.key)}
className="px-3 py-2 rounded-md text-sm cursor-pointer border" className="px-4 py-2.5 text-sm font-medium transition-colors cursor-pointer"
style={{ style={{
backgroundColor: "var(--bg-card)", color: activeTab === tab.key ? "var(--accent)" : "var(--text-muted)",
color: "var(--text-primary)", borderBottom: activeTab === tab.key ? "2px solid var(--accent)" : "2px solid transparent",
borderColor: "var(--border-primary)", marginBottom: "-1px",
}} }}
> >
<option value="">All Categories</option> {tab.label}
{CATEGORY_OPTIONS.filter(Boolean).map((c) => ( <span className="ml-2 text-xs" style={{ opacity: 0.7 }}>({tab.count})</span>
<option key={c} value={c}> </button>
{c.charAt(0).toUpperCase() + c.slice(1)} ))}
</option> </div>
))}
</select> <div className="mb-4">
<span className="flex items-center text-sm" style={{ color: "var(--text-muted)" }}> <SearchBar
{total} {total === 1 ? "note" : "notes"} onSearch={setSearch}
</span> placeholder={activeTab === "messages" ? "Search by subject or message..." : "Search by title or content..."}
</div> />
</div> </div>
{error && ( {error && (
<div <div
className="text-sm rounded-md p-3 mb-4 border" className="text-sm rounded-md p-3 mb-4 border"
style={{ style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
> >
{error} {error}
</div> </div>
@@ -135,98 +383,12 @@ export default function NoteList() {
{loading ? ( {loading ? (
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div> <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
) : notes.length === 0 ? (
<div
className="rounded-lg p-8 text-center text-sm border"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
color: "var(--text-muted)",
}}
>
No notes found.
</div>
) : ( ) : (
<div <>
className="rounded-lg overflow-hidden border" {activeTab === "notes" && renderNotesTable(noteItems, false)}
style={{ {activeTab === "issues" && renderNotesTable(issueItems, true)}
backgroundColor: "var(--bg-card)", {activeTab === "messages" && renderHelpdeskMessages()}
borderColor: "var(--border-primary)", </>
}}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-4 py-3 text-left font-medium w-28" style={{ color: "var(--text-secondary)" }}>Category</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Title</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Device</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>User</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Created</th>
{canEdit && (
<th className="px-4 py-3 text-left font-medium w-24" style={{ color: "var(--text-secondary)" }} />
)}
</tr>
</thead>
<tbody>
{notes.map((note, index) => (
<tr
key={note.id}
onClick={() => 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)}
>
<td className="px-4 py-3">
<span
className="px-2 py-0.5 text-xs rounded-full"
style={categoryStyle(note.category)}
>
{note.category || "general"}
</span>
</td>
<td className="px-4 py-3 font-medium" style={{ color: "var(--text-heading)" }}>
{note.title || "Untitled"}
</td>
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>
{note.device_name || "-"}
</td>
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>
{note.user_name || "-"}
</td>
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
{note.created_at || "-"}
</td>
{canEdit && (
<td className="px-4 py-3">
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => navigate(`/equipment/notes/${note.id}/edit`)}
className="hover:opacity-80 text-xs cursor-pointer"
style={{ color: "var(--text-link)" }}
>
Edit
</button>
<button
onClick={() => setDeleteTarget(note)}
className="hover:opacity-80 text-xs cursor-pointer"
style={{ color: "var(--danger)" }}
>
Delete
</button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
)} )}
<ConfirmDialog <ConfirmDialog

View File

@@ -4,10 +4,16 @@ import api from "../api/client";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import ConfirmDialog from "../components/ConfirmDialog"; import ConfirmDialog from "../components/ConfirmDialog";
const NOTE_CATEGORIES = ["general", "maintenance", "installation", "other"];
const ISSUE_CATEGORIES = ["issue", "action_item"];
const ALL_CATEGORIES = ["general", "maintenance", "installation", "issue", "action_item", "other"];
const categoryStyle = (cat) => { const categoryStyle = (cat) => {
switch (cat) { switch (cat) {
case "issue": case "issue":
return { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }; 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": case "maintenance":
return { backgroundColor: "var(--warning-bg, rgba(245,158,11,0.15))", color: "var(--warning-text, #f59e0b)" }; return { backgroundColor: "var(--warning-bg, rgba(245,158,11,0.15))", color: "var(--warning-text, #f59e0b)" };
case "installation": 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 }) { export default function NotesPanel({ deviceId, userId }) {
const [notes, setNotes] = useState([]); const [notes, setNotes] = useState([]);
const [helpdeskMessages, setHelpdeskMessages] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [deleteTarget, setDeleteTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null);
@@ -29,6 +49,7 @@ export default function NotesPanel({ deviceId, userId }) {
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [category, setCategory] = useState("general"); const [category, setCategory] = useState("general");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [activeTab, setActiveTab] = useState("notes");
const navigate = useNavigate(); const navigate = useNavigate();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const canEdit = hasPermission("equipment", "edit"); 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(() => { useEffect(() => {
fetchNotes(); fetchNotes();
fetchHelpdesk();
}, [deviceId, userId]); }, [deviceId, userId]);
const handleCreate = async (e) => { 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 inputClass = "w-full px-3 py-2 rounded-md text-sm border";
const renderNoteItem = (note, showStatus = false) => (
<div
key={note.id}
className="p-3 rounded-md border cursor-pointer hover:opacity-90 transition-colors"
style={{
backgroundColor: "var(--bg-primary)",
borderColor: "var(--border-primary)",
opacity: note.status === "completed" ? 0.6 : 1,
}}
onClick={() => navigate(`/equipment/notes/${note.id}`)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 flex-1 min-w-0">
{showStatus && (
<button
onClick={(e) => { e.stopPropagation(); handleToggleStatus(note); }}
className="w-5 h-5 rounded border-2 flex items-center justify-center cursor-pointer transition-colors shrink-0 mt-0.5"
style={{
borderColor: note.status === "completed" ? "var(--success-text)" : "var(--border-primary)",
backgroundColor: note.status === "completed" ? "var(--success-bg)" : "transparent",
color: "var(--success-text)",
fontSize: "0.7rem",
}}
title={note.status === "completed" ? "Mark as open" : "Mark as completed"}
>
{note.status === "completed" && "✓"}
</button>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="px-2 py-0.5 text-xs rounded-full shrink-0" style={categoryStyle(note.category)}>
{formatLabel(note.category)}
</span>
<span
className="text-sm font-medium truncate"
style={{
color: "var(--text-heading)",
textDecoration: note.status === "completed" ? "line-through" : "none",
}}
>
{note.title}
</span>
</div>
<p className="text-xs truncate" style={{ color: "var(--text-muted)" }}>{note.content}</p>
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
{note.created_by && `${note.created_by} · `}{note.created_at}
</p>
</div>
</div>
{canEdit && (
<button
onClick={(e) => { e.stopPropagation(); setDeleteTarget(note); }}
className="text-xs hover:opacity-80 shrink-0 cursor-pointer"
style={{ color: "var(--danger)" }}
>
Delete
</button>
)}
</div>
</div>
);
const renderHelpdeskItem = (msg) => (
<div
key={msg.id}
className="p-3 rounded-md border transition-colors"
style={{
backgroundColor: "var(--bg-primary)",
borderColor: "var(--border-primary)",
opacity: msg.acknowledged ? 0.6 : 1,
}}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 flex-1 min-w-0">
{canEdit && (
<button
onClick={() => handleToggleAcknowledged(msg)}
className="w-5 h-5 rounded border-2 flex items-center justify-center cursor-pointer transition-colors shrink-0 mt-0.5"
style={{
borderColor: msg.acknowledged ? "var(--success-text)" : "var(--border-primary)",
backgroundColor: msg.acknowledged ? "var(--success-bg)" : "transparent",
color: "var(--success-text)",
fontSize: "0.7rem",
}}
title={msg.acknowledged ? `Acknowledged by ${msg.acknowledged_by}` : "Mark as acknowledged"}
>
{msg.acknowledged && "✓"}
</button>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="px-2 py-0.5 text-xs rounded-full shrink-0" style={helpdeskTypeStyle(msg.type)}>
{msg.type || "Other"}
</span>
<span className="text-sm font-medium truncate" style={{ color: "var(--text-heading)" }}>
{msg.subject || "No subject"}
</span>
</div>
<p className="text-xs truncate" style={{ color: "var(--text-muted)" }}>{msg.message}</p>
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
{msg.sender_name && `${msg.sender_name} · `}{msg.phone && `${msg.phone} · `}{msg.date_sent}
</p>
</div>
</div>
</div>
</div>
);
return ( return (
<section <section
className="rounded-lg border p-6" className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
> >
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-3">
<h2 <h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
className="text-lg font-semibold" Issues & Notes ({totalCount})
style={{ color: "var(--text-heading)" }}
>
Notes ({notes.length})
</h2> </h2>
<div className="flex gap-2"> <div className="flex gap-2">
{canEdit && ( {canEdit && activeTab !== "messages" && (
<button <button
onClick={() => setShowForm(!showForm)} onClick={() => setShowForm(!showForm)}
className="px-3 py-1.5 text-xs rounded-md hover:opacity-90 transition-colors cursor-pointer" className="px-3 py-1.5 text-xs rounded-md hover:opacity-90 transition-colors cursor-pointer"
@@ -129,20 +300,34 @@ export default function NotesPanel({ deviceId, userId }) {
</div> </div>
</div> </div>
{/* Compact tabs */}
<div className="flex gap-1 mb-4 border-b" style={{ borderColor: "var(--border-primary)" }}>
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className="px-3 py-2 text-xs font-medium transition-colors cursor-pointer"
style={{
color: activeTab === tab.key ? "var(--accent)" : "var(--text-muted)",
borderBottom: activeTab === tab.key ? "2px solid var(--accent)" : "2px solid transparent",
marginBottom: "-1px",
}}
>
{tab.label} ({tab.count})
</button>
))}
</div>
{error && ( {error && (
<div <div
className="text-sm rounded-md p-3 mb-4 border" className="text-sm rounded-md p-3 mb-4 border"
style={{ style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
> >
{error} {error}
</div> </div>
)} )}
{showForm && ( {showForm && activeTab !== "messages" && (
<form onSubmit={handleCreate} className="mb-4 p-4 rounded-md border" style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)" }}> <form onSubmit={handleCreate} className="mb-4 p-4 rounded-md border" style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)" }}>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex gap-3"> <div className="flex gap-3">
@@ -154,27 +339,17 @@ export default function NotesPanel({ deviceId, userId }) {
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Note title" placeholder="Note title"
className={inputClass} className={inputClass}
style={{ style={{ backgroundColor: "var(--bg-card)", color: "var(--text-primary)", borderColor: "var(--border-primary)" }}
backgroundColor: "var(--bg-card)",
color: "var(--text-primary)",
borderColor: "var(--border-primary)",
}}
/> />
</div> </div>
<select <select
value={category} value={category}
onChange={(e) => setCategory(e.target.value)} onChange={(e) => setCategory(e.target.value)}
className="px-3 py-2 rounded-md text-sm border" className="px-3 py-2 rounded-md text-sm border"
style={{ style={{ backgroundColor: "var(--bg-card)", color: "var(--text-primary)", borderColor: "var(--border-primary)" }}
backgroundColor: "var(--bg-card)",
color: "var(--text-primary)",
borderColor: "var(--border-primary)",
}}
> >
{CATEGORIES.map((c) => ( {ALL_CATEGORIES.map((c) => (
<option key={c} value={c}> <option key={c} value={c}>{formatLabel(c)}</option>
{c.charAt(0).toUpperCase() + c.slice(1)}
</option>
))} ))}
</select> </select>
</div> </div>
@@ -185,12 +360,7 @@ export default function NotesPanel({ deviceId, userId }) {
rows={3} rows={3}
placeholder="Note content..." placeholder="Note content..."
className={inputClass} className={inputClass}
style={{ style={{ backgroundColor: "var(--bg-card)", color: "var(--text-primary)", borderColor: "var(--border-primary)", resize: "vertical" }}
backgroundColor: "var(--bg-card)",
color: "var(--text-primary)",
borderColor: "var(--border-primary)",
resize: "vertical",
}}
/> />
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
@@ -208,55 +378,30 @@ export default function NotesPanel({ deviceId, userId }) {
{loading ? ( {loading ? (
<p className="text-sm py-4 text-center" style={{ color: "var(--text-muted)" }}>Loading...</p> <p className="text-sm py-4 text-center" style={{ color: "var(--text-muted)" }}>Loading...</p>
) : notes.length === 0 ? (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
No notes yet.
</p>
) : ( ) : (
<div className="space-y-2"> <>
{notes.map((note) => ( {activeTab === "notes" && (
<div noteItems.length === 0 ? (
key={note.id} <p className="text-sm" style={{ color: "var(--text-muted)" }}>No notes yet.</p>
className="p-3 rounded-md border cursor-pointer hover:opacity-90 transition-colors" ) : (
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)" }} <div className="space-y-2">{noteItems.map(n => renderNoteItem(n, false))}</div>
onClick={() => navigate(`/equipment/notes/${note.id}`)} )
> )}
<div className="flex items-start justify-between gap-2"> {activeTab === "issues" && (
<div className="flex-1 min-w-0"> issueItems.length === 0 ? (
<div className="flex items-center gap-2 mb-1"> <p className="text-sm" style={{ color: "var(--text-muted)" }}>No issues or action items.</p>
<span ) : (
className="px-2 py-0.5 text-xs rounded-full shrink-0" <div className="space-y-2">{issueItems.map(n => renderNoteItem(n, true))}</div>
style={categoryStyle(note.category)} )
> )}
{note.category} {activeTab === "messages" && (
</span> helpdeskMessages.length === 0 ? (
<span className="text-sm font-medium truncate" style={{ color: "var(--text-heading)" }}> <p className="text-sm" style={{ color: "var(--text-muted)" }}>No client messages.</p>
{note.title} ) : (
</span> <div className="space-y-2">{helpdeskMessages.map(renderHelpdeskItem)}</div>
</div> )
<p className="text-xs truncate" style={{ color: "var(--text-muted)" }}> )}
{note.content} </>
</p>
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
{note.created_by && `${note.created_by} · `}{note.created_at}
</p>
</div>
{canEdit && (
<button
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(note);
}}
className="text-xs hover:opacity-80 shrink-0 cursor-pointer"
style={{ color: "var(--danger)" }}
>
Delete
</button>
)}
</div>
</div>
))}
</div>
)} )}
<ConfirmDialog <ConfirmDialog

View File

@@ -23,7 +23,7 @@ const navItems = [
{ to: "/mqtt/logs", label: "Logs" }, { to: "/mqtt/logs", label: "Logs" },
], ],
}, },
{ to: "/equipment/notes", label: "Equipment Notes", permission: "equipment" }, { to: "/equipment/notes", label: "Issues and Notes", permission: "equipment" },
]; ];
const linkClass = (isActive, locked) => const linkClass = (isActive, locked) =>

View File

@@ -15,7 +15,7 @@ const SECTIONS = [
{ key: "melodies", label: "Melodies" }, { key: "melodies", label: "Melodies" },
{ key: "devices", label: "Devices" }, { key: "devices", label: "Devices" },
{ key: "app_users", label: "App Users" }, { key: "app_users", label: "App Users" },
{ key: "equipment", label: "Equipment Notes" }, { key: "equipment", label: "Issues and Notes" },
]; ];
const ACTIONS = ["view", "add", "edit", "delete"]; const ACTIONS = ["view", "add", "edit", "delete"];

View File

@@ -7,7 +7,7 @@ const SECTIONS = [
{ key: "melodies", label: "Melodies" }, { key: "melodies", label: "Melodies" },
{ key: "devices", label: "Devices" }, { key: "devices", label: "Devices" },
{ key: "app_users", label: "App Users" }, { key: "app_users", label: "App Users" },
{ key: "equipment", label: "Equipment Notes" }, { key: "equipment", label: "Issues and Notes" },
]; ];
const ACTIONS = ["view", "add", "edit", "delete"]; const ACTIONS = ["view", "add", "edit", "delete"];

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import api from "../api/client"; import api from "../api/client";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
@@ -37,6 +37,8 @@ export default function UserDetail() {
const [assigningDevice, setAssigningDevice] = useState(false); const [assigningDevice, setAssigningDevice] = useState(false);
const [selectedDeviceId, setSelectedDeviceId] = useState(""); const [selectedDeviceId, setSelectedDeviceId] = useState("");
const [showAssignPanel, setShowAssignPanel] = useState(false); const [showAssignPanel, setShowAssignPanel] = useState(false);
const [uploadingPhoto, setUploadingPhoto] = useState(false);
const photoInputRef = useRef(null);
useEffect(() => { useEffect(() => {
loadData(); loadData();
@@ -126,6 +128,22 @@ export default function UserDetail() {
loadAllDevices(); 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) { if (loading) {
return ( return (
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}> <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>
@@ -247,33 +265,77 @@ export default function UserDetail() {
> >
Account Information Account Information
</h2> </h2>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4"> <div style={{ display: "flex", gap: "1.5rem" }}>
<Field label="Document ID"> {/* Profile Photo */}
<span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}> <div className="shrink-0" style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "0.5rem" }}>
{user.id} <div
</span> className="relative rounded-full overflow-hidden"
</Field> style={{ width: 80, height: 80, backgroundColor: "var(--bg-card-hover)" }}
<Field label="UID">
<span className="font-mono text-xs">{user.uid}</span>
</Field>
<Field label="Status">
<span
className="px-2 py-0.5 text-xs rounded-full"
style={
isBlocked
? { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }
: user.status === "active"
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
: { backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }
}
> >
{user.status || "unknown"} {user.photo_url ? (
</span> <img
</Field> src={user.photo_url}
<Field label="Email">{user.email}</Field> alt={user.display_name || "User"}
<Field label="Phone">{user.phone_number}</Field> style={{ width: "100%", height: "100%", objectFit: "cover" }}
<Field label="Title">{user.userTitle}</Field> />
</dl> ) : (
<div
className="w-full h-full flex items-center justify-center text-2xl font-bold"
style={{ color: "var(--text-muted)" }}
>
{(user.display_name || user.email || "?").charAt(0).toUpperCase()}
</div>
)}
</div>
{canEdit && (
<>
<button
onClick={() => photoInputRef.current?.click()}
disabled={uploadingPhoto}
className="text-xs hover:opacity-80 cursor-pointer transition-colors"
style={{ color: "var(--text-link)" }}
>
{uploadingPhoto ? "Uploading..." : "Change Photo"}
</button>
<input
ref={photoInputRef}
type="file"
accept="image/*"
onChange={handlePhotoUpload}
style={{ display: "none" }}
/>
</>
)}
</div>
{/* Fields */}
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4 flex-1">
<Field label="Document ID">
<span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>
{user.id}
</span>
</Field>
<Field label="UID">
<span className="font-mono text-xs">{user.uid}</span>
</Field>
<Field label="Status">
<span
className="px-2 py-0.5 text-xs rounded-full"
style={
isBlocked
? { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }
: user.status === "active"
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
: { backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }
}
>
{user.status || "unknown"}
</span>
</Field>
<Field label="Email">{user.email}</Field>
<Field label="Phone">{user.phone_number}</Field>
<Field label="Title">{user.userTitle}</Field>
</dl>
</div>
</section> </section>
{/* Profile */} {/* Profile */}
@@ -289,7 +351,6 @@ export default function UserDetail() {
</h2> </h2>
<dl className="grid grid-cols-1 gap-4"> <dl className="grid grid-cols-1 gap-4">
<Field label="Bio">{user.bio}</Field> <Field label="Bio">{user.bio}</Field>
<Field label="Photo URL">{user.photo_url}</Field>
</dl> </dl>
</section> </section>
@@ -475,7 +536,7 @@ export default function UserDetail() {
)} )}
</section> </section>
{/* Equipment Notes */} {/* Issues and Notes */}
<NotesPanel userId={id} /> <NotesPanel userId={id} />
</div> </div>
</div> </div>