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:
2
backend/migrations/029_task_resolution.sql
Normal file
2
backend/migrations/029_task_resolution.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE housekeeping_tasks ADD COLUMN IF NOT EXISTS resolution_notes TEXT;
|
||||||
|
ALTER TABLE housekeeping_tasks ADD COLUMN IF NOT EXISTS resolution_photos TEXT[] NOT NULL DEFAULT '{}';
|
||||||
@@ -105,7 +105,7 @@ const housekeeping: FastifyPluginAsync = async (fastify) => {
|
|||||||
const hotelId = await getHotelId(slug)
|
const hotelId = await getHotelId(slug)
|
||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||||
|
|
||||||
const allowed = ['room_id','type','status','priority','assignee_id','notes','due_date','category','photos']
|
const allowed = ['room_id','type','status','priority','assignee_id','notes','due_date','category','photos','resolution_notes','resolution_photos']
|
||||||
const updates: string[] = []
|
const updates: string[] = []
|
||||||
const values: unknown[] = []
|
const values: unknown[] = []
|
||||||
let idx = 1
|
let idx = 1
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ interface BookingCalendarProps {
|
|||||||
onMaintenanceTaskCreate?: (roomId: string, description: string, priority: HkPriority, photos: string[]) => void
|
onMaintenanceTaskCreate?: (roomId: string, description: string, priority: HkPriority, photos: string[]) => void
|
||||||
/** roomId → assignee name, for tooltip on "убирается" */
|
/** roomId → assignee name, for tooltip on "убирается" */
|
||||||
cleaningAssignees?: Record<string, string>
|
cleaningAssignees?: Record<string, string>
|
||||||
|
/** Set of room IDs that have active maintenance tasks */
|
||||||
|
activeMaintenance?: Set<string>
|
||||||
fadingBookingIds?: Set<string>
|
fadingBookingIds?: Set<string>
|
||||||
rentalObjects?: RentalObject[]
|
rentalObjects?: RentalObject[]
|
||||||
rentalBookings?: RentalBooking[]
|
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'
|
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 [startDate, setStartDate] = useState(() => startOfDay(new Date()))
|
||||||
const [visibleDays, setVisibleDays] = useState(DAYS_VISIBLE)
|
const [visibleDays, setVisibleDays] = useState(DAYS_VISIBLE)
|
||||||
|
|
||||||
@@ -521,6 +523,9 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
|
|||||||
{room.status === 'blocked' && (
|
{room.status === 'blocked' && (
|
||||||
<span className="text-[10px] text-slate-400 font-medium leading-none">🔒 закрыт</span>
|
<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.status !== 'maintenance' && room.status !== 'blocked' &&
|
||||||
(room.housekeepingStatus === 'dirty' || room.housekeepingStatus === 'cleaning') && (
|
(room.housekeepingStatus === 'dirty' || room.housekeepingStatus === 'cleaning') && (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -812,13 +812,16 @@ function RoomHistoryTab({ slug, roomId }: { slug: string; roomId: string }) {
|
|||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{grouped[day].map(task => {
|
{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 isMaintenance = task.type === 'maintenance'
|
||||||
const isExpanded = expandedTask === task.id
|
const isExpanded = expandedTask === task.id
|
||||||
const photos = task.photos ?? []
|
const problemPhotos = task.photos ?? []
|
||||||
const assigneeName = task.assignedToName ?? taskAny.assigneeName ?? taskAny.assignee_name
|
const resolutionPhotos = task.resolutionPhotos ?? (taskAny.resolution_photos as string[] | undefined) ?? []
|
||||||
const startedAt = task.startedAt ?? taskAny.started_at
|
const resolutionNotes = task.resolutionNotes ?? taskAny.resolution_notes as string | undefined
|
||||||
const completedAt = task.completedAt ?? taskAny.completed_at
|
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
|
const startTime = startedAt
|
||||||
? new Date(startedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
? 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'])}>
|
<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']}
|
{STATUS_LABELS[task.status ?? 'pending']}
|
||||||
</span>
|
</span>
|
||||||
{photos.length > 0 && (
|
{allPhotoCount > 0 && (
|
||||||
<span className="text-[10px] text-slate-400 dark:text-slate-500">
|
<span className="text-[10px] text-slate-400 dark:text-slate-500">
|
||||||
📷 {photos.length}
|
📷 {allPhotoCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -895,59 +898,77 @@ function RoomHistoryTab({ slug, roomId }: { slug: string; roomId: string }) {
|
|||||||
|
|
||||||
{/* Expanded details */}
|
{/* Expanded details */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="px-3 pb-3 border-t border-slate-100 dark:border-slate-700 space-y-3 pt-3">
|
<div className="border-t border-slate-100 dark:border-slate-700 space-y-0">
|
||||||
{task.notes && (
|
{/* Problem section */}
|
||||||
<div>
|
<div className="p-3 space-y-2">
|
||||||
<p className="text-[11px] font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1">
|
<p className="text-[10px] font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wide flex items-center gap-1">
|
||||||
{isMaintenance ? 'Описание проблемы' : 'Замечания'}
|
<AlertTriangle size={10} className="text-orange-400" />
|
||||||
</p>
|
{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">
|
<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}
|
{task.notes}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<p className="text-xs text-slate-400 italic">Описание не указано</p>
|
||||||
|
)}
|
||||||
{photos.length > 0 && (
|
{problemPhotos.length > 0 && (
|
||||||
<div>
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<p className="text-[11px] font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-2">
|
{problemPhotos.map((url, i) => (
|
||||||
{isMaintenance ? 'Фото (проблема / решение)' : 'Фото'}
|
<button key={i} onClick={() => setLightboxPhoto(url)}>
|
||||||
</p>
|
<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" />
|
||||||
<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>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Timing details */}
|
{/* Timing */}
|
||||||
{(startedAt || completedAt) && (
|
{(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 && (
|
{startedAt && (
|
||||||
<div className="bg-slate-50 dark:bg-slate-700/40 rounded-lg px-2.5 py-2">
|
<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-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 })}
|
{format(new Date(startedAt), 'd MMM HH:mm', { locale: ru })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{completedAt && (
|
{completedAt && !isMaintenance && (
|
||||||
<div className="bg-emerald-50 dark:bg-emerald-900/20 rounded-lg px-2.5 py-2">
|
<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-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 })}
|
{format(new Date(completedAt), 'd MMM HH:mm', { locale: ru })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -557,6 +557,7 @@ export interface HkPayload {
|
|||||||
room_id?: string; type?: string; priority?: string
|
room_id?: string; type?: string; priority?: string
|
||||||
status?: string; assignee_id?: string; notes?: string; due_date?: string
|
status?: string; assignee_id?: string; notes?: string; due_date?: string
|
||||||
category?: string; photos?: string[]
|
category?: string; photos?: string[]
|
||||||
|
resolution_notes?: string; resolution_photos?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HkSettings {
|
export interface HkSettings {
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export function CalendarPage() {
|
|||||||
const [priceOverrides, setPriceOverrides] = useState<Record<string, Record<string, number>>>({})
|
const [priceOverrides, setPriceOverrides] = useState<Record<string, Record<string, number>>>({})
|
||||||
// roomId → assigneeName for "убирается" tooltip
|
// roomId → assigneeName for "убирается" tooltip
|
||||||
const [cleaningAssignees, setCleaningAssignees] = useState<Record<string, string>>({})
|
const [cleaningAssignees, setCleaningAssignees] = useState<Record<string, string>>({})
|
||||||
|
// Set of room IDs with active maintenance tasks
|
||||||
|
const [activeMaintenance, setActiveMaintenance] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slug) return
|
if (!slug) return
|
||||||
@@ -55,6 +57,17 @@ export function CalendarPage() {
|
|||||||
}
|
}
|
||||||
}).catch(console.error)
|
}).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
|
// Fetch active HK tasks to build "who is cleaning" tooltip map
|
||||||
api.housekeeping.list(slug, { status: 'active', category: 'housekeeping' })
|
api.housekeeping.list(slug, { status: 'active', category: 'housekeeping' })
|
||||||
.then(tasks => {
|
.then(tasks => {
|
||||||
@@ -90,8 +103,23 @@ export function CalendarPage() {
|
|||||||
if (msg.roomStatus !== 'cleaning') {
|
if (msg.roomStatus !== 'cleaning') {
|
||||||
setCleaningAssignees(prev => { const n = { ...prev }; delete n[msg.roomId]; return n })
|
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') {
|
} else if (msg.type === 'housekeeping_updated') {
|
||||||
const t = msg.task as Record<string, string>
|
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.roomId) {
|
||||||
if (t.status === 'in_progress' && t.assigneeName) {
|
if (t.status === 'in_progress' && t.assigneeName) {
|
||||||
setCleaningAssignees(prev => ({ ...prev, [t.roomId]: t.assigneeName }))
|
setCleaningAssignees(prev => ({ ...prev, [t.roomId]: t.assigneeName }))
|
||||||
@@ -258,6 +286,7 @@ export function CalendarPage() {
|
|||||||
onRoomUpdate={handleRoomUpdate}
|
onRoomUpdate={handleRoomUpdate}
|
||||||
onMaintenanceTaskCreate={handleMaintenanceTaskCreate}
|
onMaintenanceTaskCreate={handleMaintenanceTaskCreate}
|
||||||
cleaningAssignees={cleaningAssignees}
|
cleaningAssignees={cleaningAssignees}
|
||||||
|
activeMaintenance={activeMaintenance}
|
||||||
fadingBookingIds={fadingBookings}
|
fadingBookingIds={fadingBookings}
|
||||||
rentalObjects={isRentalActive ? rentalObjects as unknown as import('../data/rentalData').RentalObject[] : undefined}
|
rentalObjects={isRentalActive ? rentalObjects as unknown as import('../data/rentalData').RentalObject[] : undefined}
|
||||||
rentalBookings={isRentalActive ? rentalBookings as unknown as RentalBooking[] : undefined}
|
rentalBookings={isRentalActive ? rentalBookings as unknown as RentalBooking[] : undefined}
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ import { ru } from 'date-fns/locale'
|
|||||||
function normalizeTask(raw: Record<string, unknown>): Record<string, unknown> {
|
function normalizeTask(raw: Record<string, unknown>): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
...raw,
|
...raw,
|
||||||
roomNumber: raw.roomNumber ?? raw.room_number,
|
roomNumber: raw.roomNumber ?? raw.room_number,
|
||||||
roomId: raw.roomId ?? raw.room_id,
|
roomId: raw.roomId ?? raw.room_id,
|
||||||
assigneeName: raw.assigneeName ?? raw.assignee_name,
|
assigneeName: raw.assigneeName ?? raw.assignee_name,
|
||||||
dueDate: raw.dueDate ?? raw.due_date,
|
dueDate: raw.dueDate ?? raw.due_date,
|
||||||
completedAt: raw.completedAt ?? raw.completed_at,
|
completedAt: raw.completedAt ?? raw.completed_at,
|
||||||
startedAt: raw.startedAt ?? raw.started_at,
|
startedAt: raw.startedAt ?? raw.started_at,
|
||||||
|
resolutionNotes: raw.resolutionNotes ?? raw.resolution_notes,
|
||||||
|
resolutionPhotos: raw.resolutionPhotos ?? raw.resolution_photos,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function RoomsPage() {
|
|||||||
const [rooms, setRooms] = useState<Room[]>([])
|
const [rooms, setRooms] = useState<Room[]>([])
|
||||||
const [categories, setCategories] = useState<RoomCategory[]>([])
|
const [categories, setCategories] = useState<RoomCategory[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [activeMaintenance, setActiveMaintenance] = useState<Set<string>>(new Set())
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [floorFilter, setFloorFilter] = useState<number | 'all'>('all')
|
const [floorFilter, setFloorFilter] = useState<number | 'all'>('all')
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
@@ -38,6 +39,16 @@ export function RoomsPage() {
|
|||||||
.then(([r, cats]) => { setRooms(r); setCategories(cats) })
|
.then(([r, cats]) => { setRooms(r); setCategories(cats) })
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => setLoading(false))
|
.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])
|
}, [slug])
|
||||||
|
|
||||||
const floors = [...new Set(rooms.map(r => r.floor))].sort()
|
const floors = [...new Set(rooms.map(r => r.floor))].sort()
|
||||||
@@ -228,6 +239,7 @@ export function RoomsPage() {
|
|||||||
<RoomCard
|
<RoomCard
|
||||||
key={room.id}
|
key={room.id}
|
||||||
room={room}
|
room={room}
|
||||||
|
hasActiveMaintenance={activeMaintenance.has(room.id)}
|
||||||
onEdit={() => openEdit(room)}
|
onEdit={() => openEdit(room)}
|
||||||
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ room, x: e.clientX, y: e.clientY }) }}
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="card p-4 hover:shadow-card-hover transition-shadow cursor-pointer group relative"
|
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} />
|
<Pencil size={13} />
|
||||||
</button>
|
</button>
|
||||||
{room.status === 'maintenance' && (
|
{(room.status === 'maintenance' || hasActiveMaintenance) && (
|
||||||
<div className="absolute top-3 left-3">
|
<div className="absolute top-3 left-3 flex gap-1">
|
||||||
<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">
|
{room.status === 'maintenance' && (
|
||||||
<Wrench size={10} />ремонт
|
<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">
|
||||||
</span>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ import { Modal } from '../components/ui/Modal'
|
|||||||
function normalizeTask(raw: Record<string, unknown>): Record<string, unknown> {
|
function normalizeTask(raw: Record<string, unknown>): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
...raw,
|
...raw,
|
||||||
roomNumber: raw.roomNumber ?? raw.room_number,
|
roomNumber: raw.roomNumber ?? raw.room_number,
|
||||||
roomId: raw.roomId ?? raw.room_id,
|
roomId: raw.roomId ?? raw.room_id,
|
||||||
assigneeName: raw.assigneeName ?? raw.assignee_name,
|
assigneeName: raw.assigneeName ?? raw.assignee_name,
|
||||||
dueDate: raw.dueDate ?? raw.due_date,
|
dueDate: raw.dueDate ?? raw.due_date,
|
||||||
completedAt: raw.completedAt ?? raw.completed_at,
|
completedAt: raw.completedAt ?? raw.completed_at,
|
||||||
startedAt: raw.startedAt ?? raw.started_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>
|
<p className="text-sm text-slate-500 dark:text-slate-400">Нет технических задач</p>
|
||||||
</div>
|
</div>
|
||||||
) : filtered.map(task => {
|
) : filtered.map(task => {
|
||||||
const taskRecord = task as unknown as Record<string, string>
|
const taskRecord = task as unknown as Record<string, string | string[]>
|
||||||
const pMeta = PRIORITY_LABELS[taskRecord.priority ?? 'medium'] ?? PRIORITY_LABELS.medium
|
const pMeta = PRIORITY_LABELS[String(taskRecord.priority ?? 'medium')] ?? PRIORITY_LABELS.medium
|
||||||
const sMeta = STATUS_LABELS[task.status ?? 'pending']
|
const sMeta = STATUS_LABELS[task.status ?? 'pending']
|
||||||
const SIcon = sMeta.icon
|
const SIcon = sMeta.icon
|
||||||
const roomLabel = (taskRecord.roomNumber || taskRecord.room_number)
|
const roomLabel = (taskRecord.roomNumber || taskRecord.room_number)
|
||||||
? `Номер ${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 isUploading = uploadingFor === task.id
|
||||||
|
const isDone = task.status === 'done'
|
||||||
|
|
||||||
const startedAt = task.startedAt ?? (taskRecord as Record<string, string>).started_at
|
const startedAt = task.startedAt ?? taskRecord.started_at as string | undefined
|
||||||
const completedAt = task.completedAt ?? (taskRecord as Record<string, string>).completed_at
|
const completedAt = task.completedAt ?? taskRecord.completed_at as string | undefined
|
||||||
const durationMin = startedAt && completedAt
|
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
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={task.id} className="card p-4 space-y-3">
|
<div key={task.id} className="card overflow-hidden">
|
||||||
<div className="flex items-start gap-3">
|
{/* ── Card header ── */}
|
||||||
|
<div className="flex items-start gap-3 p-4">
|
||||||
<div className={cn('mt-0.5 shrink-0', sMeta.color)}>
|
<div className={cn('mt-0.5 shrink-0', sMeta.color)}>
|
||||||
<SIcon size={18} />
|
<SIcon size={18} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-start gap-2 flex-wrap">
|
<div className="flex items-center 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)}>
|
<span className={cn('text-[11px] font-medium px-1.5 py-0.5 rounded-md shrink-0', pMeta.color)}>
|
||||||
{pMeta.label}
|
{pMeta.label}
|
||||||
</span>
|
</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>
|
||||||
<div className="flex items-center gap-3 mt-1 text-xs text-slate-500 dark:text-slate-400 flex-wrap">
|
<div className="flex items-center gap-3 mt-1.5 text-xs text-slate-500 dark:text-slate-400 flex-wrap">
|
||||||
<span>{roomLabel}</span>
|
<span className="font-medium text-slate-700 dark:text-slate-300">{roomLabel}</span>
|
||||||
{task.dueDate && (
|
{task.dueDate && (
|
||||||
<span>{format(new Date(task.dueDate), 'd MMM', { locale: ru })}</span>
|
<span>{format(new Date(task.dueDate), 'd MMM', { locale: ru })}</span>
|
||||||
)}
|
)}
|
||||||
{taskRecord.assigneeName && (
|
{taskRecord.assigneeName && (
|
||||||
<span>→ {taskRecord.assigneeName}</span>
|
<span>→ {String(taskRecord.assigneeName)}</span>
|
||||||
)}
|
)}
|
||||||
{durationMin !== null && durationMin > 0 && (
|
{durationMin !== null && durationMin > 0 && (
|
||||||
<span className="flex items-center gap-1 text-slate-400">
|
<span className="flex items-center gap-1 text-slate-400">
|
||||||
<Clock size={10} />
|
<Clock size={10} />
|
||||||
{durationMin < 60
|
{durationMin < 60 ? `${durationMin} мин` : `${Math.floor(durationMin / 60)} ч ${durationMin % 60} мин`}
|
||||||
? `${durationMin} мин`
|
|
||||||
: `${Math.floor(durationMin / 60)} ч ${durationMin % 60} мин`}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -250,30 +258,71 @@ export function TechnicalPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Photos */}
|
{/* ── Задача (problem) section ── */}
|
||||||
{(photos.length > 0 || true) && (
|
<div className="mx-4 mb-3 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||||
<div className="flex flex-wrap gap-2 items-center">
|
<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">
|
||||||
{photos.map(url => (
|
<AlertTriangle size={13} className="text-orange-500 shrink-0" />
|
||||||
<div key={url} className="relative group">
|
<span className="text-[11px] font-semibold text-slate-600 dark:text-slate-400 uppercase tracking-wide">Задача</span>
|
||||||
<a href={url} target="_blank" rel="noreferrer">
|
</div>
|
||||||
<img
|
<div className="p-3 space-y-2.5">
|
||||||
src={url}
|
{task.notes ? (
|
||||||
alt=""
|
<p className="text-sm text-slate-700 dark:text-slate-300">{task.notes}</p>
|
||||||
className="w-16 h-16 object-cover rounded-lg border border-slate-200 dark:border-slate-600 hover:opacity-90 transition-opacity"
|
) : (
|
||||||
/>
|
<p className="text-xs text-slate-400 dark:text-slate-500 italic">Описание не указано</p>
|
||||||
</a>
|
)}
|
||||||
<button
|
{(problemPhotos.length > 0 || !isDone) && (
|
||||||
onClick={() => handlePhotoRemove(task.id, url)}
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
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"
|
{problemPhotos.map(url => (
|
||||||
>
|
<div key={url} className="relative group">
|
||||||
<XIcon size={10} />
|
<a href={url} target="_blank" rel="noreferrer">
|
||||||
</button>
|
<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>
|
</div>
|
||||||
))}
|
)}
|
||||||
<PhotoUploadButton
|
</div>
|
||||||
isUploading={isUploading}
|
</div>
|
||||||
onFile={file => handlePhotoUpload(task.id, file)}
|
|
||||||
/>
|
{/* ── Решение (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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -297,11 +346,11 @@ export function TechnicalPage() {
|
|||||||
requirePhoto={requirePhoto}
|
requirePhoto={requirePhoto}
|
||||||
onClose={() => setCompletingTask(null)}
|
onClose={() => setCompletingTask(null)}
|
||||||
onConfirm={async (completionPhotos, comment) => {
|
onConfirm={async (completionPhotos, comment) => {
|
||||||
const task = tasks.find(t => t.id === completingTask)
|
const patch: import('../lib/api').HkPayload = {
|
||||||
const currentPhotos = task?.photos ?? []
|
status: 'done',
|
||||||
const allPhotos = [...currentPhotos, ...completionPhotos]
|
resolution_photos: completionPhotos,
|
||||||
const patch: import('../lib/api').HkPayload = { status: 'done', photos: allPhotos }
|
}
|
||||||
if (comment.trim()) patch.notes = comment.trim()
|
if (comment.trim()) patch.resolution_notes = comment.trim()
|
||||||
const updated = await api.housekeeping.update(slug, completingTask, patch)
|
const updated = await api.housekeeping.update(slug, completingTask, patch)
|
||||||
setTasks(prev => prev.map(t => t.id === completingTask ? updated : t))
|
setTasks(prev => prev.map(t => t.id === completingTask ? updated : t))
|
||||||
setCompletingTask(null)
|
setCompletingTask(null)
|
||||||
|
|||||||
@@ -215,6 +215,8 @@ export interface HousekeepingTask {
|
|||||||
completedAt?: string
|
completedAt?: string
|
||||||
type: 'cleaning' | 'inspection' | 'maintenance'
|
type: 'cleaning' | 'inspection' | 'maintenance'
|
||||||
photos?: string[]
|
photos?: string[]
|
||||||
|
resolutionNotes?: string
|
||||||
|
resolutionPhotos?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── API Docs ─────────────────────────────────────────────────────────────────
|
// ─── API Docs ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user