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:
@@ -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,23 +269,65 @@ 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">
|
||||
<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 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>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user