feat: photos for tech tasks, 3 priorities, remove cleaning status from context menu

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 10:38:51 +03:00
parent 4b337a3b85
commit e51d776b3a
10 changed files with 155 additions and 73 deletions

View File

@@ -0,0 +1,3 @@
-- Add photos array to housekeeping_tasks
ALTER TABLE housekeeping_tasks
ADD COLUMN IF NOT EXISTS photos TEXT[] NOT NULL DEFAULT '{}';

View File

@@ -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

View File

@@ -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']

View File

@@ -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: <Sparkles size={14} />, color: 'text-emerald-600 dark:text-emerald-400' },
{ status: 'inspect', label: 'Проверить', icon: <ClipboardList size={14} />, color: 'text-amber-600 dark:text-amber-400' },
{ status: 'cleaning', label: 'Убирается', icon: <ClipboardList size={14} />, color: 'text-blue-500 dark:text-blue-400' },
{ status: 'dirty', label: 'Убрать', icon: <ClipboardList size={14} />, color: 'text-red-500 dark:text-red-400' },
{ status: 'clean', label: 'Чисто', icon: <Sparkles size={14} />, color: 'text-emerald-600 dark:text-emerald-400' },
{ status: 'inspect', label: 'Проверить', icon: <ClipboardList size={14} />, color: 'text-amber-600 dark:text-amber-400' },
{ status: 'dirty', label: 'Убрать', icon: <ClipboardList size={14} />, 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 && <span className="ml-auto text-[10px] text-slate-400"></span>}
{(item.status === 'dirty' || item.status === 'cleaning') && <ChevronRight size={12} className="ml-auto text-slate-400 opacity-50" />}
{item.status === 'dirty' && <ChevronRight size={12} className="ml-auto text-slate-400 opacity-50" />}
</button>
))
)}

View File

