From 8bd0e908a1d797d163716c145de38cc22abd86e2 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Tue, 28 Apr 2026 16:42:36 +0330 Subject: [PATCH] feat(logs): add workspace activity log page --- .gitignore | 4 +- src/App.tsx | 2 + src/api/logs.ts | 140 +++++++++++ src/components/Sidebar.tsx | 14 ++ src/components/logs/LogDetailsPanel.tsx | 182 ++++++++++++++ src/components/logs/LogsFeed.tsx | 203 +++++++++++++++ src/components/logs/LogsFilterBar.tsx | 221 +++++++++++++++++ src/lib/permissions.ts | 4 + src/locales/en.ts | 97 +++++++- src/locales/fa.ts | 90 ++++++- src/pages/Logs.tsx | 312 ++++++++++++++++++++++++ 11 files changed, 1247 insertions(+), 22 deletions(-) create mode 100644 src/api/logs.ts create mode 100644 src/components/logs/LogDetailsPanel.tsx create mode 100644 src/components/logs/LogsFeed.tsx create mode 100644 src/components/logs/LogsFilterBar.tsx create mode 100644 src/pages/Logs.tsx diff --git a/.gitignore b/.gitignore index 3b0b403..6d426d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # Logs logs +!src/components/logs/ +!src/components/logs/** *.log npm-debug.log* yarn-debug.log* @@ -23,4 +25,4 @@ dist-ssr *.sln *.sw? -.env \ No newline at end of file +.env diff --git a/src/App.tsx b/src/App.tsx index 0cba72b..97f3629 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import ProjectEdit from "./pages/ProjectEdit" import Tags from "./pages/Tags" import Reports from "./pages/Reports" import Timesheet from "./pages/Timesheet" +import Logs from "./pages/Logs" const MainLayout = () => { const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false) @@ -65,6 +66,7 @@ const router = createBrowserRouter([ { path: "/profile", element: }, { path: "/timesheet", element: }, { path: "/reports", element: }, + { path: "/logs", element: }, { path: "/tags", element: }, { path: "/workspaces", element: }, { path: "/workspaces/create", element: }, diff --git a/src/api/logs.ts b/src/api/logs.ts new file mode 100644 index 0000000..1329bff --- /dev/null +++ b/src/api/logs.ts @@ -0,0 +1,140 @@ +import { authFetch } from "./client"; + +export type WorkspaceLogSection = + | "workspace" + | "workspace_members" + | "clients" + | "projects" + | "project_members" + | "tags" + | "time_entries" + | "rates" + | "report_exports"; + +export type WorkspaceLogEvent = + | "create" + | "update" + | "delete" + | "restore" + | "archive" + | "unarchive" + | "activate" + | "deactivate"; + +export interface WorkspaceLogActor { + id: string; + full_name: string; + mobile?: string | null; + profile_picture?: string | null; +} + +export interface WorkspaceLogTarget { + id: string; + name: string; + section: WorkspaceLogSection; + workspace_id: string; +} + +export interface WorkspaceLogPreviewChange { + field: string; + label: string; + summary: string; +} + +export interface WorkspaceLogItem { + id: number; + timestamp: string; + section: WorkspaceLogSection; + model: string | null; + event: WorkspaceLogEvent; + audit_action: string; + actor: WorkspaceLogActor | null; + target: WorkspaceLogTarget; + changed_fields: string[]; + preview_changes: WorkspaceLogPreviewChange[]; +} + +export interface WorkspaceLogChangeRow { + field: string; + label: string; + change_type: "field" | "m2m"; + operation: string; + old_value: string | null; + new_value: string | null; + summary: string; +} + +export interface WorkspaceLogDetail extends WorkspaceLogItem { + remote_addr?: string | null; + changes: WorkspaceLogChangeRow[]; + raw_changes: Record; + serialized_snapshot?: unknown; + additional_data: Record; +} + +export interface WorkspaceLogFilters { + workspace: string; + section?: WorkspaceLogSection | ""; + actor?: string; + event?: WorkspaceLogEvent | ""; + search?: string; + from?: string; + to?: string; + ordering?: "-timestamp" | "timestamp"; +} + +interface PaginatedResponse { + count: number; + next: string | null; + previous: string | null; + results: T[]; +} + +const toQueryString = (params: Record) => { + const query = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + query.set(key, String(value)); + } + }); + return query.toString(); +}; + +export const listWorkspaceLogs = async ( + filters: WorkspaceLogFilters, + pagination?: { limit?: number; offset?: number }, +): Promise> => { + const query = toQueryString({ + workspace: filters.workspace, + section: filters.section || undefined, + actor: filters.actor || undefined, + event: filters.event || undefined, + search: filters.search || undefined, + from: filters.from || undefined, + to: filters.to || undefined, + ordering: filters.ordering || "-timestamp", + limit: pagination?.limit, + offset: pagination?.offset, + }); + + const response = await authFetch(`/api/logs/${query ? `?${query}` : ""}`); + if (!response.ok) { + throw new Error("Failed to fetch workspace logs."); + } + + const data = await response.json(); + return { + count: data.count || 0, + next: data.next || null, + previous: data.previous || null, + results: data.results || [], + }; +}; + +export const getWorkspaceLogDetail = async (id: number): Promise => { + const response = await authFetch(`/api/logs/${id}/`); + if (!response.ok) { + throw new Error("Failed to fetch workspace log detail."); + } + return await response.json(); +}; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index b93a813..726d94b 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -11,9 +11,12 @@ import { Briefcase, ChartColumn, Clock3, + History, Tags, } from 'lucide-react'; +import { useWorkspace } from '../context/WorkspaceContext'; import { useTranslation } from '../hooks/useTranslation'; +import { canWorkspace, WORKSPACE_LOGS_VIEW } from '../lib/permissions'; type SidebarProps = { mobileOpen?: boolean; @@ -24,7 +27,9 @@ export const Sidebar = ({ mobileOpen = false, onMobileClose }: SidebarProps) => const [isCollapsed, setIsCollapsed] = useState(false); const { t, lang } = useTranslation(); + const { activeWorkspace } = useWorkspace(); const isRtl = lang === 'fa'; + const canViewLogs = canWorkspace(activeWorkspace?.my_role, WORKSPACE_LOGS_VIEW); const ToggleIcon = isRtl ? (isCollapsed ? PanelRightOpen : PanelRightClose) @@ -61,6 +66,15 @@ export const Sidebar = ({ mobileOpen = false, onMobileClose }: SidebarProps) => icon: ChartColumn, label: t.sidebar?.reports || 'Reports' }, + ...(canViewLogs + ? [ + { + path: '/logs', + icon: History, + label: t.sidebar?.logs || 'Logs', + }, + ] + : []), ]; const renderNavItems = (mobile = false) => diff --git a/src/components/logs/LogDetailsPanel.tsx b/src/components/logs/LogDetailsPanel.tsx new file mode 100644 index 0000000..7fba62f --- /dev/null +++ b/src/components/logs/LogDetailsPanel.tsx @@ -0,0 +1,182 @@ +import { Clock3, Globe2, User2, X } from "lucide-react"; + +import type { WorkspaceLogDetail } from "../../api/logs"; +import { API_BASE_URL } from "../../config/constants"; +import { useTranslation } from "../../hooks/useTranslation"; +import { Button } from "../ui/button"; + +const resolveImageUrl = (value?: string | null) => { + if (!value) return null; + if (/^https?:\/\//i.test(value)) return value; + return `${API_BASE_URL.replace(/\/+$/, "")}/${value.replace(/^\/+/, "")}`; +}; + +export function LogDetailsPanel({ + open, + log, + isLoading, + onClose, +}: { + open: boolean; + log: WorkspaceLogDetail | null; + isLoading: boolean; + onClose: () => void; +}) { + const { t, lang } = useTranslation(); + + if (!open) { + return null; + } + + const formatTimestamp = (value?: string | null) => { + if (!value) return "-"; + return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); + }; + + const actorName = log?.actor?.full_name || t.logs?.unknownActor || "Unknown actor"; + const actorAvatar = resolveImageUrl(log?.actor?.profile_picture); + + return ( +
+ +
+ +
+ {isLoading ? ( +
+ {t.logs?.loadingDetails || "Loading details..."} +
+ ) : !log ? ( +
+ {t.logs?.selectLogHint || "Select a log entry to see its details."} +
+ ) : ( +
+
+
+
+ {actorAvatar ? ( + {actorName} + ) : ( + {actorName.trim().charAt(0).toUpperCase()} + )} +
+
+

{actorName}

+
+
+ + {log.actor?.mobile || "-"} +
+
+ + {formatTimestamp(log.timestamp)} +
+ {log.remote_addr ? ( +
+ + {log.remote_addr} +
+ ) : null} +
+
+
+
+ +
+
+

+ {t.logs?.target || "Target"} +

+

{log.target.name}

+

+ {t.logs?.sections?.[log.section] || log.section} +

+
+
+

+ {t.logs?.event || "Event"} +

+

+ {t.logs?.events?.[log.event] || log.event} +

+

{log.audit_action}

+
+
+ +
+
+

+ {t.logs?.changesTitle || "Changes"} +

+
+ {log.changes.length ? ( +
+ {log.changes.map((change, index) => ( +
+
+

{change.label}

+ {/*

{change.summary}

*/} +
+
+

+ {t.logs?.previousValue || "Previous"} +

+

+ {change.old_value || "-"} +

+
+
+

+ {t.logs?.currentValue || "Current"} +

+

+ {change.new_value || "-"} +

+
+
+ ))} +
+ ) : ( +
+ {t.logs?.noDetails || "No field-level details are available for this activity."} +
+ )} +
+ + {log.serialized_snapshot ? ( +
+ + {t.logs?.snapshot || "Serialized snapshot"} + +
+                    {JSON.stringify(log.serialized_snapshot, null, 2)}
+                  
+
+ ) : null} +
+ )} +
+ + + ); +} diff --git a/src/components/logs/LogsFeed.tsx b/src/components/logs/LogsFeed.tsx new file mode 100644 index 0000000..f4d072f --- /dev/null +++ b/src/components/logs/LogsFeed.tsx @@ -0,0 +1,203 @@ +import { Clock3, History, Sparkles } from "lucide-react"; + +import type { WorkspaceLogItem } from "../../api/logs"; +import { API_BASE_URL } from "../../config/constants"; +import { useTranslation } from "../../hooks/useTranslation"; +import { Button } from "../ui/button"; + +const eventBadgeStyles: Record = { + create: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300", + update: "bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300", + delete: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300", + restore: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300", + archive: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300", + unarchive: "bg-lime-100 text-lime-700 dark:bg-lime-900/30 dark:text-lime-300", + activate: "bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300", + deactivate: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300", +}; + +const sectionBadgeStyles: Record = { + workspace: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300", + workspace_members: "bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300", + clients: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300", + projects: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300", + project_members: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300", + tags: "bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300", + time_entries: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300", + rates: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300", + report_exports: "bg-fuchsia-100 text-fuchsia-700 dark:bg-fuchsia-900/30 dark:text-fuchsia-300", +}; + +const resolveImageUrl = (value?: string | null) => { + if (!value) return null; + if (/^https?:\/\//i.test(value)) return value; + return `${API_BASE_URL.replace(/\/+$/, "")}/${value.replace(/^\/+/, "")}`; +}; + +export function LogsFeed({ + items, + total, + hasMore, + isLoading, + isLoadingMore, + selectedId, + onOpen, + onLoadMore, +}: { + items: WorkspaceLogItem[]; + total: number; + hasMore: boolean; + isLoading: boolean; + isLoadingMore: boolean; + selectedId: number | null; + onOpen: (id: number) => void; + onLoadMore: () => void; +}) { + const { t, lang } = useTranslation(); + + const formatTimestamp = (value: string) => + new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); + + const buildSummary = (item: WorkspaceLogItem) => { + const actor = item.actor?.full_name || t.logs?.unknownActor || "Unknown actor"; + const eventLabel = t.logs?.events?.[item.event] || item.event; + const sectionLabel = t.logs?.sections?.[item.section] || item.section; + const target = item.target?.name || "-"; + if (typeof t.logs?.summary === "function") { + return t.logs.summary(actor, eventLabel, sectionLabel, target); + } + return `${actor} ${eventLabel.toLowerCase()} ${target} in ${sectionLabel.toLowerCase()}`; + }; + + const getInitials = (item: WorkspaceLogItem) => { + const label = item.actor?.full_name || t.logs?.unknownActor || "?"; + return label.trim().charAt(0).toUpperCase(); + }; + + if (isLoading) { + return ( +
+ {t.logs?.loading || "Loading logs..."} +
+ ); + } + + if (!items.length) { + return ( +
+
+ +
+

+ {t.logs?.empty || "No activity logs found"} +

+

+ {t.logs?.emptyHint || "Adjust your filters or wait for new workspace activity."} +

+
+ ); + } + + return ( +
+
+
+
+

+ {t.logs?.resultsCount?.(total) || `${total} results`} +

+

+ {t.logs?.detailsHint || "Select an activity item to inspect the exact field changes."} +

+
+
+ + {items.length}/{total} +
+
+ +
+ {items.map((item) => { + const avatarUrl = resolveImageUrl(item.actor?.profile_picture); + return ( + + ); + })} +
+
+ + {hasMore ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/src/components/logs/LogsFilterBar.tsx b/src/components/logs/LogsFilterBar.tsx new file mode 100644 index 0000000..34b9572 --- /dev/null +++ b/src/components/logs/LogsFilterBar.tsx @@ -0,0 +1,221 @@ +import { useEffect, useState } from "react"; +import { Filter, RotateCcw, Search } from "lucide-react"; + +import type { WorkspaceLogEvent, WorkspaceLogSection } from "../../api/logs"; +import type { WorkspaceMembership } from "../../api/workspaces"; +import JalaliDatePicker from "../ui/JalaliDatePicker"; +import { SearchableSelect } from "../ui/SearchableSelect"; +import { Select } from "../ui/Select"; +import { Input } from "../ui/input"; +import { useTranslation } from "../../hooks/useTranslation"; + +export interface LogsFilterDraft { + search: string; + section: "" | WorkspaceLogSection; + event: "" | WorkspaceLogEvent; + actor: string; + from: string; + to: string; + ordering: "-timestamp" | "timestamp"; +} + +export function LogsFilterBar({ + value, + users, + isLoadingUsers, + canSelectUsers, + onApply, +}: { + value: LogsFilterDraft; + users: WorkspaceMembership[]; + isLoadingUsers: boolean; + canSelectUsers: boolean; + onApply: (value: LogsFilterDraft) => void; +}) { + const { t } = useTranslation(); + const [draft, setDraft] = useState(value); + + useEffect(() => { + setDraft(value); + }, [value]); + + useEffect(() => { + if (!canSelectUsers && draft.actor) { + setDraft((current) => ({ ...current, actor: "" })); + } + }, [canSelectUsers, draft.actor]); + + return ( +
+
+
+ +
+ + setDraft((current) => ({ ...current, search: event.target.value }))} + placeholder={t.logs?.searchPlaceholder || "Search logs..."} + className="h-10 rounded-2xl border-slate-200 bg-white pl-10 dark:border-slate-700 dark:bg-slate-900" + /> +
+
+ +
+ + + setDraft((current) => ({ ...current, event: event as "" | WorkspaceLogEvent })) + } + options={[ + { value: "", label: t.logs?.allEvents || "All events" }, + { value: "create", label: t.logs?.events?.create || "Create" }, + { value: "update", label: t.logs?.events?.update || "Update" }, + { value: "delete", label: t.logs?.events?.delete || "Delete" }, + { value: "restore", label: t.logs?.events?.restore || "Restore" }, + { value: "archive", label: t.logs?.events?.archive || "Archive" }, + { value: "unarchive", label: t.logs?.events?.unarchive || "Unarchive" }, + { value: "activate", label: t.logs?.events?.activate || "Activate" }, + { value: "deactivate", label: t.logs?.events?.deactivate || "Deactivate" }, + ]} + className="w-full" + buttonClassName="h-10 w-full rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900" + /> +
+ + {canSelectUsers || isLoadingUsers ? ( +
+ + {isLoadingUsers ? ( +
+ {t.logs?.loadingUsers || "Loading users..."} +
+ ) : ( + setDraft((current) => ({ ...current, actor }))} + options={[ + { value: "", label: t.logs?.allActors || "All actors" }, + ...users.map((membership) => ({ + value: membership.user.id, + label: + `${membership.user.first_name || ""} ${membership.user.last_name || ""}`.trim() || + membership.user.email || + membership.user.id, + searchText: membership.user.mobile || "", + })), + ]} + placeholder={t.logs?.allActors || "All actors"} + searchPlaceholder={t.logs?.searchActors || "Search users..."} + className="w-full" + buttonClassName="h-10 rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900" + /> + )} +
+ ) : null} + +
+ + setDraft((current) => ({ ...current, from: nextValue }))} + placeholder="YYYY/MM/DD" + inputClassName="h-10 rounded-2xl border border-slate-200 bg-white px-3 text-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100" + /> +
+ +
+ + setDraft((current) => ({ ...current, to: nextValue }))} + placeholder="YYYY/MM/DD" + inputClassName="h-10 rounded-2xl border border-slate-200 bg-white px-3 text-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100" + /> +
+ +
+ +