feat: enforce checklist/minibar completion before marking task done

- ApiError now carries errors[] array from 422 responses
- updateStatus re-throws 422 so TaskCard can surface errors
- TaskCard: minibarCheckedLocal state + 'Подтвердить проверку минибара' button
- TaskCard: async handleComplete with completionErrors display
- Backend validation errors shown inline in completion panel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 23:09:03 +03:00
parent 3c1a6ccebf
commit d98389e2bc
8 changed files with 289 additions and 24 deletions

View File

@@ -0,0 +1,13 @@
-- Настройка "требовать выполнения чек-листа перед завершением уборки"
ALTER TABLE checklist_templates
ADD COLUMN IF NOT EXISTS require_before_complete BOOLEAN NOT NULL DEFAULT false;
-- Настройка "требовать проверки минибара перед завершением уборки" (уровень отеля)
CREATE TABLE IF NOT EXISTS hotel_housekeeping_rules (
hotel_id UUID PRIMARY KEY REFERENCES hotels(id) ON DELETE CASCADE,
require_minibar_check BOOLEAN NOT NULL DEFAULT false
);
-- Флаг "горничная подтвердила проверку минибара" на задаче уборки
ALTER TABLE housekeeping_tasks
ADD COLUMN IF NOT EXISTS minibar_checked BOOLEAN NOT NULL DEFAULT false;

View File

@@ -83,7 +83,7 @@ const checklists: FastifyPluginAsync = async (fastify) => {
const hotelId = await getHotelId(slug) const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const allowed = ['name', 'task_type', 'is_active', 'sort_order'] const allowed = ['name', 'task_type', 'is_active', 'sort_order', 'require_before_complete']
const updates: string[] = [] const updates: string[] = []
const values: unknown[] = [] const values: unknown[] = []
let idx = 1 let idx = 1
@@ -369,4 +369,48 @@ const checklists: FastifyPluginAsync = async (fastify) => {
) )
} }
// ── GET /api/hotels/:slug/housekeeping-rules ──────────────────────────────
fastify.get<SlugParam>(
'/api/hotels/:slug/housekeeping-rules',
{ 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 { rows } = await db.query(
'SELECT require_minibar_check FROM hotel_housekeeping_rules WHERE hotel_id = $1',
[hotelId],
)
return { requireMinibarCheck: rows[0]?.require_minibar_check ?? false }
},
)
// ── PATCH /api/hotels/:slug/housekeeping-rules ─────────────────────────────
fastify.patch<SlugParam & { Body: { require_minibar_check?: boolean } }>(
'/api/hotels/:slug/housekeeping-rules',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) {
return reply.code(403).send({ error: 'Forbidden' })
}
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { require_minibar_check } = request.body
await db.query(
`INSERT INTO hotel_housekeeping_rules (hotel_id, require_minibar_check)
VALUES ($1, $2)
ON CONFLICT (hotel_id) DO UPDATE SET require_minibar_check = $2`,
[hotelId, require_minibar_check ?? false],
)
return { requireMinibarCheck: require_minibar_check ?? false }
},
)
}
export default checklists export default checklists

View File

