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:
3
backend/migrations/026_task_photos.sql
Normal file
3
backend/migrations/026_task_photos.sql
Normal 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 '{}';
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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
|
||||
@@ -23,13 +23,11 @@ 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' },
|
||||
]
|
||||
|
||||
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>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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` },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -62,12 +62,16 @@ export function HousekeepingPage() {
|
||||
|
||||
const handleWsMessage = useCallback((msg: WsMessage) => {
|
||||
if (msg.type === 'housekeeping_task_created') {
|
||||
const task = msg.task as unknown as HousekeepingTask
|
||||
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
|
||||
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))
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useHotelSocket({ slug, token: session?.token ?? null, onMessage: handleWsMessage })
|
||||
|
||||
@@ -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,18 +157,18 @@ 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 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>
|
||||
@@ -165,7 +192,6 @@ export function TechnicalPage() {
|
||||
</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)}
|
||||
@@ -184,6 +210,34 @@ export function TechnicalPage() {
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user