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.
This commit is contained in:
2026-03-26 01:28:32 +03:00
parent 5d5efecd9e
commit 8ede920e5d

View File

@@ -4,7 +4,10 @@ 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 } from 'date-fns'
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> = {
@@ -48,6 +51,12 @@ function fmtTime(t: string | null | undefined) {
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
@@ -168,18 +177,27 @@ export function SchedulePage() {
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 from = fmtDate(weekDays[0])
const to = fmtDate(weekDays[6])
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
@@ -232,12 +250,16 @@ export function SchedulePage() {
roleFilter === 'all' || u.role === roleFilter
)
// Count scheduled shifts this week per user
const getWeekCount = (userId: string) =>
// 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 */}
@@ -247,8 +269,36 @@ export function SchedulePage() {
<p className="text-sm text-slate-500 dark:text-slate-400">{users.length} сотрудников</p>
</div>
{/* Week navigation */}
<div className="flex items-center gap-2">
<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>
@@ -258,12 +308,26 @@ export function SchedulePage() {
<button onClick={() => setWeekStart(w => addWeeks(w, 1))} className="btn-secondary p-2">
<ChevronRight size={16} />
</button>
<button
onClick={() => setWeekStart(startOfWeek(new Date(), { weekStartsOn: 1 }))}
className="btn-secondary text-sm"
>
<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>
@@ -305,7 +369,8 @@ export function SchedulePage() {
? 'Нет сотрудников. Добавьте их на странице «Сотрудники».'
: 'Нет сотрудников с выбранной ролью.'}
</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>
@@ -392,9 +457,116 @@ export function SchedulePage() {
)
})}
{/* Week shift count */}
{/* Shift count */}
<td className="px-3 py-2.5 text-center text-sm font-semibold text-slate-500 dark:text-slate-400">
{getWeekCount(u.id)}
{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>
))}