feat: staff work schedule — weekly grid for all roles, shift assignment, day off marking
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
13
backend/migrations/037_staff_schedule.sql
Normal file
13
backend/migrations/037_staff_schedule.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS staff_schedules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
date DATE NOT NULL,
|
||||
shift_start TIME,
|
||||
shift_end TIME,
|
||||
is_day_off BOOLEAN NOT NULL DEFAULT false,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(hotel_id, user_id, date)
|
||||
);
|
||||
@@ -30,6 +30,7 @@ import rateOverridesRoutes from './routes/rate-overrides'
|
||||
import uploadRoutes from './routes/upload'
|
||||
import housekeepingSettingsRoutes from './routes/housekeeping-settings'
|
||||
import notificationsRoutes from './routes/notifications'
|
||||
import scheduleRoutes from './routes/schedule'
|
||||
import { startJobs } from './jobs'
|
||||
|
||||
export async function buildApp() {
|
||||
@@ -105,6 +106,7 @@ export async function buildApp() {
|
||||
await fastify.register(uploadRoutes)
|
||||
await fastify.register(housekeepingSettingsRoutes)
|
||||
await fastify.register(notificationsRoutes)
|
||||
await fastify.register(scheduleRoutes)
|
||||
|
||||
startJobs()
|
||||
|
||||
|
||||
101
backend/src/routes/schedule.ts
Normal file
101
backend/src/routes/schedule.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
import { db } from '../db'
|
||||
|
||||
type SlugParam = { Params: { slug: string } }
|
||||
|
||||
const scheduleRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const getHotelId = async (slug: string): Promise<string | null> => {
|
||||
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
|
||||
return rows[0]?.id ?? null
|
||||
}
|
||||
|
||||
const canAccess = (userSlug: string | null, role: string, slug: string) =>
|
||||
role === 'super_admin' || userSlug === slug
|
||||
|
||||
// GET /api/hotels/:slug/schedule?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||
fastify.get<SlugParam & { Querystring: { from?: string; to?: string } }>(
|
||||
'/api/hotels/:slug/schedule',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug))
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { from, to } = request.query
|
||||
let where = 'WHERE ss.hotel_id = $1'
|
||||
const params: unknown[] = [hotelId]
|
||||
if (from) { params.push(from); where += ` AND ss.date >= $${params.length}` }
|
||||
if (to) { params.push(to); where += ` AND ss.date <= $${params.length}` }
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT ss.id, ss.user_id, ss.date, ss.shift_start, ss.shift_end,
|
||||
ss.is_day_off, ss.notes,
|
||||
u.name AS user_name, u.role AS user_role, u.position AS user_position
|
||||
FROM staff_schedules ss
|
||||
JOIN users u ON u.id = ss.user_id
|
||||
${where}
|
||||
ORDER BY ss.date, u.name`,
|
||||
params,
|
||||
)
|
||||
return rows
|
||||
},
|
||||
)
|
||||
|
||||
// PUT /api/hotels/:slug/schedule — upsert one entry
|
||||
fastify.put<SlugParam & { Body: {
|
||||
user_id: string; date: string; shift_start?: string; shift_end?: string;
|
||||
is_day_off?: boolean; notes?: string
|
||||
} }>(
|
||||
'/api/hotels/:slug/schedule',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug } = request.params
|
||||
if (!['manager', 'hotel_admin', 'super_admin'].includes(request.user.role))
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug))
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { user_id, date, shift_start, shift_end, is_day_off, notes } = request.body
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO staff_schedules (hotel_id, user_id, date, shift_start, shift_end, is_day_off, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (hotel_id, user_id, date) DO UPDATE SET
|
||||
shift_start = EXCLUDED.shift_start,
|
||||
shift_end = EXCLUDED.shift_end,
|
||||
is_day_off = EXCLUDED.is_day_off,
|
||||
notes = COALESCE(EXCLUDED.notes, staff_schedules.notes),
|
||||
updated_at = NOW()
|
||||
RETURNING *`,
|
||||
[hotelId, user_id, date, shift_start ?? null, shift_end ?? null, is_day_off ?? false, notes ?? null],
|
||||
)
|
||||
return rows[0]
|
||||
},
|
||||
)
|
||||
|
||||
// DELETE /api/hotels/:slug/schedule/:userId/:date
|
||||
fastify.delete<{ Params: { slug: string; userId: string; date: string } }>(
|
||||
'/api/hotels/:slug/schedule/:userId/:date',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug, userId, date } = request.params
|
||||
if (!['manager', 'hotel_admin', 'super_admin'].includes(request.user.role))
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug))
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
await db.query(
|
||||
'DELETE FROM staff_schedules WHERE hotel_id = $1 AND user_id = $2 AND date = $3',
|
||||
[hotelId, userId, date],
|
||||
)
|
||||
return reply.code(204).send()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default scheduleRoutes
|
||||
@@ -39,6 +39,7 @@ import { GuestRoomServicePage } from './pages/GuestRoomServicePage'
|
||||
import { ResetPasswordPage } from './pages/ResetPasswordPage'
|
||||
import { TvWelcomePage } from './pages/TvWelcomePage'
|
||||
import { TechnicalPage } from './pages/TechnicalPage'
|
||||
import { SchedulePage } from './pages/SchedulePage'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -79,6 +80,7 @@ export default function App() {
|
||||
<Route path="/room-categories" element={<RoomCategoriesPage />} />
|
||||
<Route path="/documents" element={<DocumentsPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/schedule" element={<SchedulePage />} />
|
||||
<Route path="/guests" element={<GuestsPage />} />
|
||||
<Route path="/tariffs" element={<TariffsPage />} />
|
||||
<Route path="/dynamic-pricing" element={<DynamicPricingPage />} />
|
||||
|
||||
@@ -185,7 +185,8 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
||||
Управление
|
||||
</p>
|
||||
)}
|
||||
{can('users') && <NavItem to="/users" icon={UserCog} label="Сотрудники" onClick={onClose} />}
|
||||
{can('users') && <NavItem to="/users" icon={UserCog} label="Сотрудники" onClick={onClose} />}
|
||||
{can('users') && <NavItem to="/schedule" icon={CalendarClock} label="График работы" onClick={onClose} />}
|
||||
{can('loyalty') && <NavItem to="/loyalty" icon={Award} label="Лояльность" onClick={onClose} />}
|
||||
{can('maintenance')&& <NavItem to="/maintenance" icon={Wrench} label="Тех. перерывы" onClick={onClose} />}
|
||||
{can('rooms') && <NavItem to="/floor-map" icon={Map} label="План этажей" onClick={onClose} />}
|
||||
|
||||
@@ -274,6 +274,19 @@ export const api = {
|
||||
req<void>('DELETE', `/api/hotels/${slug}/users/${id}`),
|
||||
},
|
||||
|
||||
// ── Schedule ──────────────────────────────────────────────────────────────
|
||||
schedule: {
|
||||
list: (slug: string, from: string, to: string) =>
|
||||
req<ScheduleEntry[]>('GET', `/api/hotels/${slug}/schedule?from=${from}&to=${to}`),
|
||||
upsert: (slug: string, data: {
|
||||
user_id: string; date: string; shift_start?: string; shift_end?: string;
|
||||
is_day_off?: boolean; notes?: string
|
||||
}) =>
|
||||
req<ScheduleEntry>('PUT', `/api/hotels/${slug}/schedule`, data),
|
||||
remove: (slug: string, userId: string, date: string) =>
|
||||
req<void>('DELETE', `/api/hotels/${slug}/schedule/${userId}/${date}`),
|
||||
},
|
||||
|
||||
// ── Hotels ────────────────────────────────────────────────────────────────
|
||||
hotels: {
|
||||
get: (slug: string) =>
|
||||
@@ -487,6 +500,21 @@ export const api = {
|
||||
},
|
||||
}
|
||||
|
||||
// ── Schedule ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ScheduleEntry {
|
||||
id: string
|
||||
userId: string
|
||||
date: string
|
||||
shiftStart: string | null
|
||||
shiftEnd: string | null
|
||||
isDayOff: boolean
|
||||
notes: string | null
|
||||
userName: string
|
||||
userRole: string
|
||||
userPosition: string | null
|
||||
}
|
||||
|
||||
// ── Payload types & converters ───────────────────────────────────────────────
|
||||
|
||||
export interface RoomPayload {
|
||||
|
||||
439
src/pages/SchedulePage.tsx
Normal file
439
src/pages/SchedulePage.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
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 } 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)
|
||||
}
|
||||
|
||||
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 [weekStart, setWeekStart] = useState(() =>
|
||||
startOfWeek(new Date(), { weekStartsOn: 1 })
|
||||
)
|
||||
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)
|
||||
|
||||
const weekDays = getWeekDays(weekStart)
|
||||
const from = fmtDate(weekDays[0])
|
||||
const to = fmtDate(weekDays[6])
|
||||
|
||||
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 this week per user
|
||||
const getWeekCount = (userId: string) =>
|
||||
schedule.filter(s => s.userId === userId && !s.isDayOff).length
|
||||
|
||||
const allRoles = [...new Set(users.map(u => u.role))].filter(Boolean).sort()
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
</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>
|
||||
) : (
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Week 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)}
|
||||
</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
|
||||
Reference in New Issue
Block a user