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