Files
Eunjae LeeGitHubClaude Opus 4.5Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
81e4241f85 refactor: apply biome formatting to apps/web (#27692)
* 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>
2026-02-10 23:25:24 +05:30

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>
);
}