feat: cleaning tooltip, report photos, fix tech task creation, completion photos

- Calendar: hover tooltip on '🧹 убирается' shows who is cleaning (assignee name)
  fetched from active HK tasks on mount, updated via WS housekeeping_updated events
- HousekeepingPage: 'Сообщить о неисправности' now creates a real maintenance task
  in the DB (category=maintenance) + sends WS so TechnicalPage receives it instantly;
  photo upload added to the report form
- RoomContextMenu: photo upload added to tech task creation form (camera button,
  preview thumbnails); photos passed through to API on task creation
- TechnicalPage: selecting 'Выполнено' now opens CompletionModal with photo upload;
  technician can attach completion photos before confirming; if require_completion_photo
  setting is on — at least one photo is required
- Settings (HousekeepingPage plans tab): new toggle 'Обязательное фото при завершении
  тех. задачи' saved to housekeeping_settings.require_completion_photo (migration 027)
- Backend: housekeeping-settings updated with require_completion_photo field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 11:07:28 +03:00
parent 66009d345f
commit 2ebef940b9
8 changed files with 310 additions and 37 deletions

View File

@@ -0,0 +1,2 @@
ALTER TABLE housekeeping_settings
ADD COLUMN IF NOT EXISTS require_completion_photo BOOLEAN NOT NULL DEFAULT false;

View File

