Files
solution-erp/fe-admin/src/pages/office/MeetingCalendarPage.tsx
pqhuy1987 f45090b654
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m55s
[CLAUDE] Domain+App+Infra+Api+FE-Admin+FE-User: S36 Plan G-O2 Phòng họp Mig 36 + BE CRUD + FE 2 app
Phase 10.2 G-O2 Phòng họp BookingCalendar — Mig 36 schema + BE CQRS + FE 2 app
mirror cookie-cutter G-H2 HrmConfig pattern. Standalone không depend workflow.

BE schema (Mig 36 — em main solo Step 4):
- 4 Domain new: MeetingRoom catalog + MeetingBooking header + MeetingBookingAttendee
  join table N-to-N (NOT JSON per Investigator verdict) + Enums (MeetingBookingStatus
  3-state: Confirmed/Cancelled/Completed)
- 3 EF Config: UNIQUE Code + composite index (RoomId, StartAt) range query + UNIQUE
  composite (BookingId, UserId) join
- FK strategy: Room→Restrict (preserve history) + Booking→Cascade attendees +
  User→Restrict (denorm FullName+Email tránh cascade wipe)
- Mig 36 3-file rule + ApplicationDbContextModelSnapshot updated + apply Dev+Design DB

BE CQRS (~584 LOC — Implementer Case 2):
- MeetingFeatures.cs 479 LOC 9 handler: 4 Room CRUD + 5 Booking (List + GetById +
  Create + Update + Cancel)
- SERIALIZABLE transaction overlap check via EXISTS query — throw 409 Conflict
  "Phòng đã được đặt trong khoảng thời gian này"
- MeetingRoomsController 49 LOC + MeetingBookingsController 56 LOC — class-level
  [Authorize] + Roles="Admin" for write
