diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index bd8a5ef..707adfd 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -1,4 +1,6 @@ import { useEffect } from 'react' +import { useAdminReviewStore } from '../../state/adminReviewStore' +import { useAuthStore } from '../../state/authStore' import { useCatalogStore } from '../../state/catalogStore' import { useProjectStore } from '../../state/projectStore' import { useSubmissionStore } from '../../state/submissionStore' @@ -14,12 +16,23 @@ export default function AppShell() { const isCatalogLoaded = useCatalogStore((s) => s.isLoaded) const loadCatalog = useCatalogStore((s) => s.loadCatalog) const loadMySubmissions = useSubmissionStore((s) => s.loadMySubmissions) + const loadAuth = useAuthStore((s) => s.load) + const role = useAuthStore((s) => s.role) + const loadAdminQueue = useAdminReviewStore((s) => s.loadAll) useEffect(() => { loadInitialDiagram() loadCatalog() loadMySubmissions() - }, [loadInitialDiagram, loadCatalog, loadMySubmissions]) + loadAuth() + }, [loadInitialDiagram, loadCatalog, loadMySubmissions, loadAuth]) + + // Only Admins/Super-Admins need the review queue at all — and role isn't + // known until loadAuth() above resolves, so this is a separate effect + // keyed on it rather than bundled into the one above. + useEffect(() => { + if (role === 'admin' || role === 'super_admin') loadAdminQueue() + }, [role, loadAdminQueue]) if (!isLoaded || !isCatalogLoaded) { return
Loading…
diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index d69a977..c2d1733 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -1,39 +1,61 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { downloadProjectFile, readProjectFile, ProjectImportError } from '../../data/exportImport' import { supabase } from '../../data/supabaseClient' +import { useAdminReviewStore } from '../../state/adminReviewStore' +import { useAuthStore } from '../../state/authStore' import { useProjectStore } from '../../state/projectStore' import { useSubmissionStore } from '../../state/submissionStore' +import AdminReviewModal from '../submissions/AdminReviewModal' import MySubmissionsModal from '../submissions/MySubmissionsModal' import DiagramManagerModal from './DiagramManagerModal' +// When "My Submissions" was last opened — a per-user UI preference (like +// hiddenPublicDeviceTemplateIds in catalogStore), used to badge outcomes +// (approved/rejected) you haven't seen yet without a server-side +// notifications table. +const LAST_SEEN_SUBMISSIONS_KEY = 'av-planner:lastSeenSubmissionsAt' +function getLastSeenSubmissionsAt(): string { + try { + return window.localStorage.getItem(LAST_SEEN_SUBMISSIONS_KEY) ?? '' + } catch { + return '' + } +} +function setLastSeenSubmissionsAt(iso: string): void { + try { + window.localStorage.setItem(LAST_SEEN_SUBMISSIONS_KEY, iso) + } catch { + // Not essential — worst case an already-seen outcome stays badged. + } +} + export default function TopBar() { const project = useProjectStore((s) => s.project) const renameProject = useProjectStore((s) => s.renameProject) const importProject = useProjectStore((s) => s.importProject) const mySubmissions = useSubmissionStore((s) => s.mySubmissions) + const username = useAuthStore((s) => s.username) + const role = useAuthStore((s) => s.role) + const isAdmin = role === 'admin' || role === 'super_admin' + const adminPendingCount = useAdminReviewStore((s) => s.allSubmissions.filter((sub) => sub.status === 'pending').length) const fileInputRef = useRef(null) const [diagramManagerOpen, setDiagramManagerOpen] = useState(false) const [submissionsOpen, setSubmissionsOpen] = useState(false) - const [username, setUsername] = useState(null) + const [adminReviewOpen, setAdminReviewOpen] = useState(false) const pendingSubmissionCount = useMemo( () => mySubmissions.filter((s) => s.status === 'pending').length, [mySubmissions], ) + const unseenOutcomeCount = useMemo(() => { + const lastSeen = getLastSeenSubmissionsAt() + return mySubmissions.filter((s) => s.status !== 'pending' && s.updatedAt > lastSeen).length + }, [mySubmissions]) - // One-time fetch — nothing else in the app changes your own username after - // CompleteProfileScreen sets it, so there's no need to keep this live. - useEffect(() => { - supabase.auth.getUser().then(({ data: { user } }) => { - if (!user) return - supabase - .from('profiles') - .select('username') - .eq('id', user.id) - .single() - .then(({ data }) => setUsername(data?.username ?? null)) - }) - }, []) + const openMySubmissions = () => { + setSubmissionsOpen(true) + setLastSeenSubmissionsAt(new Date().toISOString()) + } const handleExport = () => downloadProjectFile(project) @@ -69,17 +91,36 @@ export default function TopBar() { > Diagrams - + {isAdmin && ( + + )} @@ -100,6 +141,7 @@ export default function TopBar() { {diagramManagerOpen && setDiagramManagerOpen(false)} />} {submissionsOpen && setSubmissionsOpen(false)} />} + {adminReviewOpen && setAdminReviewOpen(false)} />} ) } diff --git a/src/components/submissions/AdminReviewModal.tsx b/src/components/submissions/AdminReviewModal.tsx new file mode 100644 index 0000000..1df1531 --- /dev/null +++ b/src/components/submissions/AdminReviewModal.tsx @@ -0,0 +1,250 @@ +import { useEffect, useMemo, useState } from 'react' +import type { UsageImpact } from '../../data/AdminSubmissionRepository' +import type { CatalogSubmission } from '../../data/SubmissionRepository' +import type { Catalog } from '../../domain/types' +import { useAdminReviewStore } from '../../state/adminReviewStore' +import { useCatalogStore } from '../../state/catalogStore' +import Modal from '../common/Modal' +import { catalogEntityToRow, findCatalogEntity } from './catalogEntityLookup' +import { findLikelyDuplicates } from './duplicateDetection' +import { diffRows, fieldLabel, formatDiffValue } from './submissionDiff' + +const ENTITY_TYPE_LABELS: Record = { + device_template: 'Device', + port_type: 'Port type', + cable_type: 'Cable type', + device_category: 'Category', +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) +} + +function SubmissionReviewCard({ + submission, + catalog, + submitterUsername, +}: { + submission: CatalogSubmission + catalog: Catalog + submitterUsername: string | undefined +}) { + const approve = useAdminReviewStore((s) => s.approve) + const reject = useAdminReviewStore((s) => s.reject) + const getUsageImpact = useAdminReviewStore((s) => s.getUsageImpact) + const [rejecting, setRejecting] = useState(false) + const [reason, setReason] = useState('') + const [busy, setBusy] = useState(false) + const [impact, setImpact] = useState(null) + const [impactLoading, setImpactLoading] = useState(false) + + const currentEntity = findCatalogEntity(catalog, submission.entityType, submission.entityId) + // Already public: this is a "suggest an edit" submission, so the impact + // check applies (organized-ideas.md §3) — an edit to an entry already in + // use elsewhere is the case that check exists for. A submission whose + // entity is still private is a "promote my own entry" submission — only + // the submitter's own diagrams could reference it, nothing to warn about. + const isEditToPublicEntry = currentEntity?.custom === false + + useEffect(() => { + if (!isEditToPublicEntry) return + setImpactLoading(true) + getUsageImpact(submission.entityType, submission.entityId) + .then(setImpact) + .finally(() => setImpactLoading(false)) + // Only ever needs to run once per card mount — the entity/submission + // this card is about doesn't change out from under it. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isEditToPublicEntry]) + + const diffs = useMemo(() => { + const currentRow = currentEntity ? catalogEntityToRow(submission.entityType, currentEntity) : {} + return diffRows(currentRow, submission.proposedData) + }, [currentEntity, submission.entityType, submission.proposedData]) + + const duplicates = useMemo(() => { + if (isEditToPublicEntry || submission.entityType !== 'device_template') return [] + const proposedName = typeof submission.proposedData.name === 'string' ? submission.proposedData.name : '' + if (!proposedName) return [] + const publicTemplates = catalog.deviceTemplates + .filter((dt) => !dt.custom && dt.id !== submission.entityId) + .map((dt) => ({ id: dt.id, name: dt.name })) + return findLikelyDuplicates(proposedName, publicTemplates) + }, [isEditToPublicEntry, submission.entityType, submission.entityId, submission.proposedData, catalog.deviceTemplates]) + + const handleApprove = async () => { + setBusy(true) + try { + await approve(submission) + } finally { + setBusy(false) + } + } + + const handleReject = async () => { + if (!reason.trim()) return + setBusy(true) + try { + await reject(submission.id, reason.trim()) + } finally { + setBusy(false) + setRejecting(false) + setReason('') + } + } + + const displayName = currentEntity?.name ?? (typeof submission.proposedData.name === 'string' ? submission.proposedData.name : submission.entityId) + + return ( +
+
+
+
+ {displayName} + + {ENTITY_TYPE_LABELS[submission.entityType]} + + {!isEditToPublicEntry && ( + + New public entry + + )} +
+
+ {submitterUsername ?? 'Unknown user'} · submitted {formatDate(submission.createdAt)} +
+
+
+ + {!currentEntity && ( +

+ The underlying entry no longer exists — this submission can only be rejected. +

+ )} + + {diffs.length > 0 && ( +
+ {diffs.map((d) => ( +
+ {fieldLabel(d.key)}: + {formatDiffValue(d.key, d.before)} + → + {formatDiffValue(d.key, d.after)} +
+ ))} +
+ )} + {diffs.length === 0 && currentEntity && ( +

No changes proposed beyond publishing this as-is.

+ )} + + {duplicates.length > 0 && ( +

+ Possible duplicate of: + {duplicates.map((d) => d.name).join(', ')} +

+ )} + + {isEditToPublicEntry && ( +
+ {impactLoading ? ( + Checking how many diagrams use this… + ) : impact ? ( + impact.diagramCount === 0 ? ( + Not currently used in any diagram. + ) : ( +
+ + Used in {impact.diagramCount} diagram{impact.diagramCount === 1 ? '' : 's'}: + {' '} + + {impact.sample.map((s) => `${s.name} (${s.ownerUsername})`).join(', ')} + {impact.diagramCount > impact.sample.length ? ', …' : ''} + +
+ ) + ) : null} +
+ )} + + {rejecting ? ( +
+