@@ -7,17 +7,19 @@ export interface HkSettings {
checkout_auto: boolean
checkout_priority: string
inspection_after_clean: boolean
require_completion_photo: boolean
}
const DEFAULT_SETTINGS: HkSettings = {
checkout_auto: true,
checkout_priority: 'high',
inspection_after_clean: true,
require_completion_photo: false,
}
export async function getHkSettings(hotelId: string): Promise<HkSettings> {
const { rows } = await db.query(
'SELECT checkout_auto, checkout_priority, inspection_after_clean FROM housekeeping_settings WHERE hotel_id = $1',
'SELECT checkout_auto, checkout_priority, inspection_after_clean, require_completion_photo FROM housekeeping_settings WHERE hotel_id = $1',
[hotelId],
)
return rows[0] ?? DEFAULT_SETTINGS
@@ -57,20 +59,22 @@ const housekeepingSettings: FastifyPluginAsync = async (fastify) => {
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { checkout_auto, checkout_priority, inspection_after_clean } = request.body
const { checkout_auto, checkout_priority, inspection_after_clean, require_completion_photo } = request.body
await db.query(
`INSERT INTO housekeeping_settings (hotel_id, checkout_auto, checkout_priority, inspection_after_clean)
VALUES ($1, $2, $3, $4)
`INSERT INTO housekeeping_settings (hotel_id, checkout_auto, checkout_priority, inspection_after_clean, require_completion_photo)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (hotel_id) DO UPDATE SET
checkout_auto = COALESCE(EXCLUDED.checkout_auto, housekeeping_settings.checkout_auto),
checkout_priority = COALESCE(EXCLUDED.checkout_priority, housekeeping_settings.checkout_priority),
inspection_after_clean = COALESCE(EXCLUDED.inspection_after_clean, housekeeping_settings.inspection_after_clean),
updated_at = NOW()`,
checkout_auto = COALESCE(EXCLUDED.checkout_auto, housekeeping_settings.checkout_auto),
checkout_priority = COALESCE(EXCLUDED.checkout_priority, housekeeping_settings.checkout_priority),
inspection_after_clean = COALESCE(EXCLUDED.inspection_after_clean, housekeeping_settings.inspection_after_clean),
require_completion_photo = COALESCE(EXCLUDED.require_completion_photo, housekeeping_settings.require_completion_photo),
updated_at = NOW()`,
[
hotelId,
checkout_auto ?? DEFAULT_SETTINGS.checkout_auto,
checkout_priority ?? DEFAULT_SETTINGS.checkout_priority,
inspection_after_clean ?? DEFAULT_SETTINGS.inspection_after_clean,
checkout_auto ?? DEFAULT_SETTINGS.checkout_auto,
checkout_priority ?? DEFAULT_SETTINGS.checkout_priority,
inspection_after_clean ?? DEFAULT_SETTINGS.inspection_after_clean,
require_completion_photo ?? DEFAULT_SETTINGS.require_completion_photo,
],
)
return getHkSettings(hotelId)

View File

@@ -29,7 +29,9 @@ interface BookingCalendarProps {
onBookingUpdate: (id: string, b: Partial<Booking>) => void
onBookingBulkUpdate?: (updates: Array<{ id: string; data: Partial<Booking> }>) => void
onRoomUpdate?: (roomId: string, patch: Partial<Room>, priority?: HkPriority) => void
onMaintenanceTaskCreate?: (roomId: string, description: string, priority: HkPriority) => void
onMaintenanceTaskCreate?: (roomId: string, description: string, priority: HkPriority, photos: string[]) => void
/** roomId → assignee name, for tooltip on "убирается" */
cleaningAssignees?: Record<string, string>
fadingBookingIds?: Set<string>
rentalObjects?: RentalObject[]
rentalBookings?: RentalBooking[]
@@ -63,7 +65,7 @@ function getRoomTypeColor(type: string): string {
return map[type] ?? 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300'
}
export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBookingUpdate, onBookingBulkUpdate, onRoomUpdate, onMaintenanceTaskCreate, fadingBookingIds, rentalObjects, rentalBookings, onRentalBookingCreate, locks = new Map(), onDraftStart, onDraftCancel, wsConnected, priceOverrides }: BookingCalendarProps) {
export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBookingUpdate, onBookingBulkUpdate, onRoomUpdate, onMaintenanceTaskCreate, cleaningAssignees = {}, fadingBookingIds, rentalObjects, rentalBookings, onRentalBookingCreate, locks = new Map(), onDraftStart, onDraftCancel, wsConnected, priceOverrides }: BookingCalendarProps) {
const [startDate, setStartDate] = useState(() => startOfDay(new Date()))
const [visibleDays, setVisibleDays] = useState(DAYS_VISIBLE)
@@ -88,8 +90,8 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
onRoomUpdate?.(roomId, { housekeepingStatus: status }, priority)
}
const handleCtxMaintenanceTask = (roomId: string, description: string, priority: HkPriority) => {
onMaintenanceTaskCreate?.(roomId, description, priority)
const handleCtxMaintenanceTask = (roomId: string, description: string, priority: HkPriority, photos: string[]) => {
onMaintenanceTaskCreate?.(roomId, description, priority, photos)
}
// Cell hover price tooltip
@@ -521,7 +523,12 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
)}
{room.status !== 'maintenance' && room.status !== 'blocked' &&
(room.housekeepingStatus === 'dirty' || room.housekeepingStatus === 'cleaning') && (
<span className="text-[10px] text-red-500 dark:text-red-400 font-medium leading-none">
<span
className="text-[10px] text-red-500 dark:text-red-400 font-medium leading-none cursor-default"
title={room.housekeepingStatus === 'cleaning' && cleaningAssignees[room.id]
? `Убирает: ${cleaningAssignees[room.id]}`
: undefined}
>
{room.housekeepingStatus === 'dirty' ? '🧹 грязный' : '🧹 убирается'}
</span>
)}

View File

