Files
hotelsync/src/pages/SchedulePage.tsx
HotelSync 8ede920e5d feat: monthly view for staff schedule + week/month toggle
Add month view to SchedulePage — toggle between Неделя/Месяц, navigate
by month, compact 31-column grid with sticky employee names, weekend
highlighting, shift start time or В badge in each cell.
2026-03-26 01:28:32 +03:00

612 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.
import { useState, useEffect, useCallback } from 'react'
import { ChevronLeft, ChevronRight, Calendar, Users, X, Check, Loader2 } from 'lucide-react'
import { api, type ScheduleEntry } from '../lib/api'
import { useAuth } from '../contexts/AuthContext'
import type { User } from '../types'
import { cn } from '../lib/utils'
import {
format, startOfWeek, addDays, addWeeks, subWeeks, isToday, parseISO,
startOfMonth, endOfMonth, eachDayOfInterval, addMonths, subMonths, getDay,
} from 'date-fns'
import { ru } from 'date-fns/locale'
const ROLE_COLORS: Record<string, string> = {
hotel_admin: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
manager: 'bg-brand-100 text-brand-700 dark:bg-brand-900/30 dark:text-brand-300',
receptionist: 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300',
housekeeper: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
accountant: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300',
security: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
technician: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
}
const ROLE_LABELS: Record<string, string> = {
hotel_admin: 'Сис. администратор',
manager: 'Менеджер',
receptionist: 'Ресепшн',
housekeeper: 'Горничная',
accountant: 'Бухгалтер',
security: 'Охрана',
technician: 'Тех. специалист',
}
const SHIFT_PRESETS = [
{ label: 'Дневная', start: '09:00', end: '21:00' },
{ label: 'Ночная', start: '21:00', end: '09:00' },
{ label: 'Утро', start: '07:00', end: '15:00' },
{ label: 'Вечер', start: '15:00', end: '23:00' },
{ label: '8 часов',start: '09:00', end: '17:00' },
]
function getWeekDays(weekStart: Date): Date[] {
return Array.from({ length: 7 }, (_, i) => addDays(weekStart, i))
}
function fmtDate(d: Date) {
return format(d, 'yyyy-MM-dd')
}
function fmtTime(t: string | null | undefined) {
if (!t) return ''
return t.slice(0, 5)
}
/** Returns true if the date is Saturday (6) or Sunday (0) */
function isWeekend(d: Date): boolean {
const dow = getDay(d)
return dow === 0 || dow === 6
}
interface CellEditorProps {
userId: string
date: string
entry: ScheduleEntry | undefined
onClose: () => void
onSave: (data: { shift_start?: string; shift_end?: string; is_day_off: boolean; notes?: string }) => Promise<void>
onDelete: () => Promise<void>
}
function CellEditor({ date, entry, onClose, onSave, onDelete }: CellEditorProps) {
const [isDayOff, setIsDayOff] = useState(entry?.isDayOff ?? false)
const [shiftStart, setShiftStart] = useState(fmtTime(entry?.shiftStart) || '09:00')
const [shiftEnd, setShiftEnd] = useState(fmtTime(entry?.shiftEnd) || '18:00')
const [notes, setNotes] = useState(entry?.notes ?? '')
const [saving, setSaving] = useState(false)
const applyPreset = (p: typeof SHIFT_PRESETS[0]) => {
setShiftStart(p.start)
setShiftEnd(p.end)
setIsDayOff(false)
}
const handleSave = async () => {
setSaving(true)
await onSave({
shift_start: isDayOff ? undefined : shiftStart || undefined,
shift_end: isDayOff ? undefined : shiftEnd || undefined,
is_day_off: isDayOff,
notes: notes || undefined,
})
setSaving(false)
}
const handleDelete = async () => {
setSaving(true)
await onDelete()
setSaving(false)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={onClose}>
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-xl p-5 w-full max-w-sm" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-slate-900 dark:text-slate-100 text-sm">
{format(parseISO(date), 'd MMMM', { locale: ru })}
</h3>
<button onClick={onClose} className="btn-ghost p-1"><X size={16} /></button>
</div>
{/* Day off toggle */}
<div className="flex items-center justify-between p-3 rounded-xl bg-slate-50 dark:bg-slate-700/50 mb-3">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Выходной день</span>
<button
onClick={() => setIsDayOff(v => !v)}
className={cn('relative w-10 h-5 rounded-full transition-colors', isDayOff ? 'bg-red-500' : 'bg-slate-300 dark:bg-slate-600')}
>
<div className={cn('absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform', isDayOff ? 'left-[22px]' : 'left-0.5')} />
</button>
</div>
{!isDayOff && (
<>
{/* Presets */}
<div className="flex flex-wrap gap-1.5 mb-3">
{SHIFT_PRESETS.map(p => (
<button key={p.label} onClick={() => applyPreset(p)}
className="text-xs px-2 py-1 rounded-lg bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400 hover:bg-brand-50 hover:text-brand-700 dark:hover:bg-brand-900/30 dark:hover:text-brand-300 transition-colors">
{p.label}
</button>
))}
</div>
{/* Time inputs */}
<div className="grid grid-cols-2 gap-3 mb-3">
<div>
<label className="text-xs text-slate-500 dark:text-slate-400 mb-1 block">Начало</label>
<input type="time" value={shiftStart} onChange={e => setShiftStart(e.target.value)}
className="input text-sm w-full" />
</div>
<div>
<label className="text-xs text-slate-500 dark:text-slate-400 mb-1 block">Конец</label>
<input type="time" value={shiftEnd} onChange={e => setShiftEnd(e.target.value)}
className="input text-sm w-full" />
</div>
</div>
</>
)}
{/* Notes */}
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="Заметка (необязательно)"
rows={2}
className="input text-sm w-full resize-none mb-4"
/>
<div className="flex gap-2">
{entry && (
<button onClick={handleDelete} disabled={saving}
className="btn-secondary text-sm text-red-600 dark:text-red-400 border-red-200 dark:border-red-800 hover:bg-red-50 dark:hover:bg-red-900/20">
Удалить
</button>
)}
<button onClick={handleSave} disabled={saving}
className="btn-primary flex-1 justify-center text-sm gap-2">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
Сохранить
</button>
</div>
</div>
</div>
)
}
export function SchedulePage() {
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const isManager = ['hotel_admin', 'manager', 'super_admin'].includes(user?.role ?? '')
const [viewMode, setViewMode] = useState<'week' | 'month'>('week')
const [weekStart, setWeekStart] = useState(() =>
startOfWeek(new Date(), { weekStartsOn: 1 })
)
const [monthStart, setMonthStart] = useState(() =>
startOfMonth(new Date())
)
const [users, setUsers] = useState<User[]>([])
const [schedule, setSchedule] = useState<ScheduleEntry[]>([])
const [loading, setLoading] = useState(true)
const [roleFilter, setRoleFilter] = useState<string>('all')
const [editing, setEditing] = useState<{ userId: string; date: string } | null>(null)
// Compute date range depending on view mode
const weekDays = getWeekDays(weekStart)
const monthDays = eachDayOfInterval({ start: monthStart, end: endOfMonth(monthStart) })
const from = viewMode === 'week' ? fmtDate(weekDays[0]) : fmtDate(monthStart)
const to = viewMode === 'week' ? fmtDate(weekDays[6]) : fmtDate(endOfMonth(monthStart))
const loadData = useCallback(async () => {
if (!slug) return
setLoading(true)
try {
const [u, s] = await Promise.all([
api.users.list(slug),
api.schedule.list(slug, from, to),
])
setUsers(u)
setSchedule(s)
} catch (e) {
console.error(e)
} finally {
setLoading(false)
}
}, [slug, from, to])
useEffect(() => { loadData() }, [loadData])
const getEntry = (userId: string, date: string) =>
schedule.find(s => s.userId === userId && s.date === date)
const handleSave = async (userId: string, date: string, data: {
shift_start?: string; shift_end?: string; is_day_off: boolean; notes?: string
}) => {
try {
const saved = await api.schedule.upsert(slug, { user_id: userId, date, ...data })
setSchedule(prev => {
const filtered = prev.filter(s => !(s.userId === userId && s.date === date))
return [...filtered, saved]
})
setEditing(null)
} catch (e) {
console.error(e)
}
}
const handleDelete = async (userId: string, date: string) => {
try {
await api.schedule.remove(slug, userId, date)
setSchedule(prev => prev.filter(s => !(s.userId === userId && s.date === date)))
setEditing(null)
} catch (e) {
console.error(e)
}
}
const filteredUsers = users.filter(u =>
roleFilter === 'all' || u.role === roleFilter
)
// Count scheduled shifts per user in current range
const getShiftCount = (userId: string) =>
schedule.filter(s => s.userId === userId && !s.isDayOff).length
const allRoles = [...new Set(users.map(u => u.role))].filter(Boolean).sort()
// Navigation handlers
const goTodayWeek = () => setWeekStart(startOfWeek(new Date(), { weekStartsOn: 1 }))
const goTodayMonth = () => setMonthStart(startOfMonth(new Date()))
return (
<div className="p-4 md:p-6 space-y-5">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">График работы</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">{users.length} сотрудников</p>
</div>
<div className="flex items-center gap-2 flex-wrap">
{/* View mode toggle */}
<div className="flex rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden text-sm">
<button
onClick={() => setViewMode('week')}
className={cn(
'px-3 py-1.5 transition-colors',
viewMode === 'week'
? 'bg-brand-600 text-white'
: 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700'
)}
>
Неделя
</button>
<button
onClick={() => setViewMode('month')}
className={cn(
'px-3 py-1.5 border-l border-slate-200 dark:border-slate-700 transition-colors',
viewMode === 'month'
? 'bg-brand-600 text-white'
: 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700'
)}
>
Месяц
</button>
</div>
{/* Navigation */}
{viewMode === 'week' ? (
<>
<button onClick={() => setWeekStart(w => subWeeks(w, 1))} className="btn-secondary p-2">
<ChevronLeft size={16} />
</button>
<div className="text-sm font-medium text-slate-700 dark:text-slate-300 min-w-[160px] text-center">
{format(weekDays[0], 'd MMM', { locale: ru })} {format(weekDays[6], 'd MMM yyyy', { locale: ru })}
</div>
<button onClick={() => setWeekStart(w => addWeeks(w, 1))} className="btn-secondary p-2">
<ChevronRight size={16} />
</button>
<button onClick={goTodayWeek} className="btn-secondary text-sm">
Сегодня
</button>
</>
) : (
<>
<button onClick={() => setMonthStart(m => subMonths(m, 1))} className="btn-secondary p-2">
<ChevronLeft size={16} />
</button>
<div className="text-sm font-medium text-slate-700 dark:text-slate-300 min-w-[140px] text-center capitalize">
{format(monthStart, 'LLLL yyyy', { locale: ru })}
</div>
<button onClick={() => setMonthStart(m => addMonths(m, 1))} className="btn-secondary p-2">
<ChevronRight size={16} />
</button>
<button onClick={goTodayMonth} className="btn-secondary text-sm">
Сегодня
</button>
</>
)}
</div>
</div>
{/* Filters */}
<div className="flex items-center gap-2 flex-wrap">
<Users size={15} className="text-slate-400" />
<button
onClick={() => setRoleFilter('all')}
className={cn('text-xs px-3 py-1.5 rounded-lg border transition-colors',
roleFilter === 'all'
? 'bg-brand-600 text-white border-brand-600'
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:border-brand-300'
)}
>
Все роли
</button>
{allRoles.map(role => (
<button key={role}
onClick={() => setRoleFilter(role)}
className={cn('text-xs px-3 py-1.5 rounded-lg border transition-colors',
roleFilter === role
? 'bg-brand-600 text-white border-brand-600'
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:border-brand-300'
)}
>
{ROLE_LABELS[role] ?? role}
</button>
))}
</div>
{/* Schedule grid */}
{loading ? (
<div className="flex items-center justify-center h-48">
<Loader2 size={24} className="animate-spin text-brand-600" />
</div>
) : filteredUsers.length === 0 ? (
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
{users.length === 0
? 'Нет сотрудников. Добавьте их на странице «Сотрудники».'
: 'Нет сотрудников с выбранной ролью.'}
</div>
) : viewMode === 'week' ? (
/* ── WEEK VIEW ── */
<div className="overflow-x-auto rounded-2xl border border-slate-200 dark:border-slate-700">
<table className="w-full min-w-[700px] border-collapse">
<thead>
<tr className="bg-slate-50 dark:bg-slate-800/50">
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 dark:text-slate-400 w-44 border-b border-slate-200 dark:border-slate-700">
Сотрудник
</th>
{weekDays.map(day => (
<th key={fmtDate(day)}
className={cn(
'px-2 py-3 text-center text-xs font-semibold border-b border-slate-200 dark:border-slate-700 min-w-[90px]',
isToday(day)
? 'text-brand-600 dark:text-brand-400'
: 'text-slate-500 dark:text-slate-400'
)}
>
<div>{format(day, 'EEE', { locale: ru })}</div>
<div className={cn(
'text-base font-bold mt-0.5',
isToday(day) ? 'text-brand-600 dark:text-brand-400' : 'text-slate-700 dark:text-slate-300'
)}>
{format(day, 'd')}
</div>
</th>
))}
<th className="px-3 py-3 text-xs font-semibold text-slate-400 border-b border-slate-200 dark:border-slate-700 text-center">
Смен
</th>
</tr>
</thead>
<tbody>
{filteredUsers.map((u, idx) => (
<tr key={u.id}
className={cn(
'border-b border-slate-100 dark:border-slate-700/50 last:border-0',
idx % 2 === 0 ? 'bg-white dark:bg-slate-800' : 'bg-slate-50/50 dark:bg-slate-800/50'
)}
>
{/* Employee info */}
<td className="px-4 py-2.5">
<div className="font-medium text-sm text-slate-900 dark:text-slate-100 truncate max-w-[150px]">
{u.name}
</div>
<span className={cn('text-[10px] px-1.5 py-0.5 rounded-md font-medium', ROLE_COLORS[u.role] ?? 'bg-slate-100 text-slate-600')}>
{ROLE_LABELS[u.role] ?? u.role}
</span>
</td>
{/* Day cells */}
{weekDays.map(day => {
const dateStr = fmtDate(day)
const entry = getEntry(u.id, dateStr)
return (
<td key={dateStr}
className={cn(
'px-1.5 py-1.5 text-center align-middle',
isToday(day) && 'bg-brand-50/30 dark:bg-brand-900/10',
)}
>
<button
onClick={() => isManager && setEditing({ userId: u.id, date: dateStr })}
className={cn(
'w-full min-h-[44px] rounded-lg text-xs font-medium transition-all',
isManager && 'hover:ring-2 hover:ring-brand-300',
entry?.isDayOff
? 'bg-red-50 dark:bg-red-900/20 text-red-500 dark:text-red-400'
: entry?.shiftStart
? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300'
: 'bg-slate-50 dark:bg-slate-700/30 text-slate-300 dark:text-slate-600',
)}
>
{entry?.isDayOff ? (
<span>Вых.</span>
) : entry?.shiftStart ? (
<div>
<div>{fmtTime(entry.shiftStart)}</div>
<div className="text-[10px] opacity-70">{fmtTime(entry.shiftEnd)}</div>
</div>
) : (
<span className="text-lg leading-none">+</span>
)}
</button>
</td>
)
})}
{/* Shift count */}
<td className="px-3 py-2.5 text-center text-sm font-semibold text-slate-500 dark:text-slate-400">
{getShiftCount(u.id)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
/* ── MONTH VIEW ── */
<div className="overflow-x-auto rounded-2xl border border-slate-200 dark:border-slate-700">
<table className="border-collapse" style={{ minWidth: `${180 + monthDays.length * 36 + 44}px` }}>
<thead>
<tr className="bg-slate-50 dark:bg-slate-800/50">
{/* Employee column */}
<th className="sticky left-0 z-10 bg-slate-50 dark:bg-slate-800/50 text-left px-3 py-2 text-xs font-semibold text-slate-500 dark:text-slate-400 w-44 border-b border-slate-200 dark:border-slate-700">
Сотрудник
</th>
{monthDays.map(day => (
<th key={fmtDate(day)}
className={cn(
'px-0.5 py-2 text-center text-[10px] font-semibold border-b border-slate-200 dark:border-slate-700 w-9',
isWeekend(day) && 'bg-slate-100 dark:bg-slate-700/50',
isToday(day) ? 'text-brand-600 dark:text-brand-400' : 'text-slate-500 dark:text-slate-400'
)}
>
<div className="leading-tight">{format(day, 'EEE', { locale: ru }).slice(0, 2)}</div>
<div className={cn(
'font-bold text-xs leading-tight',
isToday(day) ? 'text-brand-600 dark:text-brand-400' : 'text-slate-700 dark:text-slate-300'
)}>
{format(day, 'd')}
</div>
</th>
))}
{/* Shift count column */}
<th className="px-2 py-2 text-[10px] font-semibold text-slate-400 border-b border-slate-200 dark:border-slate-700 text-center w-11">
Смен
</th>
</tr>
</thead>
<tbody>
{filteredUsers.map((u, idx) => (
<tr key={u.id}
className={cn(
'border-b border-slate-100 dark:border-slate-700/50 last:border-0',
idx % 2 === 0 ? 'bg-white dark:bg-slate-800' : 'bg-slate-50/30 dark:bg-slate-800/30'
)}
>
{/* Employee info — sticky */}
<td className={cn(
'sticky left-0 z-10 px-3 py-1.5 border-r border-slate-100 dark:border-slate-700/50',
idx % 2 === 0 ? 'bg-white dark:bg-slate-800' : 'bg-slate-50/30 dark:bg-slate-800/30'
)}>
<div className="font-medium text-xs text-slate-900 dark:text-slate-100 truncate max-w-[140px]">
{u.name}
</div>
<span className={cn('text-[9px] px-1 py-0.5 rounded font-medium', ROLE_COLORS[u.role] ?? 'bg-slate-100 text-slate-600')}>
{ROLE_LABELS[u.role] ?? u.role}
</span>
</td>
{/* Day cells — compact */}
{monthDays.map(day => {
const dateStr = fmtDate(day)
const entry = getEntry(u.id, dateStr)
const weekend = isWeekend(day)
return (
<td key={dateStr}
className={cn(
'px-0.5 py-0.5 text-center align-middle',
weekend && 'bg-slate-50 dark:bg-slate-700/20',
isToday(day) && 'bg-brand-50/40 dark:bg-brand-900/10',
)}
>
<button
onClick={() => isManager && setEditing({ userId: u.id, date: dateStr })}
title={
entry?.isDayOff
? 'Выходной'
: entry?.shiftStart
? `${fmtTime(entry.shiftStart)} ${fmtTime(entry.shiftEnd)}`
: dateStr
}
className={cn(
'w-8 h-7 rounded text-[10px] font-semibold transition-all flex items-center justify-center mx-auto',
isManager && 'hover:ring-1 hover:ring-brand-300',
entry?.isDayOff
? 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400'
: entry?.shiftStart
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
: 'bg-slate-50 dark:bg-slate-700/30 text-slate-300 dark:text-slate-600',
)}
>
{entry?.isDayOff ? (
<span>В</span>
) : entry?.shiftStart ? (
<span>{fmtTime(entry.shiftStart).slice(0, 5)}</span>
) : (
<span className="text-xs leading-none">+</span>
)}
</button>
</td>
)
})}
{/* Monthly shift count */}
<td className="px-2 py-1.5 text-center text-xs font-semibold text-slate-500 dark:text-slate-400">
{getShiftCount(u.id)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Legend */}
<div className="flex items-center gap-4 text-xs text-slate-400 dark:text-slate-500">
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded bg-emerald-100 dark:bg-emerald-900/30" />
Рабочая смена
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded bg-red-50 dark:bg-red-900/20" />
Выходной
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded bg-slate-100 dark:bg-slate-700" />
Не назначено
</div>
{isManager && <span className="ml-auto">Нажмите на ячейку чтобы назначить смену</span>}
</div>
{/* Cell editor modal */}
{editing && (
<CellEditor
userId={editing.userId}
date={editing.date}
entry={getEntry(editing.userId, editing.date)}
onClose={() => setEditing(null)}
onSave={(data) => handleSave(editing.userId, editing.date, data)}
onDelete={() => handleDelete(editing.userId, editing.date)}
/>
)}
</div>
)
}
// silence unused import warning
void Calendar