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)
|
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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user