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:
@@ -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,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))
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user