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

@@ -32,6 +32,8 @@ interface BookingCalendarProps {
onMaintenanceTaskCreate?: (roomId: string, description: string, priority: HkPriority, photos: string[]) => void
/** roomId → assignee name, for tooltip on "убирается" */
cleaningAssignees?: Record<string, string>
/** Set of room IDs that have active maintenance tasks */
activeMaintenance?: Set<string>
fadingBookingIds?: Set<string>
rentalObjects?: RentalObject[]
rentalBookings?: RentalBooking[]
@@ -65,7 +67,7 @@ function getRoomTypeColor(type: string): string {
return map[type] ?? 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300'
}
export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBookingUpdate, onBookingBulkUpdate, onRoomUpdate, onMaintenanceTaskCreate, cleaningAssignees = {}, fadingBookingIds, rentalObjects, rentalBookings, onRentalBookingCreate, locks = new Map(), onDraftStart, onDraftCancel, wsConnected, priceOverrides }: BookingCalendarProps) {
export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBookingUpdate, onBookingBulkUpdate, onRoomUpdate, onMaintenanceTaskCreate, cleaningAssignees = {}, activeMaintenance = new Set(), fadingBookingIds, rentalObjects, rentalBookings, onRentalBookingCreate, locks = new Map(), onDraftStart, onDraftCancel, wsConnected, priceOverrides }: BookingCalendarProps) {
const [startDate, setStartDate] = useState(() => startOfDay(new Date()))
const [visibleDays, setVisibleDays] = useState(DAYS_VISIBLE)
@@ -521,6 +523,9 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
{room.status === 'blocked' && (
<span className="text-[10px] text-slate-400 font-medium leading-none">🔒 закрыт</span>
)}
{activeMaintenance.has(room.id) && room.status !== 'maintenance' && (
<span className="text-[10px] text-red-500 dark:text-red-400 font-medium leading-none" title="Есть активная тех. задача">🔧 неиспр.</span>
)}
{room.status !== 'maintenance' && room.status !== 'blocked' &&
(room.housekeepingStatus === 'dirty' || room.housekeepingStatus === 'cleaning') && (
<span

View File

@@ -812,13 +812,16 @@ function RoomHistoryTab({ slug, roomId }: { slug: string; roomId: string }) {
</h4>
<div className="space-y-2">
{grouped[day].map(task => {
const taskAny = task as unknown as Record<string, string>
const taskAny = task as unknown as Record<string, string | string[]>
const isMaintenance = task.type === 'maintenance'
const isExpanded = expandedTask === task.id
const photos = task.photos ?? []
const assigneeName = task.assignedToName ?? taskAny.assigneeName ?? taskAny.assignee_name
const startedAt = task.startedAt ?? taskAny.started_at
const completedAt = task.completedAt ?? taskAny.completed_at
const problemPhotos = task.photos ?? []
const resolutionPhotos = task.resolutionPhotos ?? (taskAny.resolution_photos as string[] | undefined) ?? []
const resolutionNotes = task.resolutionNotes ?? taskAny.resolution_notes as string | undefined
const allPhotoCount = problemPhotos.length + resolutionPhotos.length
const assigneeName = task.assignedToName ?? taskAny.assigneeName as string ?? taskAny.assignee_name as string
const startedAt = task.startedAt ?? taskAny.started_at as string
const completedAt = task.completedAt ?? taskAny.completed_at as string
const startTime = startedAt
? new Date(startedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
@@ -859,9 +862,9 @@ function RoomHistoryTab({ slug, roomId }: { slug: string; roomId: string }) {
<span className={cn('text-[11px] font-medium px-1.5 py-0.5 rounded-md', STATUS_COLORS[task.status ?? 'pending'])}>
{STATUS_LABELS[task.status ?? 'pending']}
</span>
{photos.length > 0 && (
{allPhotoCount > 0 && (
<span className="text-[10px] text-slate-400 dark:text-slate-500">
📷 {photos.length}
📷 {allPhotoCount}
</span>
)}
</div>
@@ -895,59 +898,77 @@ function RoomHistoryTab({ slug, roomId }: { slug: string; roomId: string }) {
{/* Expanded details */}
{isExpanded && (
<div className="px-3 pb-3 border-t border-slate-100 dark:border-slate-700 space-y-3 pt-3">
{task.notes && (
<div>
<p className="text-[11px] font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1">
{isMaintenance ? 'Описание проблемы' : 'Замечания'}
</p>
<div className="border-t border-slate-100 dark:border-slate-700 space-y-0">
{/* Problem section */}
<div className="p-3 space-y-2">
<p className="text-[10px] font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wide flex items-center gap-1">
<AlertTriangle size={10} className="text-orange-400" />
{isMaintenance ? 'Задача / проблема' : 'Замечания'}
</p>
{task.notes ? (
<p className="text-sm text-slate-700 dark:text-slate-300 bg-slate-50 dark:bg-slate-700/40 rounded-lg px-2.5 py-2">
{task.notes}
</p>
</div>
)}
{photos.length > 0 && (
<div>
<p className="text-[11px] font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-2">
{isMaintenance ? 'Фото (проблема / решение)' : 'Фото'}
</p>
<div className="flex flex-wrap gap-2">
{photos.map((url, i) => (
<button
key={i}
onClick={() => setLightboxPhoto(url)}
className="relative group"
>
<img
src={url}
alt=""
className="w-20 h-20 object-cover rounded-lg border border-slate-200 dark:border-slate-600 hover:opacity-80 transition-opacity"
/>
<div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center">
<AlertTriangle size={0} />
</div>
) : (
<p className="text-xs text-slate-400 italic">Описание не указано</p>
)}
{problemPhotos.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{problemPhotos.map((url, i) => (
<button key={i} onClick={() => setLightboxPhoto(url)}>
<img src={url} alt="" className="w-16 h-16 object-cover rounded-lg border border-slate-200 dark:border-slate-600 hover:opacity-80 transition-opacity" />
</button>
))}
</div>
)}
</div>
{/* Resolution section — for maintenance tasks */}
{isMaintenance && (
<div className="p-3 space-y-2 border-t border-slate-100 dark:border-slate-700 bg-emerald-50/50 dark:bg-emerald-900/10">
<p className="text-[10px] font-semibold text-emerald-600 dark:text-emerald-400 uppercase tracking-wide flex items-center gap-1">
<CheckCircle2 size={10} />
Решение
{completedAt && (
<span className="ml-auto font-normal normal-case text-emerald-500 dark:text-emerald-400">
{format(new Date(completedAt), 'd MMM HH:mm', { locale: ru })}
</span>
)}
</p>
{resolutionNotes ? (
<p className="text-sm text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-700/40 rounded-lg px-2.5 py-2">
{resolutionNotes}
</p>
) : (
<p className="text-xs text-slate-400 italic">Комментарий не оставлен</p>
)}
{resolutionPhotos.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{resolutionPhotos.map((url, i) => (
<button key={i} onClick={() => setLightboxPhoto(url)}>
<img src={url} alt="" className="w-16 h-16 object-cover rounded-lg border border-emerald-200 dark:border-emerald-700 hover:opacity-80 transition-opacity" />
</button>
))}
</div>
)}
</div>
)}
{/* Timing details */}
{/* Timing */}
{(startedAt || completedAt) && (
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="grid grid-cols-2 gap-2 p-3 pt-0">
{startedAt && (
<div className="bg-slate-50 dark:bg-slate-700/40 rounded-lg px-2.5 py-2">
<p className="text-slate-400 dark:text-slate-500 text-[10px] uppercase tracking-wide">Начало</p>
<p className="text-slate-700 dark:text-slate-300 font-medium mt-0.5">
<p className="text-slate-700 dark:text-slate-300 font-medium text-xs mt-0.5">
{format(new Date(startedAt), 'd MMM HH:mm', { locale: ru })}
</p>
</div>
)}
{completedAt && (
{completedAt && !isMaintenance && (
<div className="bg-emerald-50 dark:bg-emerald-900/20 rounded-lg px-2.5 py-2">
<p className="text-emerald-600 dark:text-emerald-400 text-[10px] uppercase tracking-wide">Завершено</p>
<p className="text-slate-700 dark:text-slate-300 font-medium mt-0.5">
<p className="text-slate-700 dark:text-slate-300 font-medium text-xs mt-0.5">
{format(new Date(completedAt), 'd MMM HH:mm', { locale: ru })}
</p>
</div>