From e51d776b3adfaad16597d7afb09f7257ff407d5d Mon Sep 17 00:00:00 2001 From: HotelSync Date: Tue, 24 Mar 2026 10:38:51 +0300 Subject: [PATCH] feat: photos for tech tasks, 3 priorities, remove cleaning status from context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove 'Убирается' (cleaning) from manually selectable HK statuses in calendar context menu — it now only shows automatically when a task is in_progress - Reduce priorities from 4 to 3: remove 'high', keep urgent/medium/low across context menu, tech tasks modal, and HousekeepingTask type - Add photo upload to technical tasks: upload via camera button per task, display thumbnails with hover-to-remove, stored as TEXT[] in DB (migration 026) - Fix HousekeepingPage WS handler to filter by category — no longer adds maintenance tasks to housekeeping board - Add 'tasks' folder support to upload route Co-Authored-By: Claude Sonnet 4.6 --- backend/migrations/026_task_photos.sql | 3 + backend/src/routes/housekeeping.ts | 2 +- backend/src/routes/upload.ts | 2 +- src/components/rooms/RoomContextMenu.tsx | 14 +- src/data/mockData.ts | 6 +- src/lib/api.ts | 4 +- src/pages/CalendarPage.tsx | 2 +- src/pages/HousekeepingPage.tsx | 12 +- src/pages/TechnicalPage.tsx | 180 ++++++++++++++++------- src/types/index.ts | 3 +- 10 files changed, 155 insertions(+), 73 deletions(-) create mode 100644 backend/migrations/026_task_photos.sql diff --git a/backend/migrations/026_task_photos.sql b/backend/migrations/026_task_photos.sql new file mode 100644 index 0000000..9ea5dae --- /dev/null +++ b/backend/migrations/026_task_photos.sql @@ -0,0 +1,3 @@ +-- Add photos array to housekeeping_tasks +ALTER TABLE housekeeping_tasks + ADD COLUMN IF NOT EXISTS photos TEXT[] NOT NULL DEFAULT '{}'; diff --git a/backend/src/routes/housekeeping.ts b/backend/src/routes/housekeeping.ts index 45327f3..ab41146 100644 --- a/backend/src/routes/housekeeping.ts +++ b/backend/src/routes/housekeeping.ts @@ -92,7 +92,7 @@ const housekeeping: FastifyPluginAsync = async (fastify) => { const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) - const allowed = ['room_id','type','status','priority','assignee_id','notes','due_date','category'] + const allowed = ['room_id','type','status','priority','assignee_id','notes','due_date','category','photos'] const updates: string[] = [] const values: unknown[] = [] let idx = 1 diff --git a/backend/src/routes/upload.ts b/backend/src/routes/upload.ts index 8a23527..be6ca7f 100644 --- a/backend/src/routes/upload.ts +++ b/backend/src/routes/upload.ts @@ -7,7 +7,7 @@ import { pipeline } from 'stream/promises' const UPLOADS_DIR = process.env.UPLOADS_DIR ?? join(process.cwd(), 'uploads') const CDN_HOST = process.env.CDN_HOST ?? 'cdn.hotelsync.ru' -const ALLOWED_FOLDERS = ['categories', 'rooms', 'hotels', 'guests'] as const +const ALLOWED_FOLDERS = ['categories', 'rooms', 'hotels', 'guests', 'tasks'] as const type UploadFolder = typeof ALLOWED_FOLDERS[number] const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] diff --git a/src/components/rooms/RoomContextMenu.tsx b/src/components/rooms/RoomContextMenu.tsx index 0afcc2c..59b4e26 100644 --- a/src/components/rooms/RoomContextMenu.tsx +++ b/src/components/rooms/RoomContextMenu.tsx @@ -7,7 +7,7 @@ import { cn } from '../../lib/utils' const today = () => format(new Date(), 'yyyy-MM-dd') -export type HkPriority = 'urgent' | 'high' | 'medium' | 'low' +export type HkPriority = 'urgent' | 'medium' | 'low' interface RoomContextMenuProps { room: Room @@ -21,15 +21,13 @@ interface RoomContextMenuProps { } const HK_ITEMS: { status: HousekeepingStatus; label: string; icon: React.ReactNode; color: string }[] = [ - { status: 'clean', label: 'Чисто', icon: , color: 'text-emerald-600 dark:text-emerald-400' }, - { status: 'inspect', label: 'Проверить', icon: , color: 'text-amber-600 dark:text-amber-400' }, - { status: 'cleaning', label: 'Убирается', icon: , color: 'text-blue-500 dark:text-blue-400' }, - { status: 'dirty', label: 'Убрать', icon: , color: 'text-red-500 dark:text-red-400' }, + { status: 'clean', label: 'Чисто', icon: , color: 'text-emerald-600 dark:text-emerald-400' }, + { status: 'inspect', label: 'Проверить', icon: , color: 'text-amber-600 dark:text-amber-400' }, + { status: 'dirty', label: 'Убрать', icon: , color: 'text-red-500 dark:text-red-400' }, ] const PRIORITY_ITEMS: { value: HkPriority; label: string; color: string }[] = [ { value: 'urgent', label: 'Срочно', color: 'text-red-600 dark:text-red-400' }, - { value: 'high', label: 'Высокий', color: 'text-orange-600 dark:text-orange-400' }, { value: 'medium', label: 'Средний', color: 'text-yellow-600 dark:text-yellow-400' }, { value: 'low', label: 'Низкий', color: 'text-slate-500 dark:text-slate-400' }, ] @@ -83,7 +81,7 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu } const handleHkClick = (status: HousekeepingStatus) => { - if (status === 'dirty' || status === 'cleaning') { + if (status === 'dirty') { // Show priority sub-form setPendingHkStatus(status) setSubForm('hk_priority') @@ -234,7 +232,7 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu {item.icon} {item.label} {room.housekeepingStatus === item.status && } - {(item.status === 'dirty' || item.status === 'cleaning') && } + {item.status === 'dirty' && } )) )} diff --git a/src/data/mockData.ts b/src/data/mockData.ts index 46c3a7f..a8da767 100644 --- a/src/data/mockData.ts +++ b/src/data/mockData.ts @@ -227,12 +227,12 @@ export const MOCK_CHANNELS: Channel[] = [ // ─── Housekeeping ────────────────────────────────────────────────────────────── export const MOCK_HK_TASKS: HousekeepingTask[] = [ - { id: 'hk1', hotelId: 'hotel-1', roomId: 'r1', roomNumber: '101', assignedToId: 'user-housekeeper-1', assignedToName: 'Мария Иванова', priority: 'high', status: 'in_progress', type: 'cleaning', dueDate: TODAY, notes: 'Выезд гостя в 12:00, срочная уборка' }, - { id: 'hk2', hotelId: 'hotel-1', roomId: 'r4', roomNumber: '201', assignedToId: 'user-housekeeper-1', assignedToName: 'Мария Иванова', priority: 'high', status: 'pending', type: 'cleaning', dueDate: TODAY }, + { id: 'hk1', hotelId: 'hotel-1', roomId: 'r1', roomNumber: '101', assignedToId: 'user-housekeeper-1', assignedToName: 'Мария Иванова', priority: 'urgent', status: 'in_progress', type: 'cleaning', dueDate: TODAY, notes: 'Выезд гостя в 12:00, срочная уборка' }, + { id: 'hk2', hotelId: 'hotel-1', roomId: 'r4', roomNumber: '201', assignedToId: 'user-housekeeper-1', assignedToName: 'Мария Иванова', priority: 'urgent', status: 'pending', type: 'cleaning', dueDate: TODAY }, { id: 'hk3', hotelId: 'hotel-1', roomId: 'r7', roomNumber: '301', assignedToId: 'user-housekeeper-1', assignedToName: 'Мария Иванова', priority: 'medium', status: 'pending', type: 'cleaning', dueDate: TODAY }, { id: 'hk4', hotelId: 'hotel-1', roomId: 'r12', roomNumber: '102A', assignedToId: 'user-housekeeper-1', assignedToName: 'Мария Иванова', priority: 'medium', status: 'pending', type: 'cleaning', dueDate: TODAY }, { id: 'hk5', hotelId: 'hotel-1', roomId: 'r5', roomNumber: '202', priority: 'medium', status: 'pending', type: 'inspection', dueDate: TODAY, notes: 'Проверка после ремонта кондиционера' }, - { id: 'hk6', hotelId: 'hotel-1', roomId: 'r6', roomNumber: '203', priority: 'high', status: 'pending', type: 'maintenance', dueDate: TODAY, notes: 'Замена сантехники, номер закрыт' }, + { id: 'hk6', hotelId: 'hotel-1', roomId: 'r6', roomNumber: '203', priority: 'urgent', status: 'pending', type: 'maintenance', dueDate: TODAY, notes: 'Замена сантехники, номер закрыт' }, { id: 'hk7', hotelId: 'hotel-1', roomId: 'r2', roomNumber: '102', priority: 'low', status: 'done', type: 'cleaning', dueDate: TODAY, completedAt: `${TODAY}T09:30:00` }, { id: 'hk8', hotelId: 'hotel-1', roomId: 'r3', roomNumber: '103', priority: 'low', status: 'done', type: 'cleaning', dueDate: TODAY, completedAt: `${TODAY}T10:15:00` }, { id: 'hk9', hotelId: 'hotel-1', roomId: 'r8', roomNumber: '302', priority: 'medium', status: 'done', type: 'cleaning', dueDate: TODAY, completedAt: `${TODAY}T08:45:00` }, diff --git a/src/lib/api.ts b/src/lib/api.ts index ee687ef..b46b7eb 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -362,7 +362,7 @@ export const api = { // ── File Upload ─────────────────────────────────────────────────────────── upload: { - photo: async (file: File, folder: 'categories' | 'rooms' | 'hotels' | 'guests' = 'rooms'): Promise => { + photo: async (file: File, folder: 'categories' | 'rooms' | 'hotels' | 'guests' | 'tasks' = 'rooms'): Promise => { const token = getToken() const fd = new FormData() fd.append('file', file) @@ -555,7 +555,7 @@ function toBookingPayload(b: Partial): Record { export interface HkPayload { room_id?: string; type?: string; priority?: string status?: string; assignee_id?: string; notes?: string; due_date?: string - category?: string + category?: string; photos?: string[] } export interface HkSettings { diff --git a/src/pages/CalendarPage.tsx b/src/pages/CalendarPage.tsx index cb5661e..a196509 100644 --- a/src/pages/CalendarPage.tsx +++ b/src/pages/CalendarPage.tsx @@ -158,7 +158,7 @@ export function CalendarPage() { try { const updated = await api.rooms.update(slug, roomId, patch) setRooms(prev => prev.map(r => r.id === updated.id ? updated : r)) - if (patch.housekeepingStatus === 'dirty' || patch.housekeepingStatus === 'cleaning') { + if (patch.housekeepingStatus === 'dirty') { const today = new Date().toISOString().slice(0, 10) const task = await api.housekeeping.create(slug, { room_id: roomId, diff --git a/src/pages/HousekeepingPage.tsx b/src/pages/HousekeepingPage.tsx index 716491e..62f120a 100644 --- a/src/pages/HousekeepingPage.tsx +++ b/src/pages/HousekeepingPage.tsx @@ -62,11 +62,15 @@ export function HousekeepingPage() { const handleWsMessage = useCallback((msg: WsMessage) => { if (msg.type === 'housekeeping_task_created') { - const task = msg.task as unknown as HousekeepingTask - setTasks(prev => prev.some(t => t.id === task.id) ? prev : [task, ...prev]) + const task = msg.task as unknown as HousekeepingTask & { category?: string } + if (task.category !== 'maintenance') { + setTasks(prev => prev.some(t => t.id === task.id) ? prev : [task, ...prev]) + } } else if (msg.type === 'housekeeping_updated') { - const task = msg.task as unknown as HousekeepingTask - setTasks(prev => prev.map(t => t.id === task.id ? task : t)) + const task = msg.task as unknown as HousekeepingTask & { category?: string } + if (task.category !== 'maintenance') { + setTasks(prev => prev.map(t => t.id === task.id ? task : t)) + } } }, []) diff --git a/src/pages/TechnicalPage.tsx b/src/pages/TechnicalPage.tsx index 99ab7ed..dde2dc6 100644 --- a/src/pages/TechnicalPage.tsx +++ b/src/pages/TechnicalPage.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect, useCallback } from 'react' -import { Plus, Wrench, Clock, CheckCircle2, AlertTriangle, Trash2 } from 'lucide-react' +import { useState, useEffect, useCallback, useRef } from 'react' +import { Plus, Wrench, Clock, CheckCircle2, AlertTriangle, Trash2, Camera, X as XIcon, Loader2 } from 'lucide-react' import { format } from 'date-fns' import { ru } from 'date-fns/locale' import { api } from '../lib/api' @@ -12,7 +12,6 @@ import { Modal } from '../components/ui/Modal' const PRIORITY_LABELS: Record = { urgent: { label: 'Срочно', color: 'text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400 border border-red-200 dark:border-red-800' }, - high: { label: 'Высокий', color: 'text-orange-600 bg-orange-50 dark:bg-orange-900/20 dark:text-orange-400 border border-orange-200 dark:border-orange-800' }, medium: { label: 'Средний', color: 'text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 dark:text-yellow-400 border border-yellow-200 dark:border-yellow-800' }, low: { label: 'Низкий', color: 'text-slate-500 bg-slate-100 dark:bg-slate-700 dark:text-slate-400 border border-slate-200 dark:border-slate-600' }, } @@ -31,6 +30,7 @@ export function TechnicalPage() { const [tasks, setTasks] = useState([]) const [filterStatus, setFilterStatus] = useState('active') const [showModal, setShowModal] = useState(false) + const [uploadingFor, setUploadingFor] = useState(null) const load = useCallback(() => { if (!slug) return @@ -43,13 +43,13 @@ export function TechnicalPage() { const handleWsMessage = useCallback((msg: WsMessage) => { if (msg.type === 'housekeeping_task_created') { - const task = msg.task as unknown as HousekeepingTask - if ((task as unknown as Record).category === 'maintenance') { + const task = msg.task as unknown as HousekeepingTask & { category?: string } + if (task.category === 'maintenance') { setTasks(prev => prev.some(t => t.id === task.id) ? prev : [task, ...prev]) } } else if (msg.type === 'housekeeping_updated') { - const task = msg.task as unknown as HousekeepingTask - if ((task as unknown as Record).category === 'maintenance') { + const task = msg.task as unknown as HousekeepingTask & { category?: string } + if (task.category === 'maintenance') { setTasks(prev => prev.map(t => t.id === task.id ? task : t)) } } @@ -76,6 +76,33 @@ export function TechnicalPage() { } } + const handlePhotoUpload = async (taskId: string, file: File) => { + setUploadingFor(taskId) + try { + const url = await api.upload.photo(file, 'tasks') + const task = tasks.find(t => t.id === taskId) + const currentPhotos = task?.photos ?? [] + const updated = await api.housekeeping.update(slug, taskId, { photos: [...currentPhotos, url] }) + setTasks(prev => prev.map(t => t.id === taskId ? updated : t)) + } catch (err) { + console.error('Photo upload failed:', err) + } finally { + setUploadingFor(null) + } + } + + const handlePhotoRemove = async (taskId: string, photoUrl: string) => { + const task = tasks.find(t => t.id === taskId) + if (!task) return + const newPhotos = (task.photos ?? []).filter(p => p !== photoUrl) + try { + const updated = await api.housekeeping.update(slug, taskId, { photos: newPhotos }) + setTasks(prev => prev.map(t => t.id === taskId ? updated : t)) + } catch (err) { + console.error(err) + } + } + const filtered = tasks.filter(t => { const s = t.status as string if (filterStatus === 'active') return s !== 'done' && s !== 'cancelled' @@ -122,7 +149,7 @@ export function TechnicalPage() { {/* Task list */} -
+
{filtered.length === 0 ? (
@@ -130,59 +157,86 @@ export function TechnicalPage() {
) : filtered.map(task => { const taskRecord = task as unknown as Record - const pMeta = PRIORITY_LABELS[taskRecord.priority ?? 'medium'] + const pMeta = PRIORITY_LABELS[taskRecord.priority ?? 'medium'] ?? PRIORITY_LABELS.medium const sMeta = STATUS_LABELS[task.status ?? 'pending'] const SIcon = sMeta.icon const roomLabel = taskRecord.roomNumber ? `Номер ${taskRecord.roomNumber}` : 'Без номера' + const photos = task.photos ?? [] + const isUploading = uploadingFor === task.id return ( -
-
- -
-
-
- - {task.notes ?? task.type ?? 'Задача'} - - - {pMeta.label} - +
+
+
+
-
- {roomLabel} - {task.dueDate && ( - {format(new Date(task.dueDate), 'd MMM', { locale: ru })} - )} - {taskRecord.assigneeName && ( - → {taskRecord.assigneeName} - )} +
+
+ + {task.notes ?? task.type ?? 'Задача'} + + + {pMeta.label} + +
+
+ {roomLabel} + {task.dueDate && ( + {format(new Date(task.dueDate), 'd MMM', { locale: ru })} + )} + {taskRecord.assigneeName && ( + → {taskRecord.assigneeName} + )} +
+
+
+ +
-
- {/* Status dropdown */} - - -
+ + {/* Photos */} + {(photos.length > 0 || true) && ( +
+ {photos.map(url => ( +
+ + + + +
+ ))} + handlePhotoUpload(task.id, file)} + /> +
+ )}
) })} @@ -202,6 +256,29 @@ export function TechnicalPage() { ) } +function PhotoUploadButton({ isUploading, onFile }: { isUploading: boolean; onFile: (f: File) => void }) { + const ref = useRef(null) + return ( + <> + { const f = e.target.files?.[0]; if (f) { onFile(f); e.target.value = '' } }} + /> + + + ) +} + function TaskCreateModal({ slug, onClose, onCreated, }: { @@ -264,7 +341,6 @@ function TaskCreateModal({ diff --git a/src/types/index.ts b/src/types/index.ts index e681976..3e9d00c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -204,7 +204,7 @@ export interface HousekeepingTask { roomNumber: string assignedToId?: string assignedToName?: string - priority: 'low' | 'medium' | 'high' | 'urgent' + priority: 'low' | 'medium' | 'urgent' status: 'pending' | 'in_progress' | 'done' notes?: string maintenanceNote?: string @@ -213,6 +213,7 @@ export interface HousekeepingTask { dueDate: string completedAt?: string type: 'cleaning' | 'inspection' | 'maintenance' + photos?: string[] } // ─── API Docs ─────────────────────────────────────────────────────────────────