mirror of
https://github.com/hanzoai/calendar.git
synced 2026-09-24 22:42:24 +00:00
* refactor: apply biome formatting to apps/web (batch 1) Formats apps/web non-modules directories: - apps/web/app - apps/web/lib - apps/web/pages - apps/web/styles - apps/web/server - apps/web/test - Root-level .ts and .mjs files Co-Authored-By: Claude Opus 4.5 <[email protected]> * refactor: apply biome formatting to apps/web/modules (batch 2) Formats smaller apps/web/modules directories: - onboarding, data-table, users, apps, auth - integration-attribute-sync, shell, availability - feature-flags, team, webhooks, getting-started - feature-opt-in, troubleshooter, schedules, notifications - signup-view.tsx Co-Authored-By: Claude Opus 4.5 <[email protected]> * refactor: apply biome formatting to apps/web/modules (batch 3) Formats medium apps/web/modules directories: - bookings - event-types - insights - embed Co-Authored-By: Claude Opus 4.5 <[email protected]> * refactor: apply biome formatting to apps/web/modules/ee (batch 4) Formats apps/web/modules/ee directory. Co-Authored-By: Claude Opus 4.5 <[email protected]> * refactor: apply biome formatting to apps/web (batch 5) Formats remaining apps/web directories: - apps/web/modules/settings - apps/web/modules/booking-audit - apps/web/playwright Co-Authored-By: Claude Opus 4.5 <[email protected]> * refactor: apply biome formatting to apps/web/components (batch 6) Formats remaining apps/web/components files. Co-Authored-By: Claude Opus 4.5 <[email protected]> * refactor: apply biome formatting to new apps/web files from main Formats newly added/modified files from main merge: - WorkflowStepContainer.tsx (resolved merge conflicts) - Newly moved files (blocklist, form-builder, schedules, etc.) - Other files modified in main Co-Authored-By: Claude Opus 4.5 <[email protected]> * refactor: apply biome formatting to new apps/web files from main Co-Authored-By: Claude Opus 4.5 <[email protected]> --------- Co-authored-by: Claude Opus 4.5 <[email protected]> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
467 lines
16 KiB
TypeScript
467 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import dayjs from "@calcom/dayjs";
|
|
import type { AuditActorType } from "@calcom/features/booking-audit/lib/repository/IAuditActorRepository";
|
|
import ServerTrans from "@calcom/lib/components/ServerTrans";
|
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
import { trpc } from "@calcom/trpc/react";
|
|
import { Avatar } from "@calcom/ui/components/avatar";
|
|
import { Button } from "@calcom/ui/components/button";
|
|
import { FilterSearchField, Select } from "@calcom/ui/components/form";
|
|
import { Icon, type IconName } from "@calcom/ui/components/icon";
|
|
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
|
import Link from "next/link";
|
|
import { useState } from "react";
|
|
|
|
interface BookingHistoryProps {
|
|
bookingUid: string;
|
|
}
|
|
|
|
type TranslationComponent = {
|
|
type: "link";
|
|
href: string;
|
|
};
|
|
|
|
type TranslationWithParams = {
|
|
key: string;
|
|
params?: Record<string, string | number>;
|
|
components?: TranslationComponent[];
|
|
};
|
|
|
|
type AuditLog = {
|
|
id: string;
|
|
action: string;
|
|
type: string;
|
|
timestamp: string;
|
|
source: string;
|
|
displayJson?: Record<string, unknown> | null;
|
|
actionDisplayTitle: TranslationWithParams;
|
|
displayFields?: Array<{ labelKey: string; valueKey?: string; value?: string; values?: string[] }> | null;
|
|
actor: {
|
|
type: AuditActorType;
|
|
displayName: string | null;
|
|
displayEmail: string | null;
|
|
displayAvatar: string | null;
|
|
};
|
|
impersonatedBy?: {
|
|
displayName: string;
|
|
displayEmail: string | null;
|
|
displayAvatar: string | null;
|
|
} | null;
|
|
hasError?: boolean;
|
|
};
|
|
|
|
interface BookingLogsFiltersProps {
|
|
searchTerm: string;
|
|
onSearchChange: (value: string) => void;
|
|
actorFilter: string | null;
|
|
onActorFilterChange: (value: string | null) => void;
|
|
actorOptions: Array<{ label: string; value: string }>;
|
|
}
|
|
|
|
interface BookingLogsTimelineProps {
|
|
logs: AuditLog[];
|
|
}
|
|
|
|
const ACTION_ICON_MAP: Record<string, IconName> = {
|
|
CREATED: "calendar",
|
|
CANCELLED: "ban",
|
|
REJECTED: "ban",
|
|
ACCEPTED: "check",
|
|
RESCHEDULED: "pencil",
|
|
RESCHEDULE_REQUESTED: "pencil",
|
|
REASSIGNMENT: "user-check",
|
|
ATTENDEE_ADDED: "user-check",
|
|
ATTENDEE_REMOVED: "user-check",
|
|
LOCATION_CHANGED: "map-pin",
|
|
NO_SHOW_UPDATED: "ban",
|
|
} as const;
|
|
|
|
const ACTOR_ROLE_LABEL_MAP: Record<AuditActorType, string | null> = {
|
|
GUEST: "guest",
|
|
ATTENDEE: "attendee",
|
|
SYSTEM: null,
|
|
USER: null,
|
|
APP: null,
|
|
} as const;
|
|
|
|
function BookingLogsFilters({
|
|
searchTerm,
|
|
onSearchChange,
|
|
actorFilter,
|
|
onActorFilterChange,
|
|
actorOptions,
|
|
}: BookingLogsFiltersProps) {
|
|
const { t } = useLocale();
|
|
|
|
return (
|
|
<div className="flex flex-wrap gap-2 items-start">
|
|
<div className="flex-1 min-w-[200px]">
|
|
<FilterSearchField
|
|
size="sm"
|
|
value={searchTerm}
|
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
containerClassName=""
|
|
/>
|
|
</div>
|
|
|
|
<div className="min-w-[140px]">
|
|
<Select
|
|
size="sm"
|
|
value={
|
|
actorFilter
|
|
? { label: `${t("actor")}: ${actorFilter}`, value: actorFilter }
|
|
: { label: `${t("actor")}: ${t("all")}`, value: "" }
|
|
}
|
|
onChange={(option) => {
|
|
if (!option) return;
|
|
onActorFilterChange(option.value || null);
|
|
}}
|
|
options={[{ label: t("all"), value: "" }, ...actorOptions]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Renders the action display title with support for Trans component interpolation
|
|
* Handles translations with embedded components (e.g., links) for proper i18n support
|
|
*/
|
|
function ActionTitle({ actionDisplayTitle }: { actionDisplayTitle: TranslationWithParams }) {
|
|
const { t } = useLocale();
|
|
|
|
if (actionDisplayTitle.components?.length) {
|
|
return (
|
|
<ServerTrans
|
|
t={t}
|
|
i18nKey={actionDisplayTitle.key}
|
|
values={actionDisplayTitle.params}
|
|
components={actionDisplayTitle.components.map((comp) =>
|
|
comp.type === "link" ? (
|
|
<Link key={comp.href} href={comp.href} className="text-emphasis underline hover:no-underline" />
|
|
) : (
|
|
<span key={comp.href} />
|
|
)
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return <>{t(actionDisplayTitle.key, actionDisplayTitle.params)}</>;
|
|
}
|
|
|
|
interface JsonViewerProps {
|
|
data: Record<string, unknown> | null;
|
|
}
|
|
|
|
function JsonViewer({ data }: JsonViewerProps) {
|
|
if (!data || Object.keys(data).length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const jsonString = JSON.stringify(data, null, 2);
|
|
const lines = jsonString.split("\n");
|
|
const lineCount = lines.length;
|
|
const lineNumberWidth = Math.max(2, Math.ceil(Math.log10(lineCount)) + 1);
|
|
|
|
return (
|
|
<div className="bg-default p-3 rounded-md text-[10px] overflow-x-auto font-mono">
|
|
{lines.map((line, idx) => (
|
|
<div key={idx} className="flex gap-2">
|
|
<span
|
|
className="text-subtle select-none text-right shrink-0"
|
|
style={{ minWidth: `${lineNumberWidth}ch` }}>
|
|
{idx + 1}
|
|
</span>
|
|
<span className="text-default whitespace-pre-wrap break-all">{line || " "}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface DisplayFieldValueProps {
|
|
field: {
|
|
valueKey?: string;
|
|
value?: string;
|
|
values?: string[];
|
|
};
|
|
}
|
|
|
|
function DisplayFieldValue({ field }: DisplayFieldValueProps) {
|
|
const { t } = useLocale();
|
|
|
|
if (field.values) {
|
|
return (
|
|
<span className="flex flex-col">
|
|
{field.values.map((v, i) => (
|
|
<span className="p-0.5" key={i}>
|
|
{v}
|
|
</span>
|
|
))}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return <>{field.value ?? (field.valueKey ? t(field.valueKey) : "")}</>;
|
|
}
|
|
|
|
function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
|
|
const { t } = useLocale();
|
|
const [expandedLogIds, setExpandedLogIds] = useState<Set<string>>(new Set());
|
|
const [showJsonMap, setShowJsonMap] = useState<Record<string, boolean>>({});
|
|
|
|
const toggleExpand = (logId: string) => {
|
|
setExpandedLogIds((prev) => {
|
|
const newSet = new Set(prev);
|
|
if (newSet.has(logId)) {
|
|
newSet.delete(logId);
|
|
} else {
|
|
newSet.add(logId);
|
|
}
|
|
return newSet;
|
|
});
|
|
};
|
|
|
|
const toggleJson = (logId: string) => {
|
|
setShowJsonMap((prev) => ({
|
|
...prev,
|
|
[logId]: !prev[logId],
|
|
}));
|
|
};
|
|
|
|
if (logs.length === 0) {
|
|
return (
|
|
<div className="text-center py-12">
|
|
<p className="text-muted">{t("no_audit_logs_found")}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-0.5">
|
|
{logs.map((log, index) => {
|
|
const isLast = index === logs.length - 1;
|
|
const isExpanded = expandedLogIds.has(log.id);
|
|
const showJson = showJsonMap[log.id] || false;
|
|
const actorRole = ACTOR_ROLE_LABEL_MAP[log.actor.type] ?? null;
|
|
return (
|
|
<div key={log.id} className="flex gap-1">
|
|
<div className="flex flex-col items-center self-stretch">
|
|
<div className="pt-2 shrink-0">
|
|
<div className="bg-subtle rounded-[3.556px] p-1 flex items-center justify-center w-4 h-4">
|
|
<Icon
|
|
name={log.hasError ? "triangle-alert" : (ACTION_ICON_MAP[log.action] ?? "sparkles")}
|
|
className={`h-3 w-3 ${log.hasError ? "text-attention" : "text-subtle"}`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{!isLast && <div className="w-px bg-subtle flex-1 min-h-0" />}
|
|
</div>
|
|
|
|
<div className="flex-1 rounded-lg py-2">
|
|
<div className="px-3 mb-2">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="text-sm font-medium leading-4 text-emphasis">
|
|
<ActionTitle actionDisplayTitle={log.actionDisplayTitle} />
|
|
</h3>
|
|
<div className="flex items-center gap-1 mt-1 text-xs text-subtle">
|
|
{log.actor.displayAvatar && (
|
|
<Avatar
|
|
size="xs"
|
|
imageSrc={log.actor.displayAvatar}
|
|
alt={log.actor.displayName || ""}
|
|
/>
|
|
)}
|
|
<span>
|
|
{log.actor.displayName}
|
|
{actorRole && <span>{` (${t(actorRole)})`}</span>}
|
|
</span>
|
|
<span>•</span>
|
|
<span>{dayjs(log.timestamp).fromNow()}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="px-3">
|
|
<div className="bg-muted rounded-lg border border-muted">
|
|
<div className="py-1">
|
|
<Button
|
|
color="minimal"
|
|
size="sm"
|
|
onClick={() => toggleExpand(log.id)}
|
|
StartIcon={isExpanded ? "chevron-down" : "chevron-right"}
|
|
className="justify-start text-xs font-medium text-subtle h-6">
|
|
{isExpanded ? t("hide_details") : t("show_details")}
|
|
</Button>
|
|
</div>
|
|
|
|
{isExpanded && (
|
|
<div className="bg-default rounded-lg m-0.5 text-xs">
|
|
{/* Render displayFields if available, otherwise show type */}
|
|
{log.displayFields && log.displayFields.length > 0
|
|
? log.displayFields.map((field, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
|
|
<span className="font-medium text-emphasis w-[140px]">{t(field.labelKey)}</span>
|
|
<span className="font-medium">
|
|
<DisplayFieldValue field={field} />
|
|
</span>
|
|
</div>
|
|
))
|
|
: null}
|
|
<div className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
|
|
<span className="font-medium text-emphasis w-[140px]">{t("actor")}</span>
|
|
<span className="text-default">{log.actor.displayName || log.actor.type}</span>
|
|
</div>
|
|
{log.impersonatedBy && (
|
|
<div className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
|
|
<span className="font-medium text-emphasis w-[140px]">
|
|
{t("booking_audit_action.actor_impersonated_by", {
|
|
actor: log.actor.displayName,
|
|
})}
|
|
</span>
|
|
<span className="text-default">{log.impersonatedBy.displayName}</span>
|
|
</div>
|
|
)}
|
|
<div className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
|
|
<span className="font-medium text-emphasis w-[140px]">
|
|
{t("booking_audit_action.source")}
|
|
</span>
|
|
<span className="text-default">{log.source}</span>
|
|
</div>
|
|
<div className="flex items-start gap-2 py-2 px-3 border-b border-subtle">
|
|
<span className="font-medium text-emphasis w-[140px]">{t("timestamp")}</span>
|
|
<span className="text-default">
|
|
{dayjs(log.timestamp).format("YYYY-MM-DD HH:mm:ss")}
|
|
</span>
|
|
</div>
|
|
{log.displayJson && Object.keys(log.displayJson).length > 0 && (
|
|
<div>
|
|
<div className="flex flex-col items-start gap-2 py-1 px-3 border-b border-subtle">
|
|
<Button
|
|
color="minimal"
|
|
size="sm"
|
|
onClick={() => toggleJson(log.id)}
|
|
StartIcon={showJson ? "chevron-down" : "chevron-right"}
|
|
className="-ml-3 h-6 px-2 font-medium text-xs">
|
|
{t("json")}
|
|
</Button>
|
|
</div>
|
|
<div>{showJson && <JsonViewer data={log.displayJson} />}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function useBookingLogsFilters(auditLogs: AuditLog[], searchTerm: string, actorFilter: string | null) {
|
|
const filteredLogs = auditLogs.filter((log) => {
|
|
const doesMatchDisplayFields = (): boolean => {
|
|
return (
|
|
log.displayFields?.some((field) => {
|
|
const searchLower = searchTerm.toLowerCase();
|
|
return (
|
|
field.valueKey?.toLowerCase().includes(searchLower) ||
|
|
field.labelKey?.toLowerCase().includes(searchLower) ||
|
|
field.value?.toLowerCase().includes(searchLower) ||
|
|
field.values?.some((v) => v.toLowerCase().includes(searchLower))
|
|
);
|
|
}) ?? false
|
|
);
|
|
};
|
|
|
|
const doesMatchActionDisplayTitle = (): boolean => {
|
|
return (
|
|
log.actionDisplayTitle.key?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
Object.values(log.actionDisplayTitle.params ?? {}).some((param) =>
|
|
param?.toString().toLowerCase().includes(searchTerm.toLowerCase())
|
|
)
|
|
);
|
|
};
|
|
|
|
const matchesSearch =
|
|
!searchTerm ||
|
|
log.action.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
log.actor.displayName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
doesMatchDisplayFields() ||
|
|
doesMatchActionDisplayTitle();
|
|
|
|
const matchesActor = !actorFilter || log.actor.displayName === actorFilter;
|
|
|
|
return matchesSearch && matchesActor;
|
|
});
|
|
|
|
const uniqueActorNames = Array.from(
|
|
new Set(auditLogs.map((log) => log.actor.displayName).filter((name): name is string => Boolean(name)))
|
|
);
|
|
|
|
const actorOptions = uniqueActorNames.map((actorName) => ({
|
|
label: actorName,
|
|
value: actorName,
|
|
}));
|
|
|
|
return { filteredLogs, actorOptions };
|
|
}
|
|
|
|
export function BookingHistory({ bookingUid }: BookingHistoryProps) {
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [actorFilter, setActorFilter] = useState<string | null>(null);
|
|
const { t } = useLocale();
|
|
const { data, isLoading, error } = trpc.viewer.bookings.getBookingHistory.useQuery({
|
|
bookingUid,
|
|
});
|
|
|
|
const auditLogs = data?.auditLogs || [];
|
|
|
|
const { filteredLogs, actorOptions } = useBookingLogsFilters(auditLogs, searchTerm, actorFilter);
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="text-center">
|
|
<p className="text-red-600 font-medium">{t("error_loading_booking_logs")}</p>
|
|
<p className="text-sm text-gray-500 mt-2">{error.message}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<SkeletonText className="h-12 w-full" />
|
|
<SkeletonText className="h-24 w-full" />
|
|
<SkeletonText className="h-24 w-full" />
|
|
<SkeletonText className="h-24 w-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<BookingLogsFilters
|
|
searchTerm={searchTerm}
|
|
onSearchChange={setSearchTerm}
|
|
actorFilter={actorFilter}
|
|
onActorFilterChange={setActorFilter}
|
|
actorOptions={actorOptions}
|
|
/>
|
|
|
|
<BookingLogsTimeline logs={filteredLogs} />
|
|
</div>
|
|
);
|
|
}
|