@@ -159,7 +159,67 @@ const housekeeping: FastifyPluginAsync = async (fastify) => {
updates.push(`completed_at = NULL`) updates.push(`completed_at = NULL`)
} }
// Also allow updating minibar_checked flag
if ((request.body as Record<string, unknown>).minibar_checked !== undefined) {
updates.push(`minibar_checked = $${idx}`)
values.push((request.body as Record<string, unknown>).minibar_checked)
idx++
}
if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' }) if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
// ── Validate completion requirements ──────────────────────────────────
if (request.body.status === 'done') {
// Get current task (need task_type, minibar_checked, room_id)
const { rows: taskRows } = await db.query(
`SELECT t.*, t.minibar_checked FROM housekeeping_tasks t WHERE t.id = $1 AND t.hotel_id = $2`,
[id, hotelId],
)
const currentTask = taskRows[0]
if (!currentTask) return reply.code(404).send({ error: 'Task not found' })
// Merge minibar_checked from request body (may be set in same request)
const minibarCheckedNow = (request.body as Record<string, unknown>).minibar_checked ?? currentTask.minibar_checked
const errors: string[] = []
// 1. Check checklist requirements
const { rows: templates } = await db.query(
`SELECT id, name FROM checklist_templates
WHERE hotel_id = $1 AND is_active = true AND require_before_complete = true
AND (task_type IS NULL OR task_type = $2)`,
[hotelId, currentTask.type ?? null],
)
for (const tpl of templates) {
const { rows: items } = await db.query(
`SELECT ci.id, ci.text,
cc.id IS NOT NULL AS is_completed
FROM checklist_items ci
LEFT JOIN checklist_completions cc ON cc.item_id = ci.id AND cc.task_id = $1
WHERE ci.template_id = $2
ORDER BY ci.sort_order`,
[id, tpl.id],
)
const unchecked = items.filter((i: Record<string, unknown>) => !i.is_completed)
if (unchecked.length > 0) {
errors.push(`Чек-лист «${tpl.name}»: не выполнено ${unchecked.length} из ${items.length} пунктов`)
}
}
// 2. Check minibar requirement
const { rows: rules } = await db.query(
`SELECT require_minibar_check FROM hotel_housekeeping_rules WHERE hotel_id = $1`,
[hotelId],
)
if (rules[0]?.require_minibar_check && !minibarCheckedNow) {
errors.push('Минибар не проверен')
}
if (errors.length > 0) {
return reply.code(422).send({ error: errors.join('; '), errors })
}
}
values.push(id, hotelId) values.push(id, hotelId)
const { rows: updated } = await db.query( const { rows: updated } = await db.query(

View File

@@ -7,8 +7,10 @@ const BASE = (import.meta.env.VITE_API_URL as string | undefined) ?? 'https://ap
// ── Errors ────────────────────────────────────────────────────────────────── // ── Errors ──────────────────────────────────────────────────────────────────
export class ApiError extends Error { export class ApiError extends Error {
constructor(public status: number, message: string) { errors?: string[]
constructor(public status: number, message: string, errors?: string[]) {
super(message) super(message)
this.errors = errors
} }
} }
@@ -115,8 +117,10 @@ async function req<T>(
const data: unknown = await res.json() const data: unknown = await res.json()
if (!res.ok) { if (!res.ok) {
const msg = (data as Record<string, string>)?.error ?? 'Request failed' const d = data as Record<string, unknown>
throw new ApiError(res.status, msg) const msg = (d?.error as string) ?? 'Request failed'
const errors = Array.isArray(d?.errors) ? (d.errors as string[]) : undefined
throw new ApiError(res.status, msg, errors)
} }
return transformKeys(data) as T return transformKeys(data) as T
@@ -652,12 +656,13 @@ export const api = {
task_type: data.taskType ?? null, task_type: data.taskType ?? null,
}), }),
updateTemplate: (slug: string, templateId: string, data: { name?: string; taskType?: string | null; isActive?: boolean; sortOrder?: number }) => updateTemplate: (slug: string, templateId: string, data: { name?: string; taskType?: string | null; isActive?: boolean; sortOrder?: number; requireBeforeComplete?: boolean }) =>
req<ChecklistTemplate>('PATCH', `/api/hotels/${slug}/checklist-templates/${templateId}`, { req<ChecklistTemplate>('PATCH', `/api/hotels/${slug}/checklist-templates/${templateId}`, {
name: data.name, name: data.name,
task_type: data.taskType, task_type: data.taskType,
is_active: data.isActive, is_active: data.isActive,
sort_order: data.sortOrder, sort_order: data.sortOrder,
require_before_complete: data.requireBeforeComplete,
}), }),
deleteTemplate: (slug: string, templateId: string) => deleteTemplate: (slug: string, templateId: string) =>
@@ -686,6 +691,14 @@ export const api = {
uncompleteItem: (slug: string, taskId: string, itemId: string) => uncompleteItem: (slug: string, taskId: string, itemId: string) =>
req<void>('DELETE', `/api/hotels/${slug}/housekeeping/${taskId}/checklist/${itemId}/complete`), req<void>('DELETE', `/api/hotels/${slug}/housekeeping/${taskId}/checklist/${itemId}/complete`),
getHousekeepingRules: (slug: string) =>
req<{ requireMinibarCheck: boolean }>('GET', `/api/hotels/${slug}/housekeeping-rules`),
updateHousekeepingRules: (slug: string, data: { requireMinibarCheck: boolean }) =>
req<{ requireMinibarCheck: boolean }>('PATCH', `/api/hotels/${slug}/housekeeping-rules`, {
require_minibar_check: data.requireMinibarCheck,
}),
}, },
// ── Minibar ─────────────────────────────────────────────────────────────── // ── Minibar ───────────────────────────────────────────────────────────────
@@ -842,6 +855,7 @@ export interface HkPayload {
status?: string; assignee_id?: string; notes?: string; due_date?: string status?: string; assignee_id?: string; notes?: string; due_date?: string
category?: string; photos?: string[] category?: string; photos?: string[]
resolution_notes?: string; resolution_photos?: string[] resolution_notes?: string; resolution_photos?: string[]
minibar_checked?: boolean
} }
export interface HkSettings { export interface HkSettings {
@@ -1280,6 +1294,7 @@ export interface ChecklistTemplate {
name: string name: string
taskType: string | null taskType: string | null
isActive: boolean isActive: boolean
requireBeforeComplete: boolean
sortOrder: number sortOrder: number
createdAt: string createdAt: string
items: ChecklistItem[] items: ChecklistItem[]

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Plus, Trash2, ChevronUp, ChevronDown, Pencil, Check, X, Loader2, ListChecks, ToggleLeft, ToggleRight } from 'lucide-react' import { Plus, Trash2, ChevronUp, ChevronDown, Pencil, Check, X, Loader2, ListChecks, ToggleLeft, ToggleRight, ShieldAlert } from 'lucide-react'
import { api, type ChecklistTemplate, type ChecklistItem } from '../lib/api' import { api, type ChecklistTemplate, type ChecklistItem } from '../lib/api'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { cn } from '../lib/utils' import { cn } from '../lib/utils'
@@ -97,6 +97,13 @@ export function ChecklistSettingsPage() {
} catch { /* ignore */ } } catch { /* ignore */ }
} }
const toggleRequire = async (t: ChecklistTemplate) => {
try {
await api.checklists.updateTemplate(slug, t.id, { requireBeforeComplete: !t.requireBeforeComplete })
setTemplates(prev => prev.map(x => x.id === t.id ? { ...x, requireBeforeComplete: !x.requireBeforeComplete } : x))
} catch { /* ignore */ }
}
const deleteTemplate = async (id: string) => { const deleteTemplate = async (id: string) => {
if (!confirm('Удалить шаблон вместе со всеми пунктами?')) return if (!confirm('Удалить шаблон вместе со всеми пунктами?')) return
try { try {
@@ -323,6 +330,30 @@ export function ChecklistSettingsPage() {
Добавить Добавить
</button> </button>
</div> </div>
{/* Require before complete toggle */}
<div
className={cn(
'flex items-center justify-between mt-3 pt-3 border-t border-slate-100 dark:border-slate-700 px-1 py-1.5 rounded-lg cursor-pointer select-none',
tpl.requireBeforeComplete
? 'bg-amber-50 dark:bg-amber-900/10'
: 'hover:bg-slate-50 dark:hover:bg-slate-800/50',
)}
onClick={() => toggleRequire(tpl)}
>
<div className="flex items-center gap-2">
<ShieldAlert size={14} className={tpl.requireBeforeComplete ? 'text-amber-500' : 'text-slate-400'} />
<div>
<p className={cn('text-xs font-medium', tpl.requireBeforeComplete ? 'text-amber-700 dark:text-amber-400' : 'text-slate-600 dark:text-slate-400')}>
Требовать выполнения перед завершением уборки
</p>
<p className="text-xs text-slate-400">Горничная не сможет закрыть задачу, пока не отметит все пункты</p>
</div>
</div>
{tpl.requireBeforeComplete
? <ToggleRight size={18} className="text-amber-500 shrink-0" />
: <ToggleLeft size={18} className="text-slate-300 dark:text-slate-600 shrink-0" />}
</div>
</div> </div>
</div> </div>
))} ))}

