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

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

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

View File

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

View File

@@ -5,12 +5,15 @@ import { useAuth } from "../auth/AuthContext";
import SearchBar from "../components/SearchBar";
import ConfirmDialog from "../components/ConfirmDialog";
const CATEGORY_OPTIONS = ["", "general", "maintenance", "installation", "issue", "other"];
const NOTE_CATEGORIES = ["general", "maintenance", "installation", "other"];
const ISSUE_CATEGORIES = ["issue", "action_item"];
const categoryStyle = (cat) => {
switch (cat) {
case "issue":
return { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" };
case "action_item":
return { backgroundColor: "var(--badge-blue-bg, rgba(59,130,246,0.15))", color: "var(--badge-blue-text, #3b82f6)" };
case "maintenance":
return { backgroundColor: "var(--warning-bg, rgba(245,158,11,0.15))", color: "var(--warning-text, #f59e0b)" };
case "installation":
@@ -20,15 +23,30 @@ const categoryStyle = (cat) => {
}
};
const helpdeskTypeStyle = (type) => {
switch (type?.toLowerCase()) {
case "problem":
return { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" };
case "suggestion":
return { backgroundColor: "var(--badge-blue-bg, rgba(59,130,246,0.15))", color: "var(--badge-blue-text, #3b82f6)" };
case "question":
return { backgroundColor: "var(--warning-bg, rgba(245,158,11,0.15))", color: "var(--warning-text, #f59e0b)" };
default:
return { backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" };
}
};
const formatLabel = (s) => s ? s.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()) : "";
export default function NoteList() {
const [notes, setNotes] = useState([]);
const [total, setTotal] = useState(0);
const [helpdeskMessages, setHelpdeskMessages] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [search, setSearch] = useState("");
const [categoryFilter, setCategoryFilter] = useState("");
const [deleteTarget, setDeleteTarget] = useState(null);
const [hoveredRow, setHoveredRow] = useState(null);
const [activeTab, setActiveTab] = useState("notes");
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canEdit = hasPermission("equipment", "edit");
@@ -43,13 +61,11 @@ export default function NoteList() {
try {
const params = new URLSearchParams();
if (search) params.set("search", search);
if (categoryFilter) params.set("category", categoryFilter);
if (deviceIdFilter) params.set("device_id", deviceIdFilter);
if (userIdFilter) params.set("user_id", userIdFilter);
const qs = params.toString();
const data = await api.get(`/equipment/notes${qs ? `?${qs}` : ""}`);
setNotes(data.notes);
setTotal(data.total);
} catch (err) {
setError(err.message);
} finally {
@@ -57,9 +73,23 @@ export default function NoteList() {
}
};
const fetchHelpdesk = async () => {
try {
const params = new URLSearchParams();
if (deviceIdFilter) params.set("device_id", deviceIdFilter);
if (userIdFilter) params.set("user_id", userIdFilter);
const qs = params.toString();
const data = await api.get(`/helpdesk${qs ? `?${qs}` : ""}`);
setHelpdeskMessages(data.messages);
} catch {
// Silently fail for helpdesk if not available
}
};
useEffect(() => {
fetchNotes();
}, [search, categoryFilter, deviceIdFilter, userIdFilter]);
fetchHelpdesk();
}, [search, deviceIdFilter, userIdFilter]);
const handleDelete = async () => {
if (!deleteTarget) return;
@@ -73,11 +103,233 @@ export default function NoteList() {
}
};
const handleToggleStatus = async (note) => {
try {
const newStatus = note.status === "completed" ? "" : "completed";
await api.patch(`/equipment/notes/${note.id}/status`, { status: newStatus });
fetchNotes();
} catch (err) {
setError(err.message);
}
};
const handleToggleAcknowledged = async (msg) => {
try {
await api.patch(`/helpdesk/${msg.id}/acknowledge`);
fetchHelpdesk();
} catch (err) {
setError(err.message);
}
};
// Split notes by tab
const noteItems = notes.filter(n => NOTE_CATEGORIES.includes(n.category));
const issueItems = notes.filter(n => ISSUE_CATEGORIES.includes(n.category));
const tabs = [
{ key: "notes", label: "Notes", count: noteItems.length },
{ key: "issues", label: "Issues & Action Items", count: issueItems.length },
{ key: "messages", label: "Client Messages", count: helpdeskMessages.length },
];
const renderNotesTable = (items, showStatus = false) => {
if (items.length === 0) {
return (
<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 (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Equipment Notes</h1>
{canEdit && (
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Issues and Notes</h1>
{canEdit && activeTab !== "messages" && (
<button
onClick={() => {
const params = new URLSearchParams();
@@ -94,40 +346,36 @@ export default function NoteList() {
)}
</div>
<div className="mb-4 space-y-3">
<SearchBar onSearch={setSearch} placeholder="Search by title or content..." />
<div className="flex flex-wrap gap-3 items-center">
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="px-3 py-2 rounded-md text-sm cursor-pointer border"
{/* 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-4 py-2.5 text-sm font-medium transition-colors cursor-pointer"
style={{
backgroundColor: "var(--bg-card)",
color: "var(--text-primary)",
borderColor: "var(--border-primary)",
color: activeTab === tab.key ? "var(--accent)" : "var(--text-muted)",
borderBottom: activeTab === tab.key ? "2px solid var(--accent)" : "2px solid transparent",
marginBottom: "-1px",
}}
>
<option value="">All Categories</option>
{CATEGORY_OPTIONS.filter(Boolean).map((c) => (
<option key={c} value={c}>
{c.charAt(0).toUpperCase() + c.slice(1)}
</option>
))}
</select>
<span className="flex items-center text-sm" style={{ color: "var(--text-muted)" }}>
{total} {total === 1 ? "note" : "notes"}
</span>
</div>
{tab.label}
<span className="ml-2 text-xs" style={{ opacity: 0.7 }}>({tab.count})</span>
</button>
))}
</div>
<div className="mb-4">
<SearchBar
onSearch={setSearch}
placeholder={activeTab === "messages" ? "Search by subject or message..." : "Search by title or content..."}
/>
</div>
{error && (
<div
className="text-sm rounded-md p-3 mb-4 border"
style={{
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
>
{error}
</div>
@@ -135,98 +383,12 @@ export default function NoteList() {
{loading ? (
<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"
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-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>
<>
{activeTab === "notes" && renderNotesTable(noteItems, false)}
{activeTab === "issues" && renderNotesTable(issueItems, true)}
{activeTab === "messages" && renderHelpdeskMessages()}
</>
)}
<ConfirmDialog

View File

@@ -4,10 +4,16 @@ import api from "../api/client";
import { useAuth } from "../auth/AuthContext";
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) => {
switch (cat) {
case "issue":
return { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" };
case "action_item":
return { backgroundColor: "var(--badge-blue-bg, rgba(59,130,246,0.15))", color: "var(--badge-blue-text, #3b82f6)" };
case "maintenance":
return { backgroundColor: "var(--warning-bg, rgba(245,158,11,0.15))", color: "var(--warning-text, #f59e0b)" };
case "installation":
@@ -17,10 +23,24 @@ const categoryStyle = (cat) => {
}
};
const CATEGORIES = ["general", "maintenance", "installation", "issue", "other"];
const helpdeskTypeStyle = (type) => {
switch (type?.toLowerCase()) {
case "problem":
return { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" };
case "suggestion":
return { backgroundColor: "var(--badge-blue-bg, rgba(59,130,246,0.15))", color: "var(--badge-blue-text, #3b82f6)" };
case "question":
return { backgroundColor: "var(--warning-bg, rgba(245,158,11,0.15))", color: "var(--warning-text, #f59e0b)" };
default:
return { backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" };
}
};
const formatLabel = (s) => s ? s.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()) : "";
export default function NotesPanel({ deviceId, userId }) {
const [notes, setNotes] = useState([]);
const [helpdeskMessages, setHelpdeskMessages] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [deleteTarget, setDeleteTarget] = useState(null);
@@ -29,6 +49,7 @@ export default function NotesPanel({ deviceId, userId }) {
const [content, setContent] = useState("");
const [category, setCategory] = useState("general");
const [saving, setSaving] = useState(false);
const [activeTab, setActiveTab] = useState("notes");
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canEdit = hasPermission("equipment", "edit");
@@ -50,8 +71,22 @@ export default function NotesPanel({ deviceId, userId }) {
}
};
const fetchHelpdesk = async () => {
try {
const params = new URLSearchParams();
if (deviceId) params.set("device_id", deviceId);
if (userId) params.set("user_id", userId);
const qs = params.toString();
const data = await api.get(`/helpdesk${qs ? `?${qs}` : ""}`);
setHelpdeskMessages(data.messages);
} catch {
// Silently fail
}
};
useEffect(() => {
fetchNotes();
fetchHelpdesk();
}, [deviceId, userId]);
const handleCreate = async (e) => {
@@ -90,22 +125,158 @@ export default function NotesPanel({ deviceId, userId }) {
}
};
const handleToggleStatus = async (note) => {
try {
const newStatus = note.status === "completed" ? "" : "completed";
await api.patch(`/equipment/notes/${note.id}/status`, { status: newStatus });
fetchNotes();
} catch (err) {
setError(err.message);
}
};
const handleToggleAcknowledged = async (msg) => {
try {
await api.patch(`/helpdesk/${msg.id}/acknowledge`);
fetchHelpdesk();
} catch (err) {
setError(err.message);
}
};
// Split notes
const noteItems = notes.filter(n => NOTE_CATEGORIES.includes(n.category));
const issueItems = notes.filter(n => ISSUE_CATEGORIES.includes(n.category));
const tabs = [
{ key: "notes", label: "Notes", count: noteItems.length },
{ key: "issues", label: "Issues", count: issueItems.length },
{ key: "messages", label: "Messages", count: helpdeskMessages.length },
];
const totalCount = notes.length + helpdeskMessages.length;
const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
const renderNoteItem = (note, showStatus = false) => (
<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 (
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="flex items-center justify-between mb-4">
<h2
className="text-lg font-semibold"
style={{ color: "var(--text-heading)" }}
>
Notes ({notes.length})
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
Issues & Notes ({totalCount})
</h2>
<div className="flex gap-2">
{canEdit && (
{canEdit && activeTab !== "messages" && (
<button
onClick={() => setShowForm(!showForm)}
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>
{/* 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 && (
<div
className="text-sm rounded-md p-3 mb-4 border"
style={{
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
>
{error}
</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)" }}>
<div className="space-y-3">
<div className="flex gap-3">
@@ -154,27 +339,17 @@ export default function NotesPanel({ deviceId, userId }) {
onChange={(e) => setTitle(e.target.value)}
placeholder="Note title"
className={inputClass}
style={{
backgroundColor: "var(--bg-card)",
color: "var(--text-primary)",
borderColor: "var(--border-primary)",
}}
style={{ backgroundColor: "var(--bg-card)", color: "var(--text-primary)", borderColor: "var(--border-primary)" }}
/>
</div>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="px-3 py-2 rounded-md text-sm border"
style={{
backgroundColor: "var(--bg-card)",
color: "var(--text-primary)",
borderColor: "var(--border-primary)",
}}
style={{ backgroundColor: "var(--bg-card)", color: "var(--text-primary)", borderColor: "var(--border-primary)" }}
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>
{c.charAt(0).toUpperCase() + c.slice(1)}
</option>
{ALL_CATEGORIES.map((c) => (
<option key={c} value={c}>{formatLabel(c)}</option>
))}
</select>
</div>
@@ -185,12 +360,7 @@ export default function NotesPanel({ deviceId, userId }) {
rows={3}
placeholder="Note content..."
className={inputClass}
style={{
backgroundColor: "var(--bg-card)",
color: "var(--text-primary)",
borderColor: "var(--border-primary)",
resize: "vertical",
}}
style={{ backgroundColor: "var(--bg-card)", color: "var(--text-primary)", borderColor: "var(--border-primary)", resize: "vertical" }}
/>
<div className="flex justify-end">
<button
@@ -208,55 +378,30 @@ export default function NotesPanel({ deviceId, userId }) {
{loading ? (
<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) => (
<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)" }}
onClick={() => navigate(`/equipment/notes/${note.id}`)}
>
<div className="flex items-start justify-between gap-2">
<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)}
>
{note.category}
</span>
<span className="text-sm font-medium truncate" style={{ color: "var(--text-heading)" }}>
{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>
{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>
<>
{activeTab === "notes" && (
noteItems.length === 0 ? (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>No notes yet.</p>
) : (
<div className="space-y-2">{noteItems.map(n => renderNoteItem(n, false))}</div>
)
)}
{activeTab === "issues" && (
issueItems.length === 0 ? (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>No issues or action items.</p>
) : (
<div className="space-y-2">{issueItems.map(n => renderNoteItem(n, true))}</div>
)
)}
{activeTab === "messages" && (
helpdeskMessages.length === 0 ? (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>No client messages.</p>
) : (
<div className="space-y-2">{helpdeskMessages.map(renderHelpdeskItem)}</div>
)
)}
</>
)}
<ConfirmDialog

View File

@@ -23,7 +23,7 @@ const navItems = [
{ 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) =>

View File

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

View File

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

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 api from "../api/client";
import { useAuth } from "../auth/AuthContext";
@@ -37,6 +37,8 @@ export default function UserDetail() {
const [assigningDevice, setAssigningDevice] = useState(false);
const [selectedDeviceId, setSelectedDeviceId] = useState("");
const [showAssignPanel, setShowAssignPanel] = useState(false);
const [uploadingPhoto, setUploadingPhoto] = useState(false);
const photoInputRef = useRef(null);
useEffect(() => {
loadData();
@@ -126,6 +128,22 @@ export default function UserDetail() {
loadAllDevices();
};
const handlePhotoUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingPhoto(true);
setError("");
try {
const result = await api.upload(`/users/${id}/photo`, file);
setUser((prev) => ({ ...prev, photo_url: result.photo_url }));
} catch (err) {
setError(err.message);
} finally {
setUploadingPhoto(false);
if (photoInputRef.current) photoInputRef.current.value = "";
}
};
if (loading) {
return (
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>
@@ -247,33 +265,77 @@ export default function UserDetail() {
>
Account Information
</h2>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
<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)" }
}
<div style={{ display: "flex", gap: "1.5rem" }}>
{/* Profile Photo */}
<div className="shrink-0" style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "0.5rem" }}>
<div
className="relative rounded-full overflow-hidden"
style={{ width: 80, height: 80, backgroundColor: "var(--bg-card-hover)" }}
>
{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>
{user.photo_url ? (
<img
src={user.photo_url}
alt={user.display_name || "User"}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : (
<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>
{/* Profile */}
@@ -289,7 +351,6 @@ export default function UserDetail() {
</h2>
<dl className="grid grid-cols-1 gap-4">
<Field label="Bio">{user.bio}</Field>
<Field label="Photo URL">{user.photo_url}</Field>
</dl>
</section>
@@ -475,7 +536,7 @@ export default function UserDetail() {
)}
</section>
{/* Equipment Notes */}
{/* Issues and Notes */}
<NotesPanel userId={id} />
</div>
</div>