All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m42s
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>
712 lines
27 KiB
TypeScript
712 lines
27 KiB
TypeScript
// Đặ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<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>Mô 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">Mô 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 có 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 có 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>
|
||
)
|
||
}
|