@@ -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` },

View File

@@ -362,7 +362,7 @@ export const api = {
// ── File Upload ───────────────────────────────────────────────────────────
upload: {
photo: async (file: File, folder: 'categories' | 'rooms' | 'hotels' | 'guests' = 'rooms'): Promise<string> => {
photo: async (file: File, folder: 'categories' | 'rooms' | 'hotels' | 'guests' | 'tasks' = 'rooms'): Promise<string> => {
const token = getToken()
const fd = new FormData()
fd.append('file', file)
@@ -555,7 +555,7 @@ function toBookingPayload(b: Partial<BookingPayload>): Record<string, unknown> {
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 {

View File

@@ -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,

View File

@@ -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))
}
}
}, [])

View File

@@ -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<string, { label: string; color: string }> = {
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<HousekeepingTask[]>([])
const [filterStatus, setFilterStatus] = useState<string>('active')
const [showModal, setShowModal] = useState(false)
const [uploadingFor, setUploadingFor] = useState<string | null>(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<string, unknown>).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<string, unknown>).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() {
</div>
{/* Task list */}
<div className="space-y-2">
<div className="space-y-3">
{filtered.length === 0 ? (
<div className="card p-10 text-center">
<Wrench size={32} className="mx-auto text-slate-300 dark:text-slate-600 mb-3" />
@@ -130,59 +157,86 @@ export function TechnicalPage() {
</div>
) : filtered.map(task => {
const taskRecord = task as unknown as Record<string, string>
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 (
<div
key={task.id}
className="card p-4 flex items-start gap-3"
>
<div className={cn('mt-0.5 shrink-0', sMeta.color)}>
<SIcon size={18} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start gap-2 flex-wrap">
<span className="text-sm font-medium text-slate-900 dark:text-slate-100 flex-1">
{task.notes ?? task.type ?? 'Задача'}
</span>
<span className={cn('text-[11px] font-medium px-1.5 py-0.5 rounded-md shrink-0', pMeta.color)}>
{pMeta.label}
</span>
<div key={task.id} className="card p-4 space-y-3">
<div className="flex items-start gap-3">
<div className={cn('mt-0.5 shrink-0', sMeta.color)}>
<SIcon size={18} />
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-slate-500 dark:text-slate-400">
<span>{roomLabel}</span>
{task.dueDate && (
<span>{format(new Date(task.dueDate), 'd MMM', { locale: ru })}</span>
)}
{taskRecord.assigneeName && (
<span> {taskRecord.assigneeName}</span>
)}
<div className="flex-1 min-w-0">
<div className="flex items-start gap-2 flex-wrap">
<span className="text-sm font-medium text-slate-900 dark:text-slate-100 flex-1">
{task.notes ?? task.type ?? 'Задача'}
</span>
<span className={cn('text-[11px] font-medium px-1.5 py-0.5 rounded-md shrink-0', pMeta.color)}>
{pMeta.label}
</span>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-slate-500 dark:text-slate-400">
<span>{roomLabel}</span>
{task.dueDate && (
<span>{format(new Date(task.dueDate), 'd MMM', { locale: ru })}</span>
)}
{taskRecord.assigneeName && (
<span> {taskRecord.assigneeName}</span>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<select
value={task.status ?? 'pending'}
onChange={e => handleStatusChange(task.id, e.target.value)}
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>
<option value="in_progress">В работе</option>
<option value="done">Выполнено</option>
<option value="cancelled">Отменено</option>
</select>
<button
onClick={() => handleDelete(task.id)}
className="p-1.5 rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<Trash2 size={14} />
</button>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{/* Status dropdown */}
<select
value={task.status ?? 'pending'}
onChange={e => handleStatusChange(task.id, e.target.value)}
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>
<option value="in_progress">В работе</option>
<option value="done">Выполнено</option>
<option value="cancelled">Отменено</option>
</select>
<button
onClick={() => handleDelete(task.id)}
className="p-1.5 rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<Trash2 size={14} />
</button>
</div>
{/* Photos */}
{(photos.length > 0 || true) && (
<div className="flex flex-wrap gap-2 items-center">
{photos.map(url => (
<div key={url} className="relative group">
<a href={url} target="_blank" rel="noreferrer">
<img
src={url}
alt=""
className="w-16 h-16 object-cover rounded-lg border border-slate-200 dark:border-slate-600 hover:opacity-90 transition-opacity"
/>
</a>
<button
onClick={() => handlePhotoRemove(task.id, url)}
className="absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-red-500 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<XIcon size={10} />
</button>
</div>
))}
<PhotoUploadButton
isUploading={isUploading}
onFile={file => handlePhotoUpload(task.id, file)}
/>
</div>
)}
</div>
)
})}
@@ -202,6 +256,29 @@ export function TechnicalPage() {
)
}
function PhotoUploadButton({ isUploading, onFile }: { isUploading: boolean; onFile: (f: File) => void }) {
const ref = useRef<HTMLInputElement>(null)
return (
<>
<input
ref={ref}
type="file"
accept="image/*"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) { onFile(f); e.target.value = '' } }}
/>
<button
onClick={() => ref.current?.click()}
disabled={isUploading}
className="w-16 h-16 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 disabled:opacity-50"
>
{isUploading ? <Loader2 size={16} className="animate-spin" /> : <Camera size={16} />}
<span className="text-[10px]">{isUploading ? '' : 'Фото'}</span>
</button>
</>
)
}
function TaskCreateModal({
slug, onClose, onCreated,
}: {
@@ -264,7 +341,6 @@ function TaskCreateModal({
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Приоритет</label>
<select className="input" value={form.priority} onChange={e => setForm(p => ({ ...p, priority: e.target.value }))}>
<option value="urgent">Срочно</option>
<option value="high">Высокий</option>
<option value="medium">Средний</option>
<option value="low">Низкий</option>
</select>

View File

@@ -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 ─────────────────────────────────────────────────────────────────