@@ -1,9 +1,10 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Wrench, Ban, CheckCircle, Sparkles, ClipboardList, Pencil, ChevronRight, X as XIcon, CalendarDays, AlertTriangle, Zap } from 'lucide-react'
import { Wrench, Ban, CheckCircle, Sparkles, ClipboardList, Pencil, ChevronRight, X as XIcon, CalendarDays, AlertTriangle, Zap, Camera, Loader2 } from 'lucide-react'
import { format } from 'date-fns'
import type { Room, RoomStatus, HousekeepingStatus } from '../../types'
import { cn } from '../../lib/utils'
import { api } from '../../lib/api'
const today = () => format(new Date(), 'yyyy-MM-dd')
@@ -16,7 +17,7 @@ interface RoomContextMenuProps {
onClose: () => void
onStatusChange: (roomId: string, status: RoomStatus, maintenanceFrom?: string | null, maintenanceTo?: string | null) => void
onHkStatusChange: (roomId: string, status: HousekeepingStatus, priority?: HkPriority) => void
onMaintenanceTask?: (roomId: string, description: string, priority: HkPriority) => void
onMaintenanceTask?: (roomId: string, description: string, priority: HkPriority, photos: string[]) => void
onEdit?: (room: Room) => void
}
@@ -43,6 +44,9 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu
const [pendingHkStatus, setPendingHkStatus] = useState<HousekeepingStatus | null>(null)
const [techDesc, setTechDesc] = useState('')
const [techPriority, setTechPriority] = useState<HkPriority>('medium')
const [techPhotos, setTechPhotos] = useState<string[]>([])
const [photoUploading, setPhotoUploading] = useState(false)
const photoInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
const handler = (e: MouseEvent) => {
@@ -98,9 +102,19 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu
onClose()
}
const handleTechPhotoUpload = async (file: File) => {
setPhotoUploading(true)
try {
const url = await api.upload.photo(file, 'tasks')
setTechPhotos(prev => [...prev, url])
} catch { /* ignore */ } finally {
setPhotoUploading(false)
}
}
const handleTechSubmit = () => {
if (onMaintenanceTask && techDesc.trim()) {
onMaintenanceTask(room.id, techDesc.trim(), techPriority)
onMaintenanceTask(room.id, techDesc.trim(), techPriority, techPhotos)
}
onClose()
}
@@ -274,10 +288,38 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu
))}
</div>
</div>
{/* Photo upload */}
<div>
<input
ref={photoInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) { handleTechPhotoUpload(f); e.target.value = '' } }}
/>
<div className="flex flex-wrap gap-1 mb-1">
{techPhotos.map((url, i) => (
<div key={i} className="relative group">
<img src={url} alt="" className="w-10 h-10 object-cover rounded border border-slate-200 dark:border-slate-600" />
<button
onClick={() => setTechPhotos(prev => prev.filter((_, j) => j !== i))}
className="absolute -top-1 -right-1 w-3.5 h-3.5 rounded-full bg-red-500 text-white flex items-center justify-center opacity-0 group-hover:opacity-100"
><XIcon size={8} /></button>
</div>
))}
<button
onClick={() => photoInputRef.current?.click()}
disabled={photoUploading}
className="w-10 h-10 rounded border-2 border-dashed border-slate-300 dark:border-slate-600 flex items-center justify-center text-slate-400 hover:border-brand-400 hover:text-brand-500 transition-colors"
>
{photoUploading ? <Loader2 size={12} className="animate-spin" /> : <Camera size={12} />}
</button>
</div>
</div>
<div className="flex gap-1.5 pt-0.5">
<button
onClick={handleTechSubmit}
disabled={!techDesc.trim()}
disabled={!techDesc.trim() || photoUploading}
className="flex-1 py-1 rounded-lg text-xs font-medium bg-brand-600 text-white hover:bg-brand-700 transition-colors disabled:opacity-40"
>
Создать задачу

View File

