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:
2026-03-26 01:18:02 +03:00
parent 17012ae3ca
commit 5d5efecd9e
7 changed files with 587 additions and 1 deletions

View 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)
);

View File

@@ -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()

View 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

View File

@@ -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 />} />

View File

@@ -186,6 +186,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
</p>
)}
{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} />}

View File

@@ -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
View 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