diff --git a/backend/migrations/037_staff_schedule.sql b/backend/migrations/037_staff_schedule.sql
new file mode 100644
index 0000000..6178bc6
--- /dev/null
+++ b/backend/migrations/037_staff_schedule.sql
@@ -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)
+);
diff --git a/backend/src/app.ts b/backend/src/app.ts
index 1d0b443..35f8dcd 100644
--- a/backend/src/app.ts
+++ b/backend/src/app.ts
@@ -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()
diff --git a/backend/src/routes/schedule.ts b/backend/src/routes/schedule.ts
new file mode 100644
index 0000000..ed113e8
--- /dev/null
+++ b/backend/src/routes/schedule.ts
@@ -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 => {
+ 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(
+ '/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(
+ '/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
diff --git a/src/App.tsx b/src/App.tsx
index 1f24699..a0e4d5e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx
index 50f41e8..4509113 100644
--- a/src/components/layout/Sidebar.tsx
+++ b/src/components/layout/Sidebar.tsx
@@ -185,7 +185,8 @@ export function Sidebar({ open, onClose }: SidebarProps) {
Управление
)}
- {can('users') && }
+ {can('users') && }
+ {can('users') && }
{can('loyalty') && }
{can('maintenance')&& }
{can('rooms') && }
diff --git a/src/lib/api.ts b/src/lib/api.ts
index a63e3b9..deb2837 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -274,6 +274,19 @@ export const api = {
req('DELETE', `/api/hotels/${slug}/users/${id}`),
},
+ // ── Schedule ──────────────────────────────────────────────────────────────
+ schedule: {
+ list: (slug: string, from: string, to: string) =>
+ req('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('PUT', `/api/hotels/${slug}/schedule`, data),
+ remove: (slug: string, userId: string, date: string) =>
+ req('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 {
diff --git a/src/pages/SchedulePage.tsx b/src/pages/SchedulePage.tsx
new file mode 100644
index 0000000..068f4e9
--- /dev/null
+++ b/src/pages/SchedulePage.tsx
@@ -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 = {
+ 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 = {
+ 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
+ onDelete: () => Promise
+}
+
+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 (
+
+
e.stopPropagation()}>
+
+
+ {format(parseISO(date), 'd MMMM', { locale: ru })}
+
+
+
+
+ {/* Day off toggle */}
+
+
Выходной день
+
+
+
+ {!isDayOff && (
+ <>
+ {/* Presets */}
+
+ {SHIFT_PRESETS.map(p => (
+
+ ))}
+
+
+ {/* Time inputs */}
+
+ >
+ )}
+
+ {/* Notes */}
+
+
+ )
+}
+
+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([])
+ const [schedule, setSchedule] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [roleFilter, setRoleFilter] = useState('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 (
+
+ {/* Header */}
+
+
+
График работы
+
{users.length} сотрудников
+
+
+ {/* Week navigation */}
+
+
+
+ {format(weekDays[0], 'd MMM', { locale: ru })} — {format(weekDays[6], 'd MMM yyyy', { locale: ru })}
+
+
+
+
+
+
+ {/* Filters */}
+
+
+
+ {allRoles.map(role => (
+
+ ))}
+
+
+ {/* Schedule grid */}
+ {loading ? (
+
+
+
+ ) : filteredUsers.length === 0 ? (
+
+ {users.length === 0
+ ? 'Нет сотрудников. Добавьте их на странице «Сотрудники».'
+ : 'Нет сотрудников с выбранной ролью.'}
+
+ ) : (
+
+
+
+
+ |
+ Сотрудник
+ |
+ {weekDays.map(day => (
+
+ {format(day, 'EEE', { locale: ru })}
+
+ {format(day, 'd')}
+
+ |
+ ))}
+
+ Смен
+ |
+
+
+
+ {filteredUsers.map((u, idx) => (
+
+ {/* Employee info */}
+ |
+
+ {u.name}
+
+
+ {ROLE_LABELS[u.role] ?? u.role}
+
+ |
+
+ {/* Day cells */}
+ {weekDays.map(day => {
+ const dateStr = fmtDate(day)
+ const entry = getEntry(u.id, dateStr)
+ return (
+
+
+ |
+ )
+ })}
+
+ {/* Week shift count */}
+
+ {getWeekCount(u.id)}
+ |
+
+ ))}
+
+
+
+ )}
+
+ {/* Legend */}
+
+
+
+
+ {isManager &&
Нажмите на ячейку чтобы назначить смену}
+
+
+ {/* Cell editor modal */}
+ {editing && (
+
setEditing(null)}
+ onSave={(data) => handleSave(editing.userId, editing.date, data)}
+ onDelete={() => handleDelete(editing.userId, editing.date)}
+ />
+ )}
+
+ )
+}
+
+// silence unused import warning
+void Calendar