From 5d5efecd9e5aebe99949af6c05d24c589ff8cf3a Mon Sep 17 00:00:00 2001 From: HotelSync Date: Thu, 26 Mar 2026 01:18:02 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20staff=20work=20schedule=20=E2=80=94=20w?= =?UTF-8?q?eekly=20grid=20for=20all=20roles,=20shift=20assignment,=20day?= =?UTF-8?q?=20off=20marking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- backend/migrations/037_staff_schedule.sql | 13 + backend/src/app.ts | 2 + backend/src/routes/schedule.ts | 101 +++++ src/App.tsx | 2 + src/components/layout/Sidebar.tsx | 3 +- src/lib/api.ts | 28 ++ src/pages/SchedulePage.tsx | 439 ++++++++++++++++++++++ 7 files changed, 587 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/037_staff_schedule.sql create mode 100644 backend/src/routes/schedule.ts create mode 100644 src/pages/SchedulePage.tsx 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 */} +
+
+ + setShiftStart(e.target.value)} + className="input text-sm w-full" /> +
+
+ + setShiftEnd(e.target.value)} + className="input text-sm w-full" /> +
+
+ + )} + + {/* Notes */} +