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