feat: task problem/solution split + maintenance icons on rooms/calendar

- TechnicalPage: redesign task cards with separate "Задача" and "Решение" sections
  - "Задача": problem description + problem photos (editable before done)
  - "Решение": technician resolution comment + resolution photos (green section)
  - Resolution comment in CompletionModal now saves to resolution_notes field
  - Resolution photos save to resolution_photos (no longer mixed with problem photos)
- RoomModal history tab: maintenance entries show separate problem/resolution sections
- Backend: add resolution_notes, resolution_photos to PATCH allowed fields
- Migration 029: resolution_notes TEXT + resolution_photos TEXT[] columns
- RoomsPage: load active maintenance tasks, show 🔧 неисправность badge on room cards
- CalendarPage: load active maintenance tasks, show 🔧 неиспр. label in calendar row
- BookingCalendar: activeMaintenance prop, WS handler updates maintenance set on task changes
- CalendarPage WS: handle housekeeping_task_created to update activeMaintenance set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 11:45:24 +03:00
parent 14f0b8e0f2
commit c83a9ca714
10 changed files with 237 additions and 107 deletions

View File

@@ -27,6 +27,8 @@ export function CalendarPage() {
const [priceOverrides, setPriceOverrides] = useState<Record<string, Record<string, number>>>({})
// roomId → assigneeName for "убирается" tooltip
const [cleaningAssignees, setCleaningAssignees] = useState<Record<string, string>>({})
// Set of room IDs with active maintenance tasks
const [activeMaintenance, setActiveMaintenance] = useState<Set<string>>(new Set())
useEffect(() => {
if (!slug) return
@@ -55,6 +57,17 @@ export function CalendarPage() {
}
}).catch(console.error)
// Fetch active maintenance tasks
api.housekeeping.list(slug, { status: 'active', category: 'maintenance' })
.then(tasks => {
const ids = new Set(tasks.map(t => {
const tAny = t as unknown as Record<string, string>
return tAny.roomId ?? tAny.room_id ?? ''
}).filter(Boolean))
setActiveMaintenance(ids)
})
.catch(() => {})
// Fetch active HK tasks to build "who is cleaning" tooltip map
api.housekeeping.list(slug, { status: 'active', category: 'housekeeping' })
.then(tasks => {
@@ -90,8 +103,23 @@ export function CalendarPage() {
if (msg.roomStatus !== 'cleaning') {
setCleaningAssignees(prev => { const n = { ...prev }; delete n[msg.roomId]; return n })
}
} else if (msg.type === 'housekeeping_task_created') {
const t = msg.task as Record<string, string>
const roomId = t.roomId ?? t.room_id
if (t.category === 'maintenance' && roomId) {
setActiveMaintenance(prev => new Set([...prev, roomId]))
}
} else if (msg.type === 'housekeeping_updated') {
const t = msg.task as Record<string, string>
const roomId = t.roomId ?? t.room_id
// Track maintenance tasks
if (t.category === 'maintenance' && roomId) {
if (t.status === 'done' || t.status === 'cancelled') {
setActiveMaintenance(prev => { const n = new Set(prev); n.delete(roomId); return n })
} else {
setActiveMaintenance(prev => new Set([...prev, roomId]))
}
}
if (t.roomId) {
if (t.status === 'in_progress' && t.assigneeName) {
setCleaningAssignees(prev => ({ ...prev, [t.roomId]: t.assigneeName }))
@@ -258,6 +286,7 @@ export function CalendarPage() {
onRoomUpdate={handleRoomUpdate}
onMaintenanceTaskCreate={handleMaintenanceTaskCreate}
cleaningAssignees={cleaningAssignees}
activeMaintenance={activeMaintenance}
fadingBookingIds={fadingBookings}
rentalObjects={isRentalActive ? rentalObjects as unknown as import('../data/rentalData').RentalObject[] : undefined}
rentalBookings={isRentalActive ? rentalBookings as unknown as RentalBooking[] : undefined}

View File

@@ -16,12 +16,14 @@ import { ru } from 'date-fns/locale'
function normalizeTask(raw: Record<string, unknown>): Record<string, unknown> {
return {
...raw,
roomNumber: raw.roomNumber ?? raw.room_number,
roomId: raw.roomId ?? raw.room_id,
assigneeName: raw.assigneeName ?? raw.assignee_name,
dueDate: raw.dueDate ?? raw.due_date,
completedAt: raw.completedAt ?? raw.completed_at,
startedAt: raw.startedAt ?? raw.started_at,
roomNumber: raw.roomNumber ?? raw.room_number,
roomId: raw.roomId ?? raw.room_id,
assigneeName: raw.assigneeName ?? raw.assignee_name,
dueDate: raw.dueDate ?? raw.due_date,
completedAt: raw.completedAt ?? raw.completed_at,
startedAt: raw.startedAt ?? raw.started_at,
resolutionNotes: raw.resolutionNotes ?? raw.resolution_notes,
resolutionPhotos: raw.resolutionPhotos ?? raw.resolution_photos,
}
}

View File

@@ -17,6 +17,7 @@ export function RoomsPage() {
const [rooms, setRooms] = useState<Room[]>([])
const [categories, setCategories] = useState<RoomCategory[]>([])
const [loading, setLoading] = useState(true)
const [activeMaintenance, setActiveMaintenance] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [floorFilter, setFloorFilter] = useState<number | 'all'>('all')
const [modalOpen, setModalOpen] = useState(false)
@@ -38,6 +39,16 @@ export function RoomsPage() {
.then(([r, cats]) => { setRooms(r); setCategories(cats) })
.catch(console.error)
.finally(() => setLoading(false))
api.housekeeping.list(slug, { status: 'active', category: 'maintenance' })
.then(tasks => {
const ids = new Set(tasks.map(t => {
const tAny = t as unknown as Record<string, string>
return tAny.roomId ?? tAny.room_id ?? ''
}).filter(Boolean))
setActiveMaintenance(ids)
})
.catch(() => {})
}, [slug])
const floors = [...new Set(rooms.map(r => r.floor))].sort()
@@ -228,6 +239,7 @@ export function RoomsPage() {
<RoomCard
key={room.id}
room={room}
hasActiveMaintenance={activeMaintenance.has(room.id)}
onEdit={() => openEdit(room)}
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ room, x: e.clientX, y: e.clientY }) }}
/>
@@ -273,7 +285,7 @@ export function RoomsPage() {
)
}
function RoomCard({ room, onEdit, onContextMenu }: { room: Room; onEdit: () => void; onContextMenu: (e: React.MouseEvent) => void }) {
function RoomCard({ room, hasActiveMaintenance, onEdit, onContextMenu }: { room: Room; hasActiveMaintenance?: boolean; onEdit: () => void; onContextMenu: (e: React.MouseEvent) => void }) {
return (
<div
className="card p-4 hover:shadow-card-hover transition-shadow cursor-pointer group relative"
@@ -285,11 +297,18 @@ function RoomCard({ room, onEdit, onContextMenu }: { room: Room; onEdit: () => v
>
<Pencil size={13} />
</button>
{room.status === 'maintenance' && (
<div className="absolute top-3 left-3">
<span className="flex items-center gap-1 text-[10px] font-medium text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/20 px-1.5 py-0.5 rounded-md border border-orange-200 dark:border-orange-800">
<Wrench size={10} />ремонт
</span>
{(room.status === 'maintenance' || hasActiveMaintenance) && (
<div className="absolute top-3 left-3 flex gap-1">
{room.status === 'maintenance' && (
<span className="flex items-center gap-1 text-[10px] font-medium text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/20 px-1.5 py-0.5 rounded-md border border-orange-200 dark:border-orange-800">
<Wrench size={10} />ремонт
</span>
)}
{hasActiveMaintenance && room.status !== 'maintenance' && (
<span title="Есть активная тех. задача" className="flex items-center gap-1 text-[10px] font-medium text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 px-1.5 py-0.5 rounded-md border border-red-200 dark:border-red-800">
<Wrench size={10} />неисправность
</span>
)}
</div>
)}

View File

@@ -13,12 +13,14 @@ import { Modal } from '../components/ui/Modal'
function normalizeTask(raw: Record<string, unknown>): Record<string, unknown> {
return {
...raw,
roomNumber: raw.roomNumber ?? raw.room_number,
roomId: raw.roomId ?? raw.room_id,
assigneeName: raw.assigneeName ?? raw.assignee_name,
dueDate: raw.dueDate ?? raw.due_date,
completedAt: raw.completedAt ?? raw.completed_at,
startedAt: raw.startedAt ?? raw.started_at,
roomNumber: raw.roomNumber ?? raw.room_number,
roomId: raw.roomId ?? raw.room_id,
assigneeName: raw.assigneeName ?? raw.assignee_name,
dueDate: raw.dueDate ?? raw.due_date,
completedAt: raw.completedAt ?? raw.completed_at,
startedAt: raw.startedAt ?? raw.started_at,
resolutionNotes: raw.resolutionNotes ?? raw.resolution_notes,
resolutionPhotos: raw.resolutionPhotos ?? raw.resolution_photos,
}
}
@@ -177,51 +179,57 @@ export function TechnicalPage() {
<p className="text-sm text-slate-500 dark:text-slate-400">Нет технических задач</p>
</div>
) : filtered.map(task => {
const taskRecord = task as unknown as Record<string, string>
const pMeta = PRIORITY_LABELS[taskRecord.priority ?? 'medium'] ?? PRIORITY_LABELS.medium
const taskRecord = task as unknown as Record<string, string | string[]>
const pMeta = PRIORITY_LABELS[String(taskRecord.priority ?? 'medium')] ?? PRIORITY_LABELS.medium
const sMeta = STATUS_LABELS[task.status ?? 'pending']
const SIcon = sMeta.icon
const roomLabel = (taskRecord.roomNumber || taskRecord.room_number)
? `Номер ${taskRecord.roomNumber ?? taskRecord.room_number}`
: 'Без номера'
const photos = task.photos ?? []
const problemPhotos = task.photos ?? []
const resolutionPhotos = task.resolutionPhotos ?? (taskRecord.resolution_photos as string[] | undefined) ?? []
const resolutionNotes = task.resolutionNotes ?? taskRecord.resolution_notes as string | undefined
const isUploading = uploadingFor === task.id
const isDone = task.status === 'done'
const startedAt = task.startedAt ?? (taskRecord as Record<string, string>).started_at
const completedAt = task.completedAt ?? (taskRecord as Record<string, string>).completed_at
const startedAt = task.startedAt ?? taskRecord.started_at as string | undefined
const completedAt = task.completedAt ?? taskRecord.completed_at as string | undefined
const durationMin = startedAt && completedAt
? Math.round((new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 60000)
? Math.round((new Date(completedAt as string).getTime() - new Date(startedAt as string).getTime()) / 60000)
: null
return (
<div key={task.id} className="card p-4 space-y-3">
<div className="flex items-start gap-3">
<div key={task.id} className="card overflow-hidden">
{/* ── Card header ── */}
<div className="flex items-start gap-3 p-4">
<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>
<div className="flex items-center gap-2 flex-wrap">
<span className={cn('text-[11px] font-medium px-1.5 py-0.5 rounded-md shrink-0', pMeta.color)}>
{pMeta.label}
</span>
<span className={cn('text-[11px] font-medium px-1.5 py-0.5 rounded-md shrink-0',
isDone ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300' :
task.status === 'in_progress' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' :
'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300'
)}>
{sMeta.label}
</span>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-slate-500 dark:text-slate-400 flex-wrap">
<span>{roomLabel}</span>
<div className="flex items-center gap-3 mt-1.5 text-xs text-slate-500 dark:text-slate-400 flex-wrap">
<span className="font-medium text-slate-700 dark:text-slate-300">{roomLabel}</span>
{task.dueDate && (
<span>{format(new Date(task.dueDate), 'd MMM', { locale: ru })}</span>
)}
{taskRecord.assigneeName && (
<span> {taskRecord.assigneeName}</span>
<span> {String(taskRecord.assigneeName)}</span>
)}
{durationMin !== null && durationMin > 0 && (
<span className="flex items-center gap-1 text-slate-400">
<Clock size={10} />
{durationMin < 60
? `${durationMin} мин`
: `${Math.floor(durationMin / 60)} ч ${durationMin % 60} мин`}
{durationMin < 60 ? `${durationMin} мин` : `${Math.floor(durationMin / 60)} ч ${durationMin % 60} мин`}
</span>
)}
</div>
@@ -250,30 +258,71 @@ export function TechnicalPage() {
</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>
{/* ── Задача (problem) section ── */}
<div className="mx-4 mb-3 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700">
<AlertTriangle size={13} className="text-orange-500 shrink-0" />
<span className="text-[11px] font-semibold text-slate-600 dark:text-slate-400 uppercase tracking-wide">Задача</span>
</div>
<div className="p-3 space-y-2.5">
{task.notes ? (
<p className="text-sm text-slate-700 dark:text-slate-300">{task.notes}</p>
) : (
<p className="text-xs text-slate-400 dark:text-slate-500 italic">Описание не указано</p>
)}
{(problemPhotos.length > 0 || !isDone) && (
<div className="flex flex-wrap gap-2 items-center">
{problemPhotos.map(url => (
<div key={url} className="relative group">
<a href={url} target="_blank" rel="noreferrer">
<img src={url} alt="" className="w-14 h-14 object-cover rounded-lg border border-slate-200 dark:border-slate-600 hover:opacity-90 transition-opacity" />
</a>
{!isDone && (
<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>
))}
{!isDone && (
<PhotoUploadButton isUploading={isUploading} onFile={file => handlePhotoUpload(task.id, file)} />
)}
</div>
))}
<PhotoUploadButton
isUploading={isUploading}
onFile={file => handlePhotoUpload(task.id, file)}
/>
)}
</div>
</div>
{/* ── Решение (resolution) section ── */}
{(isDone || resolutionNotes || resolutionPhotos.length > 0) && (
<div className="mx-4 mb-4 rounded-xl border border-emerald-200 dark:border-emerald-800/50 overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 bg-emerald-50 dark:bg-emerald-900/20 border-b border-emerald-200 dark:border-emerald-800/50">
<CheckCircle2 size={13} className="text-emerald-600 dark:text-emerald-400 shrink-0" />
<span className="text-[11px] font-semibold text-emerald-700 dark:text-emerald-400 uppercase tracking-wide">Решение</span>
{completedAt && (
<span className="ml-auto text-[10px] text-emerald-600 dark:text-emerald-400">
{format(new Date(completedAt as string), 'd MMM HH:mm', { locale: ru })}
</span>
)}
</div>
<div className="p-3 space-y-2.5">
{resolutionNotes ? (
<p className="text-sm text-slate-700 dark:text-slate-300">{resolutionNotes}</p>
) : (
<p className="text-xs text-slate-400 dark:text-slate-500 italic">Комментарий не оставлен</p>
)}
{resolutionPhotos.length > 0 && (
<div className="flex flex-wrap gap-2">
{resolutionPhotos.map(url => (
<a key={url} href={url} target="_blank" rel="noreferrer">
<img src={url} alt="" className="w-14 h-14 object-cover rounded-lg border border-emerald-200 dark:border-emerald-700 hover:opacity-90 transition-opacity" />
</a>
))}
</div>
)}
</div>
</div>
)}
</div>
@@ -297,11 +346,11 @@ export function TechnicalPage() {
requirePhoto={requirePhoto}
onClose={() => setCompletingTask(null)}
onConfirm={async (completionPhotos, comment) => {
const task = tasks.find(t => t.id === completingTask)
const currentPhotos = task?.photos ?? []
const allPhotos = [...currentPhotos, ...completionPhotos]
const patch: import('../lib/api').HkPayload = { status: 'done', photos: allPhotos }
if (comment.trim()) patch.notes = comment.trim()
const patch: import('../lib/api').HkPayload = {
status: 'done',
resolution_photos: completionPhotos,
}
if (comment.trim()) patch.resolution_notes = comment.trim()
const updated = await api.housekeeping.update(slug, completingTask, patch)
setTasks(prev => prev.map(t => t.id === completingTask ? updated : t))
setCompletingTask(null)