@@ -1109,7 +1107,7 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
border: '1px solid var(--color-border)',
overflow: 'hidden',
display: 'flex', flexDirection: 'column',
- height: 320,
+ minHeight: 320,
}}>
({ value: k, label: v })),
+]
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+function actionMeta(action) {
+ return ACTION_META[action] ?? { label: action, variant: 'neutral' }
+}
+
+function buildParams({ actorId, action, entityType, fromDate, toDate, offset }) {
+ const p = new URLSearchParams()
+ if (actorId) p.set('actor_id', actorId)
+ if (action) p.set('action', action)
+ if (entityType) p.set('entity_type', entityType)
+ if (fromDate) p.set('from_date', new Date(fromDate).toISOString())
+ if (toDate) p.set('to_date', new Date(toDate + 'T23:59:59').toISOString())
+ p.set('limit', String(PAGE_SIZE))
+ p.set('offset', String(offset))
+ return p.toString()
+}
+
+// ─── Changes diff renderer ────────────────────────────────────────────────────
+
+function ChangesDiff({ changes }) {
+ if (!changes || Object.keys(changes).length === 0) return null
+ return (
+
+
+ Changes
+
+ {Object.entries(changes).map(([field, { old: oldVal, new: newVal }]) => (
+
+
+ {field}
+
+
+ {oldVal === null || oldVal === undefined ? null : String(oldVal)}
+
+
+ {newVal === null || newVal === undefined ? null : String(newVal)}
+
+
+ ))}
+
+ )
+}
+
+// ─── Meta renderer ────────────────────────────────────────────────────────────
+
+function MetaBlock({ meta }) {
+ if (!meta || Object.keys(meta).length === 0) return null
+ return (
+
+
+ Context
+
+
+ {Object.entries(meta).map(([k, v]) => (
+
+ {k}
+ ·
+ {String(v)}
+
+ ))}
+
+
+ )
+}
+
+// ─── Row ──────────────────────────────────────────────────────────────────────
+
+function LogRow({ entry, isExpanded, onToggle }) {
+ const { label, variant } = actionMeta(entry.action)
+ const entityLabel = ENTITY_LABELS[entry.entity_type] ?? entry.entity_type
+ const hasDetail = (entry.changes && Object.keys(entry.changes).length > 0)
+ || (entry.meta && Object.keys(entry.meta).length > 0)
+
+ return (
+ <>
+
+ {/* Timestamp */}
+ |
+
+ {fmtDateTimeMedium(entry.occurred_at)}
+
+ |
+
+ {/* Actor */}
+
+
+
+ {entry.actor_name?.charAt(0)?.toUpperCase() ?? '?'}
+
+
+ {entry.actor_name}
+
+
+ |
+
+ {/* Action badge */}
+
+ {label}
+ |
+
+ {/* Entity */}
+
+
+
+ {entityLabel}
+
+ {entry.entity_label && (
+
+ {entry.entity_label}
+
+ )}
+
+ |
+
+ {/* Entity ID */}
+
+
+ {entry.entity_id?.length > 16
+ ? entry.entity_id.slice(0, 8) + '…' + entry.entity_id.slice(-4)
+ : entry.entity_id}
+
+ |
+
+ {/* Expand chevron */}
+
+ {hasDetail && (
+
+ )}
+ |
+
+
+ {/* Expanded detail row */}
+ {isExpanded && hasDetail && (
+
+ |
+
+
+
+
+ |
+
+ )}
+ >
+ )
+}
+
+// ─── Skeleton ─────────────────────────────────────────────────────────────────
+
+function SkeletonRows() {
+ return Array.from({ length: 10 }).map((_, i) => (
+
+ {[140, 120, 80, 140, 100, 24].map((w, j) => (
+ |
+
+ |
+ ))}
+
+ ))
+}
+
+// ─── Filters bar ──────────────────────────────────────────────────────────────
+
+function FiltersBar({ filters, setFilters, staffList, onReset }) {
+ const hasFilters = filters.actorId || filters.action || filters.entityType
+ || filters.fromDate || filters.toDate
+
+ return (
+
+ {/* Actor / staff member */}
+
+
+
+
+ {/* Action */}
+
+
+
+
+ {/* Entity type */}
+
+
+
+
+ {/* Date range */}
+
+ setFilters(f => ({ ...f, fromDate: e.target.value, offset: 0 }))}
+ aria-label="From date"
+ className="log-date-input"
+ />
+ –
+ setFilters(f => ({ ...f, toDate: e.target.value, offset: 0 }))}
+ aria-label="To date"
+ className="log-date-input"
+ />
+
+
+ {/* Reset */}
+ {hasFilters && (
+
+ )}
+
+ )
+}
+
+// ─── Summary stats ────────────────────────────────────────────────────────────
+
+function StatPill({ label, value, variant }) {
+ const colors = {
+ success: 'var(--color-success)',
+ danger: 'var(--color-danger)',
+ warning: 'var(--color-warning)',
+ info: 'var(--color-info)',
+ neutral: 'var(--color-text-muted)',
+ }
+ return (
+
+
+
+ {label}
+
+
+ {value}
+
+
+ )
+}
+
+// ─── Main page ────────────────────────────────────────────────────────────────
+
+const INITIAL_FILTERS = {
+ actorId: '',
+ action: '',
+ entityType: '',
+ fromDate: '',
+ toDate: '',
+ offset: 0,
+}
+
+export default function LogViewerPage() {
+ const { user } = useAuth()
+
+ const [entries, setEntries] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [total, setTotal] = useState(null)
+ const [staffList, setStaffList] = useState([])
+ const [filters, setFilters] = useState(INITIAL_FILTERS)
+ const [expandedId, setExpandedId] = useState(null)
+ const [stats, setStats] = useState(null)
+
+ // Derived pagination
+ const page = Math.floor(filters.offset / PAGE_SIZE) + 1
+ const pageCount = total != null ? Math.ceil(total / PAGE_SIZE) : 0
+
+ // Load staff list once for the actor dropdown
+ useEffect(() => {
+ api.get('/staff').then(data => {
+ setStaffList((data.staff ?? data ?? []).map(s => ({ id: s.id, name: s.name })))
+ }).catch(() => {})
+ }, [])
+
+ // Load entries whenever filters change
+ const fetchEntries = useCallback(async () => {
+ setLoading(true)
+ setError(null)
+ setExpandedId(null)
+ try {
+ const qs = buildParams(filters)
+ const data = await api.get(`/audit-log?${qs}`)
+ const rows = data.entries ?? []
+ setEntries(rows)
+
+ // The API doesn't return total — fetch count via a separate offset-trick:
+ // if we got a full page, there may be more; signal unknown total for now.
+ setTotal(null)
+
+ // Build quick stats from current page
+ const actionCounts = {}
+ rows.forEach(r => { actionCounts[r.action] = (actionCounts[r.action] ?? 0) + 1 })
+ setStats(actionCounts)
+ } catch (err) {
+ setError(err?.message ?? 'Failed to load audit log.')
+ } finally {
+ setLoading(false)
+ }
+ }, [filters])
+
+ useEffect(() => { fetchEntries() }, [fetchEntries])
+
+ function handleReset() {
+ setFilters(INITIAL_FILTERS)
+ }
+
+ function toggleExpand(id) {
+ setExpandedId(prev => prev === id ? null : id)
+ }
+
+ // ── Render states ────────────────────────────────────────────────────────
+
+ const isEmpty = !loading && !error && entries.length === 0
+
+ return (
+ <>
+
+
+
+
+
+ {/* ── Stats strip ─────────────────────────────────────────────── */}
+ {stats && Object.keys(stats).length > 0 && (
+
+ {Object.entries(stats)
+ .sort((a, b) => b[1] - a[1])
+ .map(([action, count]) => {
+ const { label, variant } = actionMeta(action)
+ return
+ })}
+
+ )}
+
+ {/* ── Filters ──────────────────────────────────────────────────── */}
+
+
+ {/* ── Table ────────────────────────────────────────────────────── */}
+
+
+
+
+ | Timestamp |
+ Staff |
+ Action |
+ Entity |
+ ID |
+ |
+
+
+
+ {loading ? (
+
+ ) : error ? (
+
+
+
+
+ {error}
+
+
+ |
+
+ ) : isEmpty ? (
+
+
+
+
+
+ No entries match these filters
+
+
+
+ |
+
+ ) : (
+ entries.map(entry => (
+ toggleExpand(entry.id)}
+ />
+ ))
+ )}
+
+
+
+
+ {/* ── Pagination ────────────────────────────────────────────────── */}
+ {!loading && !error && entries.length > 0 && (
+
+
+ Showing {filters.offset + 1}–{filters.offset + entries.length}
+ {entries.length === PAGE_SIZE && ' · more available'}
+
+
+
+
+
+
+ )}
+
+ >
+ )
+}
diff --git a/frontend/src/router/index.jsx b/frontend/src/router/index.jsx
index b2a0da0..bcbfd04 100644
--- a/frontend/src/router/index.jsx
+++ b/frontend/src/router/index.jsx
@@ -31,6 +31,7 @@ import StaffList from '@/pages/settings/staff/StaffList'
import StaffDetail from '@/pages/settings/staff/StaffDetail'
import StaffForm from '@/pages/settings/staff/StaffForm'
import PublicFeaturesSettings from '@/pages/settings/PublicFeaturesSettings'
+import LogViewerPage from '@/pages/settings/LogViewerPage'
import AutomationsPage from '@/pages/settings/automations/AutomationsPage'
import ApiReferencePage from '@/pages/engineering/developer/ApiReferencePage'
import CustomerList from '@/pages/crm/customers/CustomerList'
@@ -194,7 +195,7 @@ export default function V2Router() {
} />
} />
} />
- } />
+ } />
{/* Catch-all */}
} />
diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css
index 1ee306c..afd7a5d 100644
--- a/frontend/src/styles/components.css
+++ b/frontend/src/styles/components.css
@@ -40,6 +40,10 @@
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
+@keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
/* ==========================================================================
BUTTON (.btn)
@@ -1819,6 +1823,13 @@ tr:focus-within .btn-table-actions {
flex-shrink: 0;
}
+.sidebar-brand-logo {
+ width: 100%;
+ height: auto;
+ display: block;
+ object-fit: contain;
+}
+
/* Sidebar scrollable nav area */
.sidebar-nav {
flex: 1;
@@ -2223,6 +2234,159 @@ tr:focus-within .btn-table-actions {
appearance: none;
}
+/* ─── Global search dropdown ─────────────────────────────────────────────── */
+
+.gs-dropdown {
+ position: absolute;
+ top: calc(100% + var(--space-2));
+ left: 50%;
+ transform: translateX(-50%);
+ width: 572px;
+ max-height: 520px;
+ overflow-y: auto;
+ overflow-x: hidden;
+ background: var(--color-bg-void);
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-lg);
+ box-shadow: 0 20px 48px rgba(0, 0, 0, 0.55), 0 4px 12px rgba(0, 0, 0, 0.3);
+ z-index: 9999;
+ padding: var(--space-3);
+ animation: slide-up 0.14s cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+/* Section grouping */
+.gs-group {
+ padding-bottom: var(--space-1);
+}
+
+.gs-group-header {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3) var(--space-1);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-semibold);
+ letter-spacing: 0.07em;
+ text-transform: uppercase;
+ opacity: 0.75;
+}
+
+.gs-group-count {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ font-size: 10px;
+ font-weight: var(--font-weight-semibold);
+ background: var(--color-bg-elevated);
+ color: var(--color-text-muted);
+ border-radius: var(--radius-full);
+ line-height: 1;
+}
+
+.gs-divider {
+ height: 1px;
+ background: var(--color-border);
+ margin: var(--space-2) 0;
+}
+
+/* Result item — 2-row card */
+.gs-result-item {
+ position: relative;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-2) var(--space-3);
+ background: transparent;
+ border: none;
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ text-align: left;
+ transition: background-color 0.1s ease;
+ min-width: 0;
+}
+
+.gs-result-item--active::before {
+ content: '';
+ position: absolute;
+ inset: 0% 0%;
+ background: var(--gs-accent, var(--color-text-muted));
+ border-radius: 0%;
+ filter: blur(8px);
+ opacity: 0.10;
+ pointer-events: none;
+ z-index: 0;
+}
+
+.gs-result-item > * {
+ position: relative;
+ z-index: 1;
+}
+
+/* Icon chip */
+.gs-result-icon {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 30px;
+ height: 30px;
+ border-radius: var(--radius-md);
+}
+
+/* Text block — stacked 2 rows */
+.gs-result-body {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ min-width: 0;
+}
+
+.gs-result-label {
+ font-size: var(--font-size-base);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.3;
+}
+
+.gs-result-sublabel {
+ font-size: var(--font-size-xs);
+ color: var(--color-text-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.4;
+ font-family: var(--font-family-mono);
+ letter-spacing: 0.01em;
+}
+
+/* Empty state */
+.gs-empty {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-4) var(--space-3);
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+}
+
+.gs-empty-icon {
+ flex-shrink: 0;
+ opacity: 0.4;
+ display: flex;
+}
+
+.gs-empty em {
+ font-style: normal;
+ color: var(--color-text-secondary);
+}
+
/* Icon buttons: bell, gear */
.header-icon-btn {
display: flex;