diff --git a/fe-admin/src/App.tsx b/fe-admin/src/App.tsx index ad9035c..8e32666 100644 --- a/fe-admin/src/App.tsx +++ b/fe-admin/src/App.tsx @@ -30,6 +30,8 @@ import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage' import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage' import { HrmConfigsPage } from '@/pages/hrm/HrmConfigsPage' import { InternalDirectoryPage } from '@/pages/office/InternalDirectoryPage' +import { MeetingCalendarPage } from '@/pages/office/MeetingCalendarPage' +import { MeetingRoomsPage } from '@/pages/office/MeetingRoomsPage' function App() { return ( @@ -80,6 +82,9 @@ function App() { } /> {/* Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1) */} } /> + {/* Văn phòng số — Phòng họp Booking + Catalog (Phase 10.2 G-O2 — Mig 36 S36) */} + } /> + } /> } /> } /> 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 ( +
+ + + Đặt phòng họp + + } + description={bookings.isLoading ? 'Đang tải…' : `${totalThisWeek} booking tuần này`} + 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 + /> +
+
+ +
+ +