@@ -562,6 +562,7 @@ export interface HkSettings {
checkout_auto: boolean
checkout_priority: string
inspection_after_clean: boolean
require_completion_photo?: boolean
}
export interface HotelPayload {

View File

@@ -25,6 +25,8 @@ export function CalendarPage() {
const [rentalBookings, setRentalBookings] = useState<RentalBookingApi[]>([])
const [locks, setLocks] = useState<Map<string, BookingLock>>(new Map())
const [priceOverrides, setPriceOverrides] = useState<Record<string, Record<string, number>>>({})
// roomId → assigneeName for "убирается" tooltip
const [cleaningAssignees, setCleaningAssignees] = useState<Record<string, string>>({})
useEffect(() => {
if (!slug) return
@@ -52,6 +54,20 @@ export function CalendarPage() {
setRentalBookings(rb as RentalBookingApi[])
}
}).catch(console.error)
// Fetch active HK tasks to build "who is cleaning" tooltip map
api.housekeeping.list(slug, { status: 'active', category: 'housekeeping' })
.then(tasks => {
const assignees: Record<string, string> = {}
for (const t of tasks) {
const tAny = t as unknown as Record<string, string>
if (tAny.status === 'in_progress' && tAny.roomId && tAny.assigneeName) {
assignees[tAny.roomId] = tAny.assigneeName
}
}
setCleaningAssignees(assignees)
})
.catch(() => {})
}, [slug, isRentalActive])
const handleWsMessage = useCallback((msg: WsMessage) => {
@@ -70,6 +86,19 @@ export function CalendarPage() {
setRooms(prev => prev.map(r =>
r.id === msg.roomId ? { ...r, housekeepingStatus: msg.roomStatus as Room['housekeepingStatus'] } : r
))
// If room is no longer being cleaned, remove from tooltip map
if (msg.roomStatus !== 'cleaning') {
setCleaningAssignees(prev => { const n = { ...prev }; delete n[msg.roomId]; return n })
}
} else if (msg.type === 'housekeeping_updated') {
const t = msg.task as Record<string, string>
if (t.roomId) {
if (t.status === 'in_progress' && t.assigneeName) {
setCleaningAssignees(prev => ({ ...prev, [t.roomId]: t.assigneeName }))
} else if (t.status !== 'in_progress') {
setCleaningAssignees(prev => { const n = { ...prev }; delete n[t.roomId]; return n })
}
}
}
}, [])
@@ -174,7 +203,7 @@ export function CalendarPage() {
}
}, [slug, send])
const handleMaintenanceTaskCreate = useCallback(async (roomId: string, description: string, priority: HkPriority) => {
const handleMaintenanceTaskCreate = useCallback(async (roomId: string, description: string, priority: HkPriority, photos: string[] = []) => {
try {
const today = new Date().toISOString().slice(0, 10)
const task = await api.housekeeping.create(slug, {
@@ -184,6 +213,7 @@ export function CalendarPage() {
notes: description,
due_date: today,
category: 'maintenance',
photos,
})
send({ type: 'housekeeping_task_created', task: task as unknown as Record<string, unknown> })
} catch (err) {
@@ -227,6 +257,7 @@ export function CalendarPage() {
onBookingBulkUpdate={handleBulkUpdate}
onRoomUpdate={handleRoomUpdate}
onMaintenanceTaskCreate={handleMaintenanceTaskCreate}
cleaningAssignees={cleaningAssignees}
fadingBookingIds={fadingBookings}
rentalObjects={isRentalActive ? rentalObjects as unknown as import('../data/rentalData').RentalObject[] : undefined}
rentalBookings={isRentalActive ? rentalBookings as unknown as RentalBooking[] : undefined}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'
import { CheckCircle2, Clock, Sparkles, User, ListChecks, Settings2, Save, Wrench, X as XIcon, Send, AlertTriangle, BanIcon, Loader2, History, ChevronLeft, ChevronRight } from 'lucide-react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { CheckCircle2, Clock, Sparkles, User, ListChecks, Settings2, Save, Wrench, X as XIcon, Send, AlertTriangle, BanIcon, Loader2, History, ChevronLeft, ChevronRight, Camera } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { useHotelSocket } from '../hooks/useHotelSocket'
import type { WsMessage } from '../hooks/useHotelSocket'
@@ -84,7 +84,7 @@ export function HousekeepingPage() {
}
}, [])
useHotelSocket({ slug, token: session?.token ?? null, onMessage: handleWsMessage })
const { send } = useHotelSocket({ slug, token: session?.token ?? null, onMessage: handleWsMessage })
// Load active tasks (all pending/in_progress — no date filter)
useEffect(() => {
@@ -99,6 +99,7 @@ export function HousekeepingPage() {
checkoutAuto: s.checkout_auto,
checkoutPriority: s.checkout_priority as 'high' | 'medium',
inspectionAfterClean: s.inspection_after_clean,
requireCompletionPhoto: s.require_completion_photo ?? false,
}))
}).catch(console.error)
.finally(() => setLoading(false))
@@ -120,7 +121,7 @@ export function HousekeepingPage() {
checkoutInspection: true, dailyEnabled: true, dailyIntervalDays: 1,
dailyStartFromDay: 1, onDemandEnabled: true, onDemandPriority: 'medium' as 'high' | 'medium' | 'low',
deepCleanEnabled: false, deepCleanEveryDays: 7,
inspectionAfterClean: true, autoAssign: false,
inspectionAfterClean: true, autoAssign: false, requireCompletionPhoto: false,
})
const setP = <K extends keyof typeof plans>(k: K, v: typeof plans[K]) =>
@@ -134,6 +135,7 @@ export function HousekeepingPage() {
checkout_auto: plans.checkoutAuto,
checkout_priority: plans.checkoutPriority,
inspection_after_clean: plans.inspectionAfterClean,
require_completion_photo: plans.requireCompletionPhoto,
}
await api.housekeeping.saveSettings(slug, settings)
setPlanSaved(true)
@@ -163,20 +165,32 @@ export function HousekeepingPage() {
const { addNotification } = useNotifications()
const addMaintenanceReport = (id: string, note: string, severity: 'low' | 'medium' | 'high') => {
const addMaintenanceReport = async (id: string, note: string, severity: 'low' | 'medium' | 'high', photos: string[]) => {
const task = activeTasks.find(t => t.id === id)
const roomBlocked = severity === 'high'
setActiveTasks(prev => prev.map(t =>
t.id === id ? { ...t, maintenanceNote: note, maintenanceSeverity: severity, roomBlocked } : t,
))
const tAny = task as unknown as Record<string, string>
const priority = severity === 'high' ? 'urgent' : severity === 'medium' ? 'medium' : 'low'
try {
const newTask = await api.housekeeping.create(slug, {
room_id: tAny?.roomId || undefined,
type: 'maintenance',
priority,
notes: note,
due_date: format(new Date(), 'yyyy-MM-dd'),
category: 'maintenance',
photos,
})
send({ type: 'housekeeping_task_created', task: newTask as unknown as Record<string, unknown> })
} catch (err) {
console.error('Failed to create maintenance task:', err)
}
const severityLabel = severity === 'high' ? 'Экстренно' : severity === 'medium' ? 'Средняя срочность' : 'Не срочно'
addNotification({
type: 'maintenance',
title: severity === 'high'
? `⚠️ Экстренная поломка — Номер ${task?.roomNumber}`
: `Поломка в номере ${task?.roomNumber}`,
body: `[${severityLabel}] ${note}${roomBlocked ? ' · Номер закрыт для бронирования.' : ''}`,
link: '/housekeeping',
body: `[${severityLabel}] ${note}`,
link: '/technical',
})
}
@@ -260,7 +274,7 @@ export function HousekeepingPage() {
</div>
<div className="space-y-2.5">
{colTasks.map(task => (
<TaskCard key={task.id} task={task} onStatusChange={updateStatus} onReport={addMaintenanceReport} />
<TaskCard key={task.id} task={task} onStatusChange={updateStatus} onReport={(id, note, sev, photos) => addMaintenanceReport(id, note, sev, photos)} />
))}
{colTasks.length === 0 && (
<div className="rounded-xl border-2 border-dashed border-slate-200 dark:border-slate-700 py-8 text-center">
@@ -486,6 +500,16 @@ export function HousekeepingPage() {
</div>
</div>
<div className="card p-4">
<div className="flex items-center justify-between">
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100">Обязательное фото при завершении тех. задачи</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Технический специалист должен прикрепить фото перед тем как отметить задачу выполненной</p>
</div>
<Toggle on={plans.requireCompletionPhoto} onChange={() => setP('requireCompletionPhoto', !plans.requireCompletionPhoto)} />
</div>
</div>
<div className="flex justify-end">
<button onClick={savePlans} disabled={planSaving} className="btn-primary">
{planSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
@@ -507,16 +531,30 @@ const SEVERITY_CONFIG = {
function TaskCard({ task, onStatusChange, onReport }: {
task: HousekeepingTask
onStatusChange: (id: string, status: HousekeepingTask['status']) => void
onReport: (id: string, note: string, severity: 'low' | 'medium' | 'high') => void
onReport: (id: string, note: string, severity: 'low' | 'medium' | 'high', photos: string[]) => void
}) {
const [reportOpen, setReportOpen] = useState(false)
const [reportText, setReportText] = useState('')
const [severity, setSeverity] = useState<'low' | 'medium' | 'high'>('medium')
const [reportPhotos, setReportPhotos] = useState<string[]>([])
const [photoUploading, setPhotoUploading] = useState(false)
const photoInputRef = useRef<HTMLInputElement>(null)
const uploadReportPhoto = async (file: File) => {
setPhotoUploading(true)
try {
const url = await api.upload.photo(file, 'tasks')
setReportPhotos(prev => [...prev, url])
} catch { /* ignore */ } finally {
setPhotoUploading(false)
}
}
const submitReport = () => {
if (!reportText.trim()) return
onReport(task.id, reportText.trim(), severity)
onReport(task.id, reportText.trim(), severity, reportPhotos)
setReportText('')
setReportPhotos([])
setReportOpen(false)
}
@@ -623,9 +661,36 @@ function TaskCard({ task, onStatusChange, onReport }: {
onChange={e => setReportText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && e.ctrlKey) submitReport() }}
/>
{/* Photo upload */}
<input
ref={photoInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) { uploadReportPhoto(f); e.target.value = '' } }}
/>
<div className="flex flex-wrap gap-1.5 items-center">
{reportPhotos.map((url, i) => (
<div key={i} className="relative group">
<img src={url} alt="" className="w-12 h-12 object-cover rounded border border-slate-200 dark:border-slate-600" />
<button
onClick={() => setReportPhotos(prev => prev.filter((_, j) => j !== i))}
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-red-500 text-white flex items-center justify-center opacity-0 group-hover:opacity-100"
><XIcon size={9} /></button>
</div>
))}
<button
onClick={() => photoInputRef.current?.click()}
disabled={photoUploading}
className="w-12 h-12 rounded border-2 border-dashed border-slate-300 dark:border-slate-600 flex flex-col items-center justify-center gap-0.5 text-slate-400 hover:border-orange-400 hover:text-orange-500 transition-colors text-[10px]"
>
{photoUploading ? <Loader2 size={13} className="animate-spin" /> : <Camera size={13} />}
{!photoUploading && 'Фото'}
</button>
</div>
<button
onClick={submitReport}
disabled={!reportText.trim()}
disabled={!reportText.trim() || photoUploading}
className={cn(
'w-full flex items-center justify-center gap-1 text-xs py-1.5 rounded-lg text-white font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed',
severity === 'high' ? 'bg-red-600 hover:bg-red-700' :

View File

@@ -31,6 +31,8 @@ export function TechnicalPage() {
const [filterStatus, setFilterStatus] = useState<string>('active')
const [showModal, setShowModal] = useState(false)
const [uploadingFor, setUploadingFor] = useState<string | null>(null)
const [completingTask, setCompletingTask] = useState<string | null>(null)
const [requirePhoto, setRequirePhoto] = useState(false)
const load = useCallback(() => {
if (!slug) return
@@ -41,6 +43,13 @@ export function TechnicalPage() {
useEffect(() => { load() }, [load])
useEffect(() => {
if (!slug) return
api.housekeeping.getSettings(slug)
.then(s => setRequirePhoto(s.require_completion_photo ?? false))
.catch(() => {})
}, [slug])
const handleWsMessage = useCallback((msg: WsMessage) => {
if (msg.type === 'housekeeping_task_created') {
const task = msg.task as unknown as HousekeepingTask & { category?: string }
@@ -194,7 +203,11 @@ export function TechnicalPage() {
<div className="flex items-center gap-1 shrink-0">
<select
value={task.status ?? 'pending'}
onChange={e => handleStatusChange(task.id, e.target.value)}
onChange={e => {
const v = e.target.value
if (v === 'done') { setCompletingTask(task.id) }
else { handleStatusChange(task.id, v) }
}}
className="text-xs border border-slate-200 dark:border-slate-600 rounded-lg px-2 py-1 bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400"
>
<option value="pending">Ожидает</option>
@@ -252,6 +265,24 @@ export function TechnicalPage() {
}}
/>
)}
{completingTask && (
<CompletionModal
requirePhoto={requirePhoto}
onClose={() => setCompletingTask(null)}
onConfirm={async (completionPhotos) => {
const task = tasks.find(t => t.id === completingTask)
const currentPhotos = task?.photos ?? []
const allPhotos = [...currentPhotos, ...completionPhotos]
const updated = await api.housekeeping.update(slug, completingTask, {
status: 'done',
photos: allPhotos,
})
setTasks(prev => prev.map(t => t.id === completingTask ? updated : t))
setCompletingTask(null)
}}
/>
)}
</div>
)
}
@@ -374,3 +405,93 @@ function TaskCreateModal({
</Modal>
)
}
function CompletionModal({
requirePhoto,
onClose,
onConfirm,
}: {
requirePhoto: boolean
onClose: () => void
onConfirm: (photos: string[]) => Promise<void>
}) {
const [photos, setPhotos] = useState<string[]>([])
const [uploading, setUploading] = useState(false)
const [saving, setSaving] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const uploadPhoto = async (file: File) => {
setUploading(true)
try {
const url = await api.upload.photo(file, 'tasks')
setPhotos(prev => [...prev, url])
} catch { /* ignore */ } finally {
setUploading(false)
}
}
const handleConfirm = async () => {
if (requirePhoto && photos.length === 0) return
setSaving(true)
try { await onConfirm(photos) } catch { /* ignore */ } finally { setSaving(false) }
}
return (
<Modal open onClose={onClose} title="Завершение задачи">
<div className="space-y-4 p-4">
<p className="text-sm text-slate-600 dark:text-slate-400">
{requirePhoto
? 'Прикрепите фото выполненной работы — это обязательно по настройкам отеля.'
: 'Вы можете прикрепить фото выполненной работы (необязательно).'}
</p>
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) { uploadPhoto(f); e.target.value = '' } }}
/>
<div className="flex flex-wrap gap-2 items-center">
{photos.map((url, i) => (
<div key={i} className="relative group">
<a href={url} target="_blank" rel="noreferrer">
<img src={url} alt="" className="w-20 h-20 object-cover rounded-lg border border-slate-200 dark:border-slate-600" />
</a>
<button
onClick={() => setPhotos(prev => prev.filter((_, j) => j !== i))}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center opacity-0 group-hover:opacity-100"
><XIcon size={11} /></button>
</div>
))}
<button
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="w-20 h-20 rounded-lg border-2 border-dashed border-slate-300 dark:border-slate-600 flex flex-col items-center justify-center gap-1 text-slate-400 hover:border-brand-400 hover:text-brand-500 transition-colors"
>
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Camera size={18} />}
<span className="text-[10px]">{uploading ? '' : 'Добавить'}</span>
</button>
</div>
{requirePhoto && photos.length === 0 && (
<p className="text-xs text-red-500 flex items-center gap-1">
<AlertTriangle size={12} /> Необходимо прикрепить хотя бы одно фото
</p>
)}
<div className="flex gap-2 justify-end pt-1">
<button onClick={onClose} className="btn-secondary">Отмена</button>
<button
onClick={handleConfirm}
disabled={saving || uploading || (requirePhoto && photos.length === 0)}
className="btn-primary"
>
{saving ? 'Сохранение...' : 'Завершить задачу'}
</button>
</div>
</div>
</Modal>
)
}