View File

@@ -4,7 +4,7 @@ import type { TaskChecklist, MinibarItem, MinibarConsumption } from '../lib/api'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { useHotelSocket } from '../hooks/useHotelSocket' import { useHotelSocket } from '../hooks/useHotelSocket'
import type { WsMessage } from '../hooks/useHotelSocket' import type { WsMessage } from '../hooks/useHotelSocket'
import { api } from '../lib/api' import { api, ApiError } from '../lib/api'
import type { HkSettings } from '../lib/api' import type { HkSettings } from '../lib/api'
import type { HousekeepingTask } from '../types' import type { HousekeepingTask } from '../types'
import { cn } from '../lib/utils' import { cn } from '../lib/utils'
@@ -199,6 +199,7 @@ export function HousekeepingPage() {
setActiveTasks(prev => prev.map(t => t.id === id ? updated : t)) setActiveTasks(prev => prev.map(t => t.id === id ? updated : t))
} }
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.status === 422) throw err
console.error('Failed to update task:', err) console.error('Failed to update task:', err)
} }
} }
@@ -626,7 +627,7 @@ const SEVERITY_CONFIG = {
function TaskCard({ task, onStatusChange, onReport, slug }: { function TaskCard({ task, onStatusChange, onReport, slug }: {
task: HousekeepingTask task: HousekeepingTask
slug: string slug: string
onStatusChange: (id: string, status: HousekeepingTask['status'], resolutionNotes?: string) => void onStatusChange: (id: string, status: HousekeepingTask['status'], resolutionNotes?: string) => Promise<void>
onReport: (id: string, note: string, severity: 'low' | 'medium' | 'high', photos: string[]) => void onReport: (id: string, note: string, severity: 'low' | 'medium' | 'high', photos: string[]) => void
}) { }) {
const [reportOpen, setReportOpen] = useState(false) const [reportOpen, setReportOpen] = useState(false)
@@ -637,6 +638,9 @@ function TaskCard({ task, onStatusChange, onReport, slug }: {
const photoInputRef = useRef<HTMLInputElement>(null) const photoInputRef = useRef<HTMLInputElement>(null)
const [completingOpen, setCompletingOpen] = useState(false) const [completingOpen, setCompletingOpen] = useState(false)
const [completionComment, setCompletionComment] = useState('') const [completionComment, setCompletionComment] = useState('')
const [completionErrors, setCompletionErrors] = useState<string[]>([])
const [completionSaving, setCompletionSaving] = useState(false)
const [minibarCheckedLocal, setMinibarCheckedLocal] = useState(task.minibarChecked ?? false)
// Checklist + Minibar detail panel // Checklist + Minibar detail panel
const [detailOpen, setDetailOpen] = useState(false) const [detailOpen, setDetailOpen] = useState(false)
@@ -719,6 +723,30 @@ function TaskCard({ task, onStatusChange, onReport, slug }: {
} catch { /* ignore */ } } catch { /* ignore */ }
} }
const handleMinibarChecked = async () => {
setMinibarCheckedLocal(true)
try {
await api.housekeeping.update(slug, task.id, { minibar_checked: true })
} catch {
setMinibarCheckedLocal(false)
}
}
const handleComplete = async () => {
setCompletionErrors([])
setCompletionSaving(true)
try {
await onStatusChange(task.id, 'done', completionComment.trim() || undefined)
setCompletingOpen(false)
} catch (err) {
if (err instanceof ApiError && err.errors) {
setCompletionErrors(err.errors)
}
} finally {
setCompletionSaving(false)
}
}
const uploadReportPhoto = async (file: File) => { const uploadReportPhoto = async (file: File) => {
setPhotoUploading(true) setPhotoUploading(true)
try { try {
@@ -888,10 +916,19 @@ function TaskCard({ task, onStatusChange, onReport, slug }: {
<p className="text-xs font-semibold text-slate-700 dark:text-slate-300 flex items-center gap-1"> <p className="text-xs font-semibold text-slate-700 dark:text-slate-300 flex items-center gap-1">
<CheckCircle2 size={11} className="text-emerald-500" /> Завершение уборки <CheckCircle2 size={11} className="text-emerald-500" /> Завершение уборки
</p> </p>
<button onClick={() => setCompletingOpen(false)} className="text-slate-400 hover:text-slate-600"> <button onClick={() => { setCompletingOpen(false); setCompletionErrors([]) }} className="text-slate-400 hover:text-slate-600">
<XIcon size={13} /> <XIcon size={13} />
</button> </button>
</div> </div>
{completionErrors.length > 0 && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-2 space-y-0.5">
{completionErrors.map((e, i) => (
<p key={i} className="text-xs text-red-700 dark:text-red-300 flex items-start gap-1">
<AlertTriangle size={11} className="mt-0.5 shrink-0" /> {e}
</p>
))}
</div>
)}
<textarea <textarea
autoFocus autoFocus
className="w-full text-xs border border-slate-200 dark:border-slate-600 rounded-lg p-2 bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 resize-none focus:outline-none focus:border-emerald-400" className="w-full text-xs border border-slate-200 dark:border-slate-600 rounded-lg p-2 bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 resize-none focus:outline-none focus:border-emerald-400"
@@ -899,21 +936,15 @@ function TaskCard({ task, onStatusChange, onReport, slug }: {
placeholder="Комментарий (необязательно)..." placeholder="Комментарий (необязательно)..."
value={completionComment} value={completionComment}
onChange={e => setCompletionComment(e.target.value)} onChange={e => setCompletionComment(e.target.value)}
onKeyDown={e => { onKeyDown={e => { if (e.key === 'Enter' && e.ctrlKey) handleComplete() }}
if (e.key === 'Enter' && e.ctrlKey) {
onStatusChange(task.id, 'done', completionComment.trim() || undefined)
setCompletingOpen(false)
}
}}
/> />
<button <button
onClick={() => { onClick={handleComplete}
onStatusChange(task.id, 'done', completionComment.trim() || undefined) disabled={completionSaving}
setCompletingOpen(false) className="w-full flex items-center justify-center gap-1 text-xs py-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white font-medium transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
}}
className="w-full flex items-center justify-center gap-1 text-xs py-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white font-medium transition-colors"
> >
<CheckCircle2 size={11} /> Завершить уборку {completionSaving ? <Loader2 size={11} className="animate-spin" /> : <CheckCircle2 size={11} />}
Завершить уборку
</button> </button>
</div> </div>
)} )}
@@ -1053,6 +1084,25 @@ function TaskCard({ task, onStatusChange, onReport, slug }: {
))} ))}
</div> </div>
)} )}
{/* Minibar confirmed button */}
{!minibarLoading && (
<div className="border-t border-slate-100 dark:border-slate-700 pt-2">
<button
onClick={minibarCheckedLocal ? undefined : handleMinibarChecked}
disabled={minibarCheckedLocal}
className={cn(
'w-full text-xs py-1.5 rounded-lg font-medium flex items-center justify-center gap-1 transition-colors',
minibarCheckedLocal
? 'bg-emerald-50 dark:bg-emerald-900/10 text-emerald-600 dark:text-emerald-400 cursor-default'
: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/10 hover:text-emerald-600',
)}
>
<CheckCircle2 size={11} />
{minibarCheckedLocal ? 'Минибар проверен ✓' : 'Подтвердить проверку минибара'}
</button>
</div>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Plus, Trash2, Pencil, Check, X, Loader2, ShoppingCart } from 'lucide-react' import { Plus, Trash2, Pencil, Check, X, Loader2, ShoppingCart, ShieldAlert, ToggleLeft, ToggleRight } from 'lucide-react'
import { api, type MinibarItem } from '../lib/api' import { api, type MinibarItem } from '../lib/api'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { cn } from '../lib/utils'
interface EditRow { interface EditRow {
name: string name: string
@@ -60,15 +61,32 @@ export function MinibarSettingsPage() {
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false)
const [addForm, setAddForm] = useState<EditRow>({ name: '', price: '', category: '' }) const [addForm, setAddForm] = useState<EditRow>({ name: '', price: '', category: '' })
const [addingRow, setAddingRow] = useState(false) const [addingRow, setAddingRow] = useState(false)
const [requireMinibarCheck, setRequireMinibarCheck] = useState(false)
useEffect(() => { useEffect(() => {
if (!slug) return if (!slug) return
api.minibar.listItems(slug) Promise.all([
.then(setItems) api.minibar.listItems(slug),
api.checklists.getHousekeepingRules(slug).catch(() => ({ requireMinibarCheck: false })),
])
.then(([itemList, rules]) => {
setItems(itemList)
setRequireMinibarCheck(rules.requireMinibarCheck)
})
.catch(() => setError('Не удалось загрузить позиции минибара')) .catch(() => setError('Не удалось загрузить позиции минибара'))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [slug]) }, [slug])
const toggleRequireMinibar = async () => {
const next = !requireMinibarCheck
setRequireMinibarCheck(next)
try {
await api.checklists.updateHousekeepingRules(slug, { requireMinibarCheck: next })
} catch {
setRequireMinibarCheck(!next) // revert
}
}
const handleAdd = async (v: EditRow) => { const handleAdd = async (v: EditRow) => {
if (!v.name.trim()) return if (!v.name.trim()) return
setAddingRow(true) setAddingRow(true)
@@ -242,6 +260,39 @@ export function MinibarSettingsPage() {
)} )}
</div> </div>
</div> </div>
{/* Require minibar check setting */}
<div className="border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden">
<div className="px-6 py-4 bg-slate-50 dark:bg-slate-800/50">
<h2 className="font-semibold text-slate-800 dark:text-slate-200">Контроль уборки</h2>
</div>
<div className="p-6 bg-white dark:bg-slate-900">
<div
className={cn(
'flex items-center justify-between p-4 rounded-xl border-2 cursor-pointer select-none transition-colors',
requireMinibarCheck
? 'border-amber-400 bg-amber-50 dark:bg-amber-900/10'
: 'border-slate-200 dark:border-slate-700 hover:border-slate-300',
)}
onClick={toggleRequireMinibar}
>
<div className="flex items-center gap-3">
<ShieldAlert size={18} className={requireMinibarCheck ? 'text-amber-500' : 'text-slate-400'} />
<div>
<p className={cn('font-medium text-sm', requireMinibarCheck ? 'text-amber-700 dark:text-amber-400' : 'text-slate-700 dark:text-slate-300')}>
Требовать проверки минибара перед завершением уборки
</p>
<p className="text-xs text-slate-400 mt-0.5">
Горничная обязана подтвердить проверку минибара (даже если ничего не потреблено)
</p>
</div>
</div>
{requireMinibarCheck
? <ToggleRight size={22} className="text-amber-500 shrink-0" />
: <ToggleLeft size={22} className="text-slate-300 dark:text-slate-600 shrink-0" />}
</div>
</div>
</div>
</div> </div>
) )
} }

View File

@@ -225,6 +225,7 @@ export interface HousekeepingTask {
photos?: string[] photos?: string[]
resolutionNotes?: string resolutionNotes?: string
resolutionPhotos?: string[] resolutionPhotos?: string[]
minibarChecked?: boolean
} }
// ─── API Docs ───────────────────────────────────────────────────────────────── // ─── API Docs ─────────────────────────────────────────────────────────────────