Files
EventHubFrontAdmin/src/pages/calendars/CalendarListPage.tsx
T

512 lines
23 KiB
TypeScript

import React, { useState, useEffect, useRef, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { ColumnDef } from '@tanstack/react-table';
import { Info, Pencil, Trash2 } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
import { useForm, Controller } from 'react-hook-form';
import { useCalendars, useUpdateCalendar, useDeleteCalendar, useCalendar, useCalendarStats } from '@/hooks/useCalendars';
import { useExploreView } from '@/hooks/useExploreView';
import { Calendar as CalendarType, CalendarListParams } from '@/types/api';
import { getSavedPageSize } from '@/utils/tablePagination';
import { formatStatusLabel } from '@/utils/statusLabels';
import { ExploreListShell } from '@/components/explore/ExploreListShell';
import { ExploreSearch } from '@/components/explore/ExploreSearch';
import { ExploreDataView } from '@/components/explore/ExploreDataView';
import { ExploreInsightsCollapse } from '@/components/explore/ExploreInsightsCollapse';
import { RowActionsMenu, type RowAction } from '@/components/explore/RowActionsMenu';
import type { DenseRowModel } from '@/components/explore/DenseRowList';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { EntitySlideOver, type ExplorePreviewTarget } from '@/components/EntitySlideOver';
const TABLE_KEY = 'calendars';
const calendarTypeBadgeVariant = (type: string) => {
if (type === 'commercial') return 'secondary' as const;
return 'default' as const;
};
const calendarStatusVariant = (status: string) => {
if (status === 'active') return 'default' as const;
if (status === 'frozen') return 'secondary' as const;
return 'destructive' as const;
};
interface EditFormValues {
title?: string;
description?: string;
type?: string;
status?: string;
category?: string;
reason?: string;
}
function calendarActions(
handlers: {
onOpen: () => void;
onEdit: () => void;
onDelete: () => void;
},
t: TFunction
): RowAction[] {
return [
{ label: t('common.open'), icon: <Info className="h-4 w-4" />, onClick: handlers.onOpen },
{ label: t('common.edit'), icon: <Pencil className="h-4 w-4" />, onClick: handlers.onEdit },
{
label: t('common.delete'),
icon: <Trash2 className="h-4 w-4" />,
onClick: handlers.onDelete,
destructive: true,
separatorBefore: true,
},
];
}
const CalendarListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { viewMode, setViewMode } = useExploreView();
const [preview, setPreview] = useState<ExplorePreviewTarget>(null);
const [params, setParams] = useState<CalendarListParams>({
limit: getSavedPageSize(TABLE_KEY),
offset: 0,
sort: 'created_at',
order: 'desc',
});
const [searchDraft, setSearchDraft] = useState('');
const { data, isLoading } = useCalendars(params);
const { data: stats } = useCalendarStats();
const updateCalendar = useUpdateCalendar();
const deleteCalendar = useDeleteCalendar();
const [editModal, setEditModal] = useState<{ open: boolean; calendarId: string | null }>({
open: false,
calendarId: null,
});
const { data: editingCalendar, isLoading: loadingCalendar } = useCalendar(editModal.calendarId || '');
const editForm = useForm<EditFormValues>();
const originalCalendarRef = useRef<CalendarType | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ open: boolean; calendarId: string | null }>({
open: false,
calendarId: null,
});
const handleEdit = (id: string) => setEditModal({ open: true, calendarId: id });
const handleSaveEdit = editForm.handleSubmit((values) => {
if (!editModal.calendarId || !originalCalendarRef.current) return;
const original = originalCalendarRef.current;
const cleanedValues: Record<string, unknown> = {};
// Only form fields — do not null-out entity keys absent from the form (e.g. id, owner_id).
Object.keys(values).forEach((key) => {
const newVal = (values as Record<string, unknown>)[key];
if (newVal === '' || newVal === undefined || newVal === null) {
const origVal = (original as unknown as Record<string, unknown>)[key];
if (origVal && origVal !== '-' && origVal !== '' && origVal !== undefined && origVal !== null) {
cleanedValues[key] = null;
}
return;
}
if (newVal === '-') return;
cleanedValues[key] = newVal;
});
const payload = Object.fromEntries(
Object.entries(cleanedValues).filter(([key, v]) => {
const origVal = (original as unknown as Record<string, unknown>)[key];
return JSON.stringify(v) !== JSON.stringify(origVal === '-' ? undefined : origVal);
})
);
updateCalendar.mutate(
{ id: editModal.calendarId, data: payload },
{ onSuccess: () => setEditModal({ open: false, calendarId: null }) }
);
});
useEffect(() => {
if (!editModal.open || loadingCalendar || !editingCalendar) return;
originalCalendarRef.current = editingCalendar;
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? '' : val);
editForm.reset({
title: clean(editingCalendar.title) as string,
description: clean(editingCalendar.description) as string,
type: clean(editingCalendar.type) as string,
status: clean(editingCalendar.status) as string,
category: clean(editingCalendar.category) as string,
reason: clean(editingCalendar.reason) as string,
});
}, [editingCalendar, editModal.open, loadingCalendar, editForm]);
const handleDelete = (id: string) => setDeleteConfirm({ open: true, calendarId: id });
const confirmDelete = () => {
if (!deleteConfirm.calendarId) return;
deleteCalendar.mutate(deleteConfirm.calendarId, {
onSuccess: () => setDeleteConfirm({ open: false, calendarId: null }),
});
};
const applySearch = () => {
setParams((prev) => ({ ...prev, q: searchDraft.trim() || undefined, offset: 0 }));
};
const exploreStats = useMemo(() => {
if (!stats) return [];
const items = [{ label: t('common.total'), value: stats.total_calendars }];
Object.entries(stats.calendars_by_status || {})
.slice(0, 3)
.forEach(([status, count]) => {
items.push({ label: formatStatusLabel(status), value: count });
});
return items.slice(0, 4);
}, [stats, t]);
const columns = useMemo<ColumnDef<CalendarType>[]>(
() => [
{
accessorKey: 'title',
header: t('common.title'),
enableSorting: true,
cell: ({ row, getValue }) => (
<button
type="button"
className="text-left font-medium text-primary hover:underline"
onClick={() => setPreview({ type: 'calendar', id: row.original.id })}
>
{getValue<string>() || row.original.id}
</button>
),
},
{
accessorKey: 'type',
header: t('common.type'),
size: 110,
enableSorting: true,
cell: ({ getValue }) => {
const type = getValue<string>();
return <Badge variant={calendarTypeBadgeVariant(type)}>{type}</Badge>;
},
},
{
accessorKey: 'status',
header: t('common.status'),
size: 100,
enableSorting: true,
cell: ({ getValue }) => {
const status = getValue<string>();
return <Badge variant={calendarStatusVariant(status)}>{formatStatusLabel(status)}</Badge>;
},
},
{
accessorKey: 'owner_id',
header: t('common.owner'),
enableSorting: false,
cell: ({ getValue }) => {
const ownerId = getValue<string>();
return <Link to={`/users/${ownerId}`}>{ownerId}</Link>;
},
},
{
id: 'rating',
header: t('common.rating'),
size: 90,
enableSorting: false,
cell: ({ row }) => `${row.original.rating_avg} (${row.original.rating_count})`,
},
{
id: 'actions',
header: '',
size: 48,
enableSorting: false,
cell: ({ row }) => {
const record = row.original;
return (
<RowActionsMenu
actions={calendarActions({
onOpen: () => navigate(`/calendars/${record.id}`),
onEdit: () => handleEdit(record.id),
onDelete: () => handleDelete(record.id),
}, t)}
/>
);
},
},
],
[navigate, t]
);
const editTitle = editForm.watch('title');
const editStatus = editForm.watch('status');
const editHasErrors = !editTitle || !editStatus;
const denseRows = useMemo<DenseRowModel[]>(() => {
return (data?.data ?? []).map((record) => ({
id: record.id,
title: record.title || record.id,
subtitle: record.owner_id ? t('explore.ownerId', { id: record.owner_id }) : undefined,
badges: (
<>
<Badge variant={calendarTypeBadgeVariant(record.type)}>{record.type}</Badge>
<Badge variant={calendarStatusVariant(record.status)}>{formatStatusLabel(record.status)}</Badge>
</>
),
meta: `${record.rating_avg} (${record.rating_count})`,
onActivate: () => setPreview({ type: 'calendar', id: record.id }),
actions: calendarActions({
onOpen: () => navigate(`/calendars/${record.id}`),
onEdit: () => handleEdit(record.id),
onDelete: () => handleDelete(record.id),
}, t),
}));
}, [data?.data, navigate, t]);
return (
<>
<ExploreListShell
data-testid="page-calendars"
title={t('calendars.title')}
stats={exploreStats}
viewMode={viewMode}
onViewModeChange={setViewMode}
toolbar={
<ExploreSearch
data-testid="filter-calendars-search"
value={searchDraft}
onChange={setSearchDraft}
onSubmit={applySearch}
placeholder={t('explore.searchCalendars')}
>
<Select
value={params.status ?? 'all'}
onValueChange={(v) =>
setParams((prev) => ({
...prev,
status: v === 'all' ? undefined : (v as CalendarListParams['status']),
offset: 0,
}))
}
>
<SelectTrigger className="w-36">
<SelectValue placeholder={t('common.status')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('common.allStatuses')}</SelectItem>
<SelectItem value="active">active</SelectItem>
<SelectItem value="frozen">frozen</SelectItem>
<SelectItem value="deleted">deleted</SelectItem>
</SelectContent>
</Select>
<Select
value={params.type ?? 'all'}
onValueChange={(v) =>
setParams((prev) => ({
...prev,
type: v === 'all' ? undefined : (v as CalendarListParams['type']),
offset: 0,
}))
}
>
<SelectTrigger className="w-36">
<SelectValue placeholder={t('common.type')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('common.allTypes')}</SelectItem>
<SelectItem value="personal">personal</SelectItem>
<SelectItem value="commercial">commercial</SelectItem>
</SelectContent>
</Select>
</ExploreSearch>
}
>
{stats && (
<ExploreInsightsCollapse
title={t('explore.tops')}
tabs={[
{
id: 'rating',
label: t('explore.byRating'),
rows: (stats.top_calendars_by_rating ?? []).map((row) => ({
id: row.id,
primary: <Link to={`/calendars/${row.id}`}>{row.title || row.id}</Link>,
value: row.rating_avg,
})),
},
{
id: 'reviews',
label: t('explore.byReviews'),
rows: (stats.top_calendars_by_reviews ?? []).map((row) => ({
id: row.id,
primary: <Link to={`/calendars/${row.id}`}>{row.title || row.id}</Link>,
value: row.rating_avg,
})),
},
{
id: 'positive',
label: t('explore.positive'),
rows: (stats.top_calendars_by_positive_reviews ?? []).map((row) => ({
id: row.id,
primary: <Link to={`/calendars/${row.id}`}>{row.title || row.id}</Link>,
value: row.rating_avg,
})),
},
{
id: 'negative',
label: t('explore.negative'),
rows: (stats.top_calendars_by_negative_reviews ?? []).map((row) => ({
id: row.id,
primary: <Link to={`/calendars/${row.id}`}>{row.title || row.id}</Link>,
value: row.rating_avg,
})),
},
]}
/>
)}
<ExploreDataView
viewMode={viewMode}
columns={columns}
data={data?.data ?? []}
denseRows={denseRows}
total={data?.total}
isLoading={isLoading}
tableKey={TABLE_KEY}
params={params}
onParamsChange={setParams}
/>
</ExploreListShell>
<Dialog open={editModal.open} onOpenChange={(open) => !open && setEditModal({ open: false, calendarId: null })}>
<DialogContent className="max-w-lg" data-testid="dialog-edit-calendar">
<DialogHeader>
<DialogTitle>{t('calendars.editTitle')}</DialogTitle>
</DialogHeader>
{loadingCalendar ? (
<div className="space-y-3 py-4">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
) : (
<form id="edit-calendar-form" className="space-y-4" onSubmit={handleSaveEdit}>
<div className="space-y-2">
<Label>{t('common.title')}</Label>
<Input {...editForm.register('title', { required: true })} />
</div>
<div className="space-y-2">
<Label>{t('common.description')}</Label>
<Textarea rows={3} {...editForm.register('description')} />
</div>
<div className="space-y-2">
<Label>{t('common.type')}</Label>
<Controller
name="type"
control={editForm.control}
render={({ field }) => (
<Select value={field.value || undefined} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectType')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="personal">personal</SelectItem>
<SelectItem value="commercial">commercial</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.status')}</Label>
<Controller
name="status"
control={editForm.control}
rules={{ required: true }}
render={({ field }) => (
<Select value={field.value || undefined} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectStatus')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">active</SelectItem>
<SelectItem value="frozen">frozen</SelectItem>
<SelectItem value="deleted">deleted</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.category')}</Label>
<Input {...editForm.register('category')} />
</div>
<div className="space-y-2">
<Label>{t('common.reason')}</Label>
<Input {...editForm.register('reason')} />
</div>
</form>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditModal({ open: false, calendarId: null })}>
{t('common.cancel')}
</Button>
<Button
type="submit"
form="edit-calendar-form"
data-testid="btn-save"
disabled={editHasErrors || updateCalendar.isPending}
>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, calendarId: null })}>
<DialogContent data-testid="dialog-delete-calendar">
<DialogHeader>
<DialogTitle>{t('calendars.deleteTitle')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">{t('common.irreversible')}</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, calendarId: null })}>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
data-testid="btn-delete-confirm"
onClick={confirmDelete}
disabled={deleteCalendar.isPending}
>
{t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<EntitySlideOver target={preview} onOpenChange={(open) => !open && setPreview(null)} />
</>
);
};
export default CalendarListPage;