// Đặt phòng họp (Meeting Calendar) — Phase 10.2 G-O2 (S36 2026-05-28). // Custom HTML 7-day week grid (8h–20h, 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, 8h–20h. 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(() => startOfWeek(new Date())) const [roomId, setRoomId] = useState('') const [createOpen, setCreateOpen] = useState(false) const [createForm, setCreateForm] = useState>({}) const [createAttendees, setCreateAttendees] = useState([]) const [detailId, setDetailId] = useState(null) const rooms = useQuery({ queryKey: ['meeting-rooms', 'active'], queryFn: async () => (await api.get('/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('/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>('/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() 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 (
} accent="amberx" actions={ } /> {/* Filter bar */}
{formatDateRange(weekStart)}
{/* Calendar grid */}
{/* Header row: 7 days */}
{DAYS_OF_WEEK.map((d, i) => { const day = addDays(weekStart, i) const isToday = day.toDateString() === new Date().toDateString() return (
{d}
{day.getDate()}/{day.getMonth() + 1}
) })}
{/* Time slots column + day columns (relative parent for absolute booking blocks) */}
{/* Time labels column */}
{Array.from({ length: SLOT_COUNT }).map((_, i) => (
{String(HOUR_START + i).padStart(2, '0')}:00
))}
{/* 7 day columns */} {Array.from({ length: 7 }).map((_, dayIdx) => { const day = addDays(weekStart, dayIdx) const dayBookings = bookingsByDay.get(dayIdx) ?? [] return (
{/* Hour slot click targets */} {Array.from({ length: SLOT_COUNT }).map((_, hourIdx) => ( ) })}
) })}
{/* Legend */}
Xác nhận Hoàn tất Đã hủy Booking của tôi
{/* Create Dialog */} setCreateOpen(false)} title="Tạo Booking mới" size="lg" footer={ <> } >
setCreateForm(s => ({ ...s, title: e.target.value }))} placeholder="VD: Họp planning Q3 2026" required />
setCreateForm(s => ({ ...s, startAt: e.target.value }))} required />
setCreateForm(s => ({ ...s, endAt: e.target.value }))} required />