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:
13
backend/migrations/060_require_completion.sql
Normal file
13
backend/migrations/060_require_completion.sql
Normal 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;
|
||||
@@ -83,7 +83,7 @@ const checklists: FastifyPluginAsync = async (fastify) => {
|
||||
const hotelId = await getHotelId(slug)
|
||||
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 values: unknown[] = []
|
||||
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
|
||||
|
||||
@@ -159,7 +159,67 @@ const housekeeping: FastifyPluginAsync = async (fastify) => {
|
||||
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' })
|
||||
|
||||
// ── 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)
|
||||
|
||||
const { rows: updated } = await db.query(
|
||||
|
||||
@@ -7,8 +7,10 @@ const BASE = (import.meta.env.VITE_API_URL as string | undefined) ?? 'https://ap
|
||||
// ── Errors ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
errors?: string[]
|
||||
constructor(public status: number, message: string, errors?: string[]) {
|
||||
super(message)
|
||||
this.errors = errors
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,8 +117,10 @@ async function req<T>(
|
||||
const data: unknown = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = (data as Record<string, string>)?.error ?? 'Request failed'
|
||||
throw new ApiError(res.status, msg)
|
||||
const d = data as Record<string, unknown>
|
||||
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
|
||||
@@ -652,12 +656,13 @@ export const api = {
|
||||
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}`, {
|
||||
name: data.name,
|
||||
task_type: data.taskType,
|
||||
is_active: data.isActive,
|
||||
sort_order: data.sortOrder,
|
||||
require_before_complete: data.requireBeforeComplete,
|
||||
}),
|
||||
|
||||
deleteTemplate: (slug: string, templateId: string) =>
|
||||
@@ -686,6 +691,14 @@ export const api = {
|
||||
|
||||
uncompleteItem: (slug: string, taskId: string, itemId: string) =>
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
@@ -842,6 +855,7 @@ export interface HkPayload {
|
||||
status?: string; assignee_id?: string; notes?: string; due_date?: string
|
||||
category?: string; photos?: string[]
|
||||
resolution_notes?: string; resolution_photos?: string[]
|
||||
minibar_checked?: boolean
|
||||
}
|
||||
|
||||
export interface HkSettings {
|
||||
@@ -1280,6 +1294,7 @@ export interface ChecklistTemplate {
|
||||
name: string
|
||||
taskType: string | null
|
||||
isActive: boolean
|
||||
requireBeforeComplete: boolean
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
items: ChecklistItem[]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useAuth } from '../contexts/AuthContext'
|
||||
import { cn } from '../lib/utils'
|
||||
@@ -97,6 +97,13 @@ export function ChecklistSettingsPage() {
|
||||
} 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) => {
|
||||
if (!confirm('Удалить шаблон вместе со всеми пунктами?')) return
|
||||
try {
|
||||
@@ -323,6 +330,30 @@ export function ChecklistSettingsPage() {
|
||||
Добавить
|
||||
</button>
|
||||
</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>
|
||||
))}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { TaskChecklist, MinibarItem, MinibarConsumption } from '../lib/api'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useHotelSocket } 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 { HousekeepingTask } from '../types'
|
||||
import { cn } from '../lib/utils'
|
||||
@@ -199,6 +199,7 @@ export function HousekeepingPage() {
|
||||
setActiveTasks(prev => prev.map(t => t.id === id ? updated : t))
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 422) throw err
|
||||
console.error('Failed to update task:', err)
|
||||
}
|
||||
}
|
||||
@@ -626,7 +627,7 @@ const SEVERITY_CONFIG = {
|
||||
function TaskCard({ task, onStatusChange, onReport, slug }: {
|
||||
task: HousekeepingTask
|
||||
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
|
||||
}) {
|
||||
const [reportOpen, setReportOpen] = useState(false)
|
||||
@@ -637,6 +638,9 @@ function TaskCard({ task, onStatusChange, onReport, slug }: {
|
||||
const photoInputRef = useRef<HTMLInputElement>(null)
|
||||
const [completingOpen, setCompletingOpen] = useState(false)
|
||||
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
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
@@ -719,6 +723,30 @@ function TaskCard({ task, onStatusChange, onReport, slug }: {
|
||||
} 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) => {
|
||||
setPhotoUploading(true)
|
||||
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">
|
||||
<CheckCircle2 size={11} className="text-emerald-500" /> Завершение уборки
|
||||
</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} />
|
||||
</button>
|
||||
</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
|
||||
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"
|
||||
@@ -899,21 +936,15 @@ function TaskCard({ task, onStatusChange, onReport, slug }: {
|
||||
placeholder="Комментарий (необязательно)..."
|
||||
value={completionComment}
|
||||
onChange={e => setCompletionComment(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && e.ctrlKey) {
|
||||
onStatusChange(task.id, 'done', completionComment.trim() || undefined)
|
||||
setCompletingOpen(false)
|
||||
}
|
||||
}}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && e.ctrlKey) handleComplete() }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
onStatusChange(task.id, 'done', completionComment.trim() || undefined)
|
||||
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"
|
||||
onClick={handleComplete}
|
||||
disabled={completionSaving}
|
||||
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"
|
||||
>
|
||||
<CheckCircle2 size={11} /> Завершить уборку
|
||||
{completionSaving ? <Loader2 size={11} className="animate-spin" /> : <CheckCircle2 size={11} />}
|
||||
Завершить уборку
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1053,6 +1084,25 @@ function TaskCard({ task, onStatusChange, onReport, slug }: {
|
||||
))}
|
||||
</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>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 { useAuth } from '../contexts/AuthContext'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
interface EditRow {
|
||||
name: string
|
||||
@@ -60,15 +61,32 @@ export function MinibarSettingsPage() {
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [addForm, setAddForm] = useState<EditRow>({ name: '', price: '', category: '' })
|
||||
const [addingRow, setAddingRow] = useState(false)
|
||||
const [requireMinibarCheck, setRequireMinibarCheck] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return
|
||||
api.minibar.listItems(slug)
|
||||
.then(setItems)
|
||||
Promise.all([
|
||||
api.minibar.listItems(slug),
|
||||
api.checklists.getHousekeepingRules(slug).catch(() => ({ requireMinibarCheck: false })),
|
||||
])
|
||||
.then(([itemList, rules]) => {
|
||||
setItems(itemList)
|
||||
setRequireMinibarCheck(rules.requireMinibarCheck)
|
||||
})
|
||||
.catch(() => setError('Не удалось загрузить позиции минибара'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [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) => {
|
||||
if (!v.name.trim()) return
|
||||
setAddingRow(true)
|
||||
@@ -242,6 +260,39 @@ export function MinibarSettingsPage() {
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -225,6 +225,7 @@ export interface HousekeepingTask {
|
||||
photos?: string[]
|
||||
resolutionNotes?: string
|
||||
resolutionPhotos?: string[]
|
||||
minibarChecked?: boolean
|
||||
}
|
||||
|
||||
// ─── API Docs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user