Files
solution-erp/fe-admin/src/pages/office/MeetingCalendarPage.tsx
pqhuy1987 c556f6cfa2
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m42s
[CLAUDE] FE: Văn phòng số re-skin toàn module — 10 page PURO layout + CSS Hồ sơ NS (PageHeader/KpiCard/WidgetCard), phẫu thuật giữ nguyên logic
Re-skin TRỌN module Office sang bố cục PURO (NamGroup) + ngôn ngữ thị giác Hồ sơ Nhân sự, tái dùng 3 shared component foundation. Phẫu thuật trình bày — logic byte-identical (reviewer verify mọi api.get/post/put/delete + queryKey zero-delta HEAD vs working tree, cả 2 app build PASS).

10 page (9 fe-user → mirror fe-admin SHA256-identical + AttendanceReport fe-admin-only):
- Danh bạ nội bộ — PageHeader + KpiCard tổng hợp (NV/phòng ban) + card icon-chip.
- Phòng họp (lịch + quản lý phòng) — PageHeader amberx + calendar/table trong card-accent.
- Đề xuất (List/Create/Detail) — List: status filter → KpiCard row (6 trạng thái + inbox "Cần tôi duyệt"); Create/Detail: card-accent section + Field idiom.
- Đơn từ/Đặt xe (List/Detail, :kind leave/ot/travel/vehicle) — PageHeader teal + KpiCard status filter (client-side view over fetched) + card-accent detail.
- Ticket CNTT — PageHeader violet + KpiCard 5-status filter + Quá hạn SLA + kanban card-accent.
- Báo cáo chấm công (fe-admin only) — PageHeader + KpiCard tổng hợp + bảng card-accent + Excel-export giữ nguyên.

- Accent chỉ dùng stop hợp lệ (teal/violet/amberx/greenx 50/100/500/600/700; brand 50-900); gotcha #66 clean. a11y giữ/nâng (focus-visible, KpiCard role/aria-pressed/keyboard).
- Build PASS x2 (fe-user index-C8-p69Kn / fe-admin index-yFhLO2Wp). reviewer PASS 0 blocker; 2 concern cosmetic (badge dup ProposalDetail header+row; KpiCard filter lọc trên trang đầu đã fetch — giới hạn pagination có sẵn).
- Office VẪN ẨN non-Admin (chưa golive). 9 page fe-user↔fe-admin SHA256-identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 09:57:46 +07:00

712 lines
27 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, CalendarDays, Clock, MapPin, Plus, Trash2, Users as UsersIcon, X } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/ui/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
eyebrow="Văn phòng số"
title="Lịch phòng họp"
subtitle={bookings.isLoading ? 'Đang tải…' : `${totalThisWeek} booking tuần này`}
icon={<CalendarDays className="h-5 w-5" />}
accent="amberx"
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="card-accent overflow-x-auto"
style={{ ['--accent' as string]: 'var(--color-amberx-500)' }}
>
<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 className="flex items-start gap-3">
<span
className="icon-chip mt-0.5 shrink-0"
style={{ ['--chip-bg' as string]: 'var(--color-amberx-50)', ['--chip-fg' as string]: 'var(--color-amberx-700)' }}
aria-hidden
>
<CalendarDays className="h-4 w-4" />
</span>
<div className="min-w-0">
<h3 className="text-base font-semibold tracking-tight text-brand-800">{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-brand-800">{detailBooking.bookedByFullName}</span>
</span>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-3 rounded-xl border border-slate-200 bg-slate-50/70 p-3.5 sm:grid-cols-2">
<div className="min-w-0">
<div className="label-eyebrow flex items-center gap-1">
<MapPin className="h-3 w-3" />
Phòng họp
</div>
<div className="mt-0.5 break-words font-medium text-brand-800">
{detailBooking.roomCode} {detailBooking.roomName}
</div>
</div>
<div className="min-w-0">
<div className="label-eyebrow flex items-center gap-1">
<Clock className="h-3 w-3" />
Thời gian
</div>
<div className="mt-0.5 break-words font-medium text-brand-800">
{new Date(detailBooking.startAt).toLocaleString('vi-VN', { dateStyle: 'short', timeStyle: 'short' })}
{' '}
{new Date(detailBooking.endAt).toLocaleString('vi-VN', { dateStyle: 'short', timeStyle: 'short' })}
</div>
</div>
</div>
{detailBooking.description && (
<div>
<div className="label-eyebrow"> tả</div>
<p className="mt-1 whitespace-pre-wrap text-sm text-slate-700">{detailBooking.description}</p>
</div>
)}
{detailBooking.note && (
<div>
<div className="label-eyebrow">Ghi chú</div>
<p className="mt-1 text-sm text-slate-700">{detailBooking.note}</p>
</div>
)}
<div>
<div className="label-eyebrow flex items-center gap-1">
<UsersIcon className="h-3 w-3" />
Người tham dự ({detailBooking.attendees.length})
</div>
{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>
)
}