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

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