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.
612 lines
26 KiB
TypeScript
612 lines
26 KiB
TypeScript
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
|