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:
2
backend/migrations/027_hk_require_photo.sql
Normal file
2
backend/migrations/027_hk_require_photo.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE housekeeping_settings
|
||||
ADD COLUMN IF NOT EXISTS require_completion_photo BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
Создать задачу
|
||||
|
||||
@@ -562,6 +562,7 @@ export interface HkSettings {
|
||||
checkout_auto: boolean
|
||||
checkout_priority: string
|
||||
inspection_after_clean: boolean
|
||||
require_completion_photo?: boolean
|
||||
}
|
||||
|
||||
export interface HotelPayload {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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' :
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user