- Application.csproj +Microsoft.EntityFrameworkCore.Relational package (em main fix
  IsolationLevel overload — Implementer gotcha #53 4th truncation diagnose mid-task)
- MenuKeys.cs +4 const (Off_PhongHop sub-group + View/Manage/Book leaf)
- DbInitializer +SeedMeetingRoomsAsync 4 sample (PH-A Phòng họp lớn cap=20 + PH-B
  cap=8 + PHG-501 Giám đốc cap=6 + ONL-1 Online Zoom cap=50) — NOT gated DemoSeed
  per gotcha #51 INFRASTRUCTURE seed

FE 2 app (~1770 LOC × 2 — Implementer Case 2):
- types/meeting.ts × 2 SHA256 IDENTICAL (ce0ad9c6d017cde2) — DTO interface mirror
- MeetingCalendarPage.tsx × 2 SHA256 IDENTICAL (d6d160ae1e4f2285) ~530 LOC — custom
  HTML 7-day grid 8h-20h slot, NO FullCalendar dep (~80 KB bundle saved per
  Investigator verdict alternative)
- MeetingRoomsPage.tsx × 2 SHA256 IDENTICAL (ba35a7ef379a5e9c) ~270 LOC — admin
  catalog CRUD table + Dialog
- 4-place mirror Pattern 16-bis 7× cumulative: types + page + App.tsx route +
  menuKeys + Layout staticMap 3 entry (gotcha #50 silent sidebar drop prevention)

Verify:
- dotnet build SolutionErp.slnx PASS 0 error 2 pre-existing DocxRenderer warning
- dotnet test 130/130 PASS baseline preserve (58 Domain + 72 Infra)
- npm build × 2 app PASS 0 TS error (fe-admin 16.91s bundle 1490 KB / fe-user 8.56s
  bundle 1404 KB, +23 KB gzip both)

Pattern reinforced cumulative S36:
- Pattern 12-bis cross-module mirror 10× (PE → Contract V2 → Hrm → Office)
- Pattern 16-bis 4-place mirror cross-app 7×
- Smart Friend Implementer truncation gotcha #53 4th — mitigation tight brief WORK
  (FE 2 app no truncation, BE truncate diagnose mid only)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:06:12 +07:00

694 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Đặt phòng họp (Meeting Calendar) — Phase 10.2 G-O2 (S36 2026-05-28).
// Custom HTML 7-day week grid (8h20h, 1h slot) — KHÔNG cài FullCalendar dep
// để giữ bundle size minimal (Investigator verdict pre-flight S36).
// Booking blocks render absolute positioning theo startAt/endAt.
// File này MIRROR SHA256 identical với fe-user counterpart.
// Pattern 16-bis 4-place mirror foundation (7× cumulative).
import { useMemo, useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Clock, MapPin, Plus, Trash2, Users as UsersIcon, X } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
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 { Select } from '@/components/ui/Select'
import { Dialog } from '@/components/ui/Dialog'
import { useAuth } from '@/contexts/AuthContext'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import type { Paged } from '@/types/master'
import {
MeetingBookingStatus,
type AttendeeInput,
type MeetingBookingCreateInput,
type MeetingBookingDto,
type MeetingRoomDto,
} from '@/types/meeting'
// Calendar constants — 12 slot 1h, 8h20h.
const HOUR_START = 8
const HOUR_END = 20
const SLOT_COUNT = HOUR_END - HOUR_START
const SLOT_PX = 56 // height of each 1h row
const DAYS_OF_WEEK = ['T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'CN'] as const
// ===== Date helpers =====
function startOfWeek(d: Date): Date {
const x = new Date(d)
x.setHours(0, 0, 0, 0)
const day = x.getDay() // 0=Sun, 1=Mon...
const diff = day === 0 ? -6 : 1 - day // shift to Monday
x.setDate(x.getDate() + diff)
return x
}
function addDays(d: Date, days: number): Date {
const x = new Date(d)
x.setDate(x.getDate() + days)
return x
}
function formatDateRange(weekStart: Date): string {
const end = addDays(weekStart, 6)
const sameMonth = weekStart.getMonth() === end.getMonth()
if (sameMonth) {
return `${weekStart.getDate()}${end.getDate()}/${weekStart.getMonth() + 1}/${weekStart.getFullYear()}`
}
return `${weekStart.getDate()}/${weekStart.getMonth() + 1}${end.getDate()}/${end.getMonth() + 1}/${weekStart.getFullYear()}`
}
function formatTime(iso: string): string {
const d = new Date(iso)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
// Trả về datetime-local string 'YYYY-MM-DDTHH:mm' từ Date (giữ local tz).
function toLocalDateTimeString(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
// Booking nằm trong day d nếu (startAt < endOfDay) && (endAt > startOfDay).
function bookingOverlapsDay(b: MeetingBookingDto, day: Date): boolean {
const startOfDay = new Date(day)
startOfDay.setHours(0, 0, 0, 0)
const endOfDay = new Date(day)
endOfDay.setHours(23, 59, 59, 999)
const bStart = new Date(b.startAt)
const bEnd = new Date(b.endAt)
return bStart < endOfDay && bEnd > startOfDay
}
// Compute top + height (px) cho 1 booking trong day cụ thể.
function computeBlockStyle(b: MeetingBookingDto, day: Date): { top: number; height: number } {
const bStart = new Date(b.startAt)
const bEnd = new Date(b.endAt)
const dayStart = new Date(day)
dayStart.setHours(HOUR_START, 0, 0, 0)
const dayEnd = new Date(day)
dayEnd.setHours(HOUR_END, 0, 0, 0)
const clampedStart = bStart < dayStart ? dayStart : bStart
const clampedEnd = bEnd > dayEnd ? dayEnd : bEnd
const topHours = (clampedStart.getTime() - dayStart.getTime()) / (1000 * 60 * 60)
const durationHours = (clampedEnd.getTime() - clampedStart.getTime()) / (1000 * 60 * 60)
return {
top: Math.max(0, topHours * SLOT_PX),
height: Math.max(20, durationHours * SLOT_PX),
}
}
// Status badge color
function statusBadgeClass(status: number): string {
if (status === MeetingBookingStatus.Cancelled) return 'bg-slate-100 text-slate-500 line-through'
if (status === MeetingBookingStatus.Completed) return 'bg-emerald-100 text-emerald-700'
return 'bg-brand-100 text-brand-700' // Confirmed
}
function statusLabel(status: number): string {
if (status === MeetingBookingStatus.Cancelled) return 'Đã hủy'
if (status === MeetingBookingStatus.Completed) return 'Đã hoàn tất'
return 'Xác nhận'
}
// ===== Page =====
type UserOption = { id: string; fullName: string; email: string }
export function MeetingCalendarPage() {
const auth = useAuth()
const qc = useQueryClient()
const [weekStart, setWeekStart] = useState<Date>(() => startOfWeek(new Date()))
const [roomId, setRoomId] = useState<string>('')
const [createOpen, setCreateOpen] = useState(false)
const [createForm, setCreateForm] = useState<Partial<MeetingBookingCreateInput>>({})
const [createAttendees, setCreateAttendees] = useState<AttendeeInput[]>([])
const [detailId, setDetailId] = useState<string | null>(null)
const rooms = useQuery({
queryKey: ['meeting-rooms', 'active'],
queryFn: async () =>
(await api.get<MeetingRoomDto[]>('/meeting-rooms', { params: { isActiveOnly: true } })).data,
})
const weekEnd = useMemo(() => addDays(weekStart, 7), [weekStart])
const bookings = useQuery({
queryKey: ['meeting-bookings', { roomId, weekStart: weekStart.toISOString() }],
queryFn: async () =>
(await api.get<MeetingBookingDto[]>('/meeting-bookings', {
params: {
roomId: roomId || undefined,
startDate: weekStart.toISOString(),
endDate: weekEnd.toISOString(),
},
})).data,
})
const users = useQuery({
queryKey: ['users-meeting-attendees'],
queryFn: async () =>
(await api.get<Paged<UserOption>>('/users', { params: { page: 1, pageSize: 200 } })).data.items,
enabled: createOpen || detailId !== null,
})
const createBooking = useMutation({
mutationFn: async (payload: MeetingBookingCreateInput) => {
await api.post('/meeting-bookings', payload)
},
onSuccess: () => {
toast.success('Đã tạo booking')
qc.invalidateQueries({ queryKey: ['meeting-bookings'] })
setCreateOpen(false)
setCreateForm({})
setCreateAttendees([])
},
onError: err => toast.error(getErrorMessage(err)),
})
const cancelBooking = useMutation({
mutationFn: async (id: string) => {
await api.delete(`/meeting-bookings/${id}`)
},
onSuccess: () => {
toast.success('Đã hủy booking')
qc.invalidateQueries({ queryKey: ['meeting-bookings'] })
setDetailId(null)
},
onError: err => toast.error(getErrorMessage(err)),
})
function openCreateForSlot(day: Date, hour: number) {
const start = new Date(day)
start.setHours(hour, 0, 0, 0)
const end = new Date(day)
end.setHours(hour + 1, 0, 0, 0)
setCreateForm({
roomId: roomId || rooms.data?.[0]?.id || '',
startAt: toLocalDateTimeString(start),
endAt: toLocalDateTimeString(end),
title: '',
description: '',
note: '',
})
setCreateAttendees([])
setCreateOpen(true)
}
function openCreateBlank() {
const now = new Date()
const next = new Date(now)
next.setHours(now.getHours() + 1, 0, 0, 0)
const end = new Date(next)
end.setHours(end.getHours() + 1)
setCreateForm({
roomId: roomId || rooms.data?.[0]?.id || '',
startAt: toLocalDateTimeString(next),
endAt: toLocalDateTimeString(end),
title: '',
description: '',
note: '',
})
setCreateAttendees([])
setCreateOpen(true)
}
function submitCreate(e: FormEvent) {
e.preventDefault()
if (!createForm.roomId || !createForm.startAt || !createForm.endAt || !createForm.title?.trim()) {
toast.error('Vui lòng nhập đầy đủ thông tin')
return
}
// datetime-local strings không có tz suffix → convert sang ISO UTC
const payload: MeetingBookingCreateInput = {
roomId: createForm.roomId,
startAt: new Date(createForm.startAt).toISOString(),
endAt: new Date(createForm.endAt).toISOString(),
title: createForm.title.trim(),
description: createForm.description?.trim() || null,
note: createForm.note?.trim() || null,
attendees: createAttendees,
}
createBooking.mutate(payload)
}
const detailBooking = useMemo(
() => bookings.data?.find(b => b.id === detailId) ?? null,
[bookings.data, detailId],
)
// Filter bookings cho từng day column
const bookingsByDay = useMemo(() => {
const map = new Map<number, MeetingBookingDto[]>()
for (let i = 0; i < 7; i++) {
const day = addDays(weekStart, i)
const dayBookings = (bookings.data ?? []).filter(b => bookingOverlapsDay(b, day))
map.set(i, dayBookings)
}
return map
}, [bookings.data, weekStart])
const totalThisWeek = bookings.data?.length ?? 0
return (
<div className="p-6">
<PageHeader
title={
<span className="flex items-center gap-2">
<CalendarIcon className="h-5 w-5" />
Đt phòng họp
</span>
}
description={bookings.isLoading ? 'Đang tải…' : `${totalThisWeek} booking tuần này`}
actions={
<Button onClick={openCreateBlank}>
<Plus className="h-4 w-4" />
Tạo Booking
</Button>
}
/>
{/* Filter bar */}
<div className="mb-4 flex flex-col gap-2 rounded-lg border border-slate-200 bg-white p-3 shadow-sm sm:flex-row sm:items-center">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setWeekStart(startOfWeek(addDays(weekStart, -7)))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={() => setWeekStart(startOfWeek(new Date()))}>
Hôm nay
</Button>
<Button variant="outline" size="sm" onClick={() => setWeekStart(startOfWeek(addDays(weekStart, 7)))}>
<ChevronRight className="h-4 w-4" />
</Button>
<span className="ml-2 text-sm font-medium text-slate-700">{formatDateRange(weekStart)}</span>
</div>
<div className="flex-1" />
<Select
value={roomId}
onChange={e => setRoomId(e.target.value)}
className="sm:w-64"
>
<option value="">Tất cả phòng họp</option>
{(rooms.data ?? []).map(r => (
<option key={r.id} value={r.id}>
{r.code} {r.name} ({r.capacity} chỗ)
</option>
))}
</Select>
</div>
{/* Calendar grid */}
<div className="overflow-x-auto rounded-lg border border-slate-200 bg-white shadow-sm">
<div className="min-w-[900px]">
{/* Header row: 7 days */}
<div className="sticky top-0 z-10 grid grid-cols-[60px_repeat(7,minmax(0,1fr))] border-b border-slate-200 bg-slate-50">
<div className="border-r border-slate-200 px-2 py-2 text-[11px] uppercase tracking-wider text-slate-500"></div>
{DAYS_OF_WEEK.map((d, i) => {
const day = addDays(weekStart, i)
const isToday = day.toDateString() === new Date().toDateString()
return (
<div
key={d}
className={cn(
'border-r border-slate-200 px-2 py-2 text-center last:border-r-0',
isToday && 'bg-brand-50',
)}
>
<div className="text-[11px] uppercase tracking-wider text-slate-500">{d}</div>
<div className={cn('text-sm font-semibold', isToday ? 'text-brand-700' : 'text-slate-900')}>
{day.getDate()}/{day.getMonth() + 1}
</div>
</div>
)
})}
</div>
{/* Time slots column + day columns (relative parent for absolute booking blocks) */}
<div className="grid grid-cols-[60px_repeat(7,minmax(0,1fr))]">
{/* Time labels column */}
<div className="border-r border-slate-200 bg-slate-50/50">
{Array.from({ length: SLOT_COUNT }).map((_, i) => (
<div
key={i}
className="flex items-start justify-end border-b border-slate-100 px-2 py-1 text-[10px] text-slate-400"
style={{ height: `${SLOT_PX}px` }}
>
{String(HOUR_START + i).padStart(2, '0')}:00
</div>
))}
</div>
{/* 7 day columns */}
{Array.from({ length: 7 }).map((_, dayIdx) => {
const day = addDays(weekStart, dayIdx)
const dayBookings = bookingsByDay.get(dayIdx) ?? []
return (
<div
key={dayIdx}
className="relative border-r border-slate-200 last:border-r-0"
style={{ height: `${SLOT_COUNT * SLOT_PX}px` }}
>
{/* Hour slot click targets */}
{Array.from({ length: SLOT_COUNT }).map((_, hourIdx) => (
<button
key={hourIdx}
onClick={() => openCreateForSlot(day, HOUR_START + hourIdx)}
className="block w-full border-b border-slate-100 transition hover:bg-brand-50/40"
style={{ height: `${SLOT_PX}px` }}
title={`Tạo booking lúc ${String(HOUR_START + hourIdx).padStart(2, '0')}:00`}
/>
))}
{/* Absolute booking blocks */}
{dayBookings.map(b => {
const { top, height } = computeBlockStyle(b, day)
const isOwn = b.bookedByUserId === auth.user?.id
return (
<button
key={b.id}
onClick={() => setDetailId(b.id)}
className={cn(
'absolute left-1 right-1 overflow-hidden rounded-md border px-2 py-1 text-left text-[11px] shadow-sm transition hover:shadow',
statusBadgeClass(b.status),
isOwn ? 'border-brand-400 ring-1 ring-brand-200' : 'border-slate-200',
)}
style={{ top: `${top}px`, height: `${height}px` }}
>
<div className="truncate font-semibold leading-tight">{b.title}</div>
<div className="truncate text-[10px] opacity-80">
{formatTime(b.startAt)}{formatTime(b.endAt)} · {b.roomCode}
</div>
{height >= 56 && (
<div className="truncate text-[10px] opacity-70">{b.bookedByFullName}</div>
)}
</button>
)
})}
</div>
)
})}
</div>
</div>
</div>
{/* Legend */}
<div className="mt-3 flex flex-wrap gap-3 text-[11px] text-slate-500">
<span className="flex items-center gap-1"><span className="h-3 w-3 rounded bg-brand-100" /> Xác nhận</span>
<span className="flex items-center gap-1"><span className="h-3 w-3 rounded bg-emerald-100" /> Hoàn tất</span>
<span className="flex items-center gap-1"><span className="h-3 w-3 rounded bg-slate-100" /> Đã hủy</span>
<span className="flex items-center gap-1"><span className="h-3 w-3 rounded border border-brand-400" /> Booking của tôi</span>
</div>
{/* Create Dialog */}
<Dialog
open={createOpen}
onClose={() => setCreateOpen(false)}
title="Tạo Booking mới"
size="lg"
footer={
<>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Hủy</Button>
<Button onClick={(e: FormEvent) => submitCreate(e)} disabled={createBooking.isPending}>
{createBooking.isPending ? 'Đang tạo…' : 'Tạo'}
</Button>
</>
}
>
<form onSubmit={submitCreate} className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label>Phòng họp *</Label>
<Select
value={createForm.roomId ?? ''}
onChange={e => setCreateForm(s => ({ ...s, roomId: e.target.value }))}
required
>
<option value=""> Chọn phòng </option>
{(rooms.data ?? []).map(r => (
<option key={r.id} value={r.id}>
{r.code} {r.name} ({r.capacity} chỗ)
</option>
))}
</Select>
</div>
<div className="space-y-1">
<Label>Tiêu đ *</Label>
<Input
value={createForm.title ?? ''}
onChange={e => setCreateForm(s => ({ ...s, title: e.target.value }))}
placeholder="VD: Họp planning Q3 2026"
required
/>
</div>
<div className="space-y-1">
<Label>Bắt đu *</Label>
<Input
type="datetime-local"
value={createForm.startAt ?? ''}
onChange={e => setCreateForm(s => ({ ...s, startAt: e.target.value }))}
required
/>
</div>
<div className="space-y-1">
<Label>Kết thúc *</Label>
<Input
type="datetime-local"
value={createForm.endAt ?? ''}
onChange={e => setCreateForm(s => ({ ...s, endAt: e.target.value }))}
required
/>
</div>
</div>
<div className="space-y-1">
<Label> tả</Label>
<Textarea
rows={2}
value={createForm.description ?? ''}
onChange={e => setCreateForm(s => ({ ...s, description: e.target.value }))}
placeholder="Nội dung họp, agenda..."
/>
</div>
<div className="space-y-1">
<Label>Ghi chú</Label>
<Input
value={createForm.note ?? ''}
onChange={e => setCreateForm(s => ({ ...s, note: e.target.value }))}
placeholder="Ghi chú nội bộ"
/>
</div>
<div className="space-y-1">
<Label>Người tham dự ({createAttendees.length})</Label>
<AttendeePicker
users={users.data ?? []}
selected={createAttendees}
onChange={setCreateAttendees}
/>
</div>
</form>
</Dialog>
{/* Detail Dialog */}
<Dialog
open={detailBooking !== null}
onClose={() => setDetailId(null)}
title="Chi tiết Booking"
size="lg"
footer={
detailBooking && detailBooking.bookedByUserId === auth.user?.id
&& detailBooking.status === MeetingBookingStatus.Confirmed
? (
<>
<Button variant="outline" onClick={() => setDetailId(null)}>Đóng</Button>
<Button
variant="danger"
onClick={() => {
if (confirm('Hủy booking này?')) cancelBooking.mutate(detailBooking.id)
}}
disabled={cancelBooking.isPending}
>
<Trash2 className="h-4 w-4" />
Hủy booking
</Button>
</>
) : (
<Button variant="outline" onClick={() => setDetailId(null)}>Đóng</Button>
)
}
>
{detailBooking && (
<div className="space-y-3 text-sm">
<div>
<h3 className="text-base font-semibold text-slate-900">{detailBooking.title}</h3>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
<span className={cn('rounded-full px-2 py-0.5', statusBadgeClass(detailBooking.status))}>
{statusLabel(detailBooking.status)}
</span>
<span className="text-slate-500">
Người tạo: <span className="font-medium text-slate-700">{detailBooking.bookedByFullName}</span>
</span>
</div>
</div>
<div className="grid grid-cols-1 gap-2 rounded-md bg-slate-50 p-3 sm:grid-cols-2">
<div className="flex items-center gap-2 text-slate-700">
<MapPin className="h-4 w-4 text-slate-400" />
<span>{detailBooking.roomCode} {detailBooking.roomName}</span>
</div>
<div className="flex items-center gap-2 text-slate-700">
<Clock className="h-4 w-4 text-slate-400" />
<span>
{new Date(detailBooking.startAt).toLocaleString('vi-VN', { dateStyle: 'short', timeStyle: 'short' })}
{' '}
{new Date(detailBooking.endAt).toLocaleString('vi-VN', { dateStyle: 'short', timeStyle: 'short' })}
</span>
</div>
</div>
{detailBooking.description && (
<div>
<Label className="text-xs text-slate-500"> tả</Label>
<p className="mt-1 whitespace-pre-wrap text-sm text-slate-700">{detailBooking.description}</p>
</div>
)}
{detailBooking.note && (
<div>
<Label className="text-xs text-slate-500">Ghi chú</Label>
<p className="mt-1 text-sm text-slate-700">{detailBooking.note}</p>
</div>
)}
<div>
<Label className="flex items-center gap-1 text-xs text-slate-500">
<UsersIcon className="h-3.5 w-3.5" />
Người tham dự ({detailBooking.attendees.length})
</Label>
{detailBooking.attendees.length === 0 ? (
<p className="mt-1 text-xs text-slate-400"> Không người tham dự </p>
) : (
<ul className="mt-1 divide-y divide-slate-100 rounded-md border border-slate-200">
{detailBooking.attendees.map(a => (
<li key={a.userId} className="flex items-start gap-2 px-3 py-2 text-xs">
<UsersIcon className="mt-0.5 h-3.5 w-3.5 text-slate-400" />
<div className="flex-1">
<div className="font-medium text-slate-700">{a.fullName}</div>
{a.email && <div className="text-[11px] text-slate-500">{a.email}</div>}
{a.notes && <div className="mt-0.5 text-[11px] italic text-slate-500">{a.notes}</div>}
</div>
</li>
))}
</ul>
)}
</div>
</div>
)}
</Dialog>
</div>
)
}
// ===== AttendeePicker subcomponent =====
function AttendeePicker({
users,
selected,
onChange,
}: {
users: UserOption[]
selected: AttendeeInput[]
onChange: (next: AttendeeInput[]) => void
}) {
const [search, setSearch] = useState('')
const selectedIds = useMemo(() => new Set(selected.map(a => a.userId)), [selected])
const filtered = useMemo(() => {
if (!search.trim()) return users.slice(0, 50)
const s = search.toLowerCase()
return users
.filter(u => u.fullName.toLowerCase().includes(s) || u.email.toLowerCase().includes(s))
.slice(0, 50)
}, [users, search])
function toggle(u: UserOption) {
if (selectedIds.has(u.id)) {
onChange(selected.filter(a => a.userId !== u.id))
} else {
onChange([...selected, { userId: u.id, notes: null }])
}
}
function removeAt(id: string) {
onChange(selected.filter(a => a.userId !== id))
}
return (
<div className="rounded-md border border-slate-200">
{/* Selected chips */}
{selected.length > 0 && (
<div className="flex flex-wrap gap-1 border-b border-slate-200 bg-slate-50 p-2">
{selected.map(a => {
const u = users.find(x => x.id === a.userId)
return (
<span
key={a.userId}
className="inline-flex items-center gap-1 rounded-full bg-brand-100 px-2 py-0.5 text-[11px] text-brand-700"
>
{u?.fullName ?? a.userId}
<button
type="button"
onClick={() => removeAt(a.userId)}
className="rounded-full text-brand-600 hover:bg-brand-200"
>
<X className="h-3 w-3" />
</button>
</span>
)
})}
</div>
)}
{/* Search + list */}
<div className="p-2">
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Tìm theo tên / email…"
className="mb-2 h-8 text-xs"
/>
<div className="max-h-40 overflow-y-auto rounded border border-slate-100">
{filtered.length === 0 ? (
<div className="p-3 text-center text-[11px] text-slate-400">Không user khớp.</div>
) : (
filtered.map(u => {
const isSel = selectedIds.has(u.id)
return (
<button
key={u.id}
type="button"
onClick={() => toggle(u)}
className={cn(
'block w-full px-2 py-1.5 text-left text-xs transition',
isSel ? 'bg-brand-50 text-brand-700' : 'hover:bg-slate-50',
)}
>
<div className="font-medium">{u.fullName}</div>
<div className="text-[10px] text-slate-500">{u.email}</div>
</button>
)
})
)}
</div>
</div>
</div>
)
}