fix: 8 improvements — calendar overlaps, legend, nav, tech tasks photos/sort/modal, hk history active maintenance

This commit is contained in:
2026-03-24 19:32:46 +03:00
parent 8b55bc58f1
commit 9a2e45b4c4
5 changed files with 162 additions and 73 deletions

View File

@@ -367,11 +367,11 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
<div className="flex-1" /> <div className="flex-1" />
{/* Legend */} {/* Legend */}
<div className="hidden lg:flex items-center gap-3 text-xs text-slate-500"> <div className="hidden md:flex items-center gap-2 text-xs text-slate-500">
{(['confirmed', 'checked_in', 'checked_out', 'inquiry'] as const).map(s => ( {(['confirmed', 'checked_in', 'checked_out', 'inquiry'] as const).map(s => (
<div key={s} className="flex items-center gap-1.5"> <div key={s} className="flex items-center gap-1">
<div className={cn('w-3 h-3 rounded-sm', BOOKING_STATUS_COLORS[s].split(' ')[0])} /> <div className={cn('w-2.5 h-2.5 rounded-sm shrink-0', BOOKING_STATUS_COLORS[s].split(' ')[0])} />
<span>{BOOKING_STATUS_LABELS[s]}</span> <span className="hidden lg:inline">{BOOKING_STATUS_LABELS[s]}</span>
</div> </div>
))} ))}
</div> </div>
@@ -386,7 +386,7 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
'w-1.5 h-1.5 rounded-full', 'w-1.5 h-1.5 rounded-full',
wsConnected ? 'bg-emerald-500' : 'bg-slate-300 dark:bg-slate-600 animate-pulse', wsConnected ? 'bg-emerald-500' : 'bg-slate-300 dark:bg-slate-600 animate-pulse',
)} /> )} />
<span className="hidden sm:inline">{wsConnected ? 'Онлайн' : 'Офлайн'}</span> <span className="hidden sm:inline">{wsConnected ? 'Синхронизация' : 'Офлайн'}</span>
</div> </div>
)} )}
@@ -489,7 +489,15 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
) )
} }
// Room row // Room row
const roomBookings = bookings.filter(b => b.roomId === room.id && b.status !== 'cancelled') const allRoomBookings = bookings.filter(b => b.roomId === room.id && b.status !== 'cancelled')
// Hide checked_out bookings that overlap with a newer active booking
const activeRoomBookings = allRoomBookings.filter(b => b.status !== 'checked_out')
const roomBookings = allRoomBookings.filter(b => {
if (b.status !== 'checked_out') return true
return !activeRoomBookings.some(other =>
other.id !== b.id && b.checkIn < other.checkOut && b.checkOut > other.checkIn,
)
})
const draftStyle = getDraftStyle(room.id) const draftStyle = getDraftStyle(room.id)
rows.push( rows.push(
<div <div

View File

@@ -146,12 +146,17 @@ export function Sidebar({ open, onClose }: SidebarProps) {
</p> </p>
)} )}
{isModuleActive('housekeeping') && ( {isModuleActive('housekeeping') && (
<NavItem <>
to="/housekeeping" <NavItem
icon={MODULES_DATA.find(m => m.id === 'housekeeping')!.sidebarItem!.icon} to="/housekeeping"
label="Уборка" icon={MODULES_DATA.find(m => m.id === 'housekeeping')!.sidebarItem!.icon}
onClick={onClose} label="Уборка"
/> onClick={onClose}
/>
{!isHousekeeper && (
<NavItem to="/technical" icon={Zap} label="Тех. задачи" onClick={onClose} />
)}
</>
)} )}
{!isHousekeeper && activeModuleItems.map(m => ( {!isHousekeeper && activeModuleItems.map(m => (
<NavItem <NavItem
@@ -172,7 +177,6 @@ export function Sidebar({ open, onClose }: SidebarProps) {
<NavItem to="/users" icon={UserCog} label="Сотрудники" onClick={onClose} /> <NavItem to="/users" icon={UserCog} label="Сотрудники" onClick={onClose} />
<NavItem to="/loyalty" icon={Award} label="Лояльность" onClick={onClose} /> <NavItem to="/loyalty" icon={Award} label="Лояльность" onClick={onClose} />
<NavItem to="/maintenance" icon={Wrench} label="Тех. перерывы" onClick={onClose} /> <NavItem to="/maintenance" icon={Wrench} label="Тех. перерывы" onClick={onClose} />
<NavItem to="/technical" icon={Zap} label="Тех. задачи" onClick={onClose} />
<NavItem to="/floor-map" icon={Map} label="План этажей" onClick={onClose} /> <NavItem to="/floor-map" icon={Map} label="План этажей" onClick={onClose} />
{isModuleActive('channel-manager') && ( {isModuleActive('channel-manager') && (
<NavItem <NavItem

View File

@@ -277,23 +277,34 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu
/> />
<div> <div>
<p className="text-[10px] text-slate-500 dark:text-slate-400 mb-1">Приоритет</p> <p className="text-[10px] text-slate-500 dark:text-slate-400 mb-1">Приоритет</p>
<div className="grid grid-cols-3 gap-1"> <div className="flex flex-col gap-1">
{TECH_PRIORITY_ITEMS.map(p => ( <button
<button onClick={() => setTechPriority('urgent')}
key={p.value} className={cn(
onClick={() => setTechPriority(p.value)} 'w-full px-2 py-1.5 rounded-lg text-[11px] font-medium transition-colors border',
className={cn( techPriority === 'urgent'
'px-2 py-1.5 rounded-lg text-[11px] font-medium transition-colors border', ? 'border-red-500 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300'
techPriority === p.value : 'border-slate-200 dark:border-slate-600 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700',
? p.value === 'urgent' )}
? 'border-red-500 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300' >
: 'border-brand-500 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300' 🚨 Экстренно!
: 'border-slate-200 dark:border-slate-600 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700', </button>
)} <div className="grid grid-cols-2 gap-1">
> {TECH_PRIORITY_ITEMS.filter(p => p.value !== 'urgent').map(p => (
{p.label} <button
</button> key={p.value}
))} onClick={() => setTechPriority(p.value)}
className={cn(
'px-2 py-1.5 rounded-lg text-[11px] font-medium transition-colors border',
techPriority === p.value
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300'
: 'border-slate-200 dark:border-slate-600 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700',
)}
>
{p.label}
</button>
))}
</div>
</div> </div>
{techPriority === 'urgent' && ( {techPriority === 'urgent' && (
<p className="text-[10px] text-red-600 dark:text-red-400 mt-1.5 flex items-center gap-1"> <p className="text-[10px] text-red-600 dark:text-red-400 mt-1.5 flex items-center gap-1">
@@ -310,24 +321,27 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu
className="hidden" className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) { handleTechPhotoUpload(f); e.target.value = '' } }} onChange={e => { const f = e.target.files?.[0]; if (f) { handleTechPhotoUpload(f); e.target.value = '' } }}
/> />
<div className="flex flex-wrap gap-1 mb-1"> {techPhotos.length > 0 && (
{techPhotos.map((url, i) => ( <div className="flex flex-wrap gap-1 mb-1.5">
<div key={i} className="relative group"> {techPhotos.map((url, i) => (
<img src={url} alt="" className="w-10 h-10 object-cover rounded border border-slate-200 dark:border-slate-600" /> <div key={i} className="relative group">
<button <img src={url} alt="" className="w-10 h-10 object-cover rounded border border-slate-200 dark:border-slate-600" />
onClick={() => setTechPhotos(prev => prev.filter((_, j) => j !== i))} <button
className="absolute -top-1 -right-1 w-3.5 h-3.5 rounded-full bg-red-500 text-white flex items-center justify-center opacity-0 group-hover:opacity-100" onClick={() => setTechPhotos(prev => prev.filter((_, j) => j !== i))}
><XIcon size={8} /></button> className="absolute -top-1 -right-1 w-3.5 h-3.5 rounded-full bg-red-500 text-white flex items-center justify-center opacity-0 group-hover:opacity-100"
</div> ><XIcon size={8} /></button>
))} </div>
<button ))}
onClick={() => photoInputRef.current?.click()} </div>
disabled={photoUploading} )}
className="w-10 h-10 rounded border-2 border-dashed border-slate-300 dark:border-slate-600 flex items-center justify-center text-slate-400 hover:border-brand-400 hover:text-brand-500 transition-colors" <button
> onClick={() => photoInputRef.current?.click()}
{photoUploading ? <Loader2 size={12} className="animate-spin" /> : <Camera size={12} />} disabled={photoUploading}
</button> className="w-full py-1.5 rounded-lg border border-dashed border-slate-300 dark:border-slate-600 flex items-center justify-center gap-1.5 text-[11px] text-slate-400 hover:border-brand-400 hover:text-brand-500 transition-colors disabled:opacity-50"
</div> >
{photoUploading ? <Loader2 size={11} className="animate-spin" /> : <Camera size={11} />}
{photoUploading ? 'Загрузка...' : 'Добавить фото'}
</button>
</div> </div>
<div className="flex gap-1.5 pt-0.5"> <div className="flex gap-1.5 pt-0.5">
<button <button

View File

@@ -134,8 +134,16 @@ export function HousekeepingPage() {
useEffect(() => { useEffect(() => {
if (activeTab !== 'history' || !slug) return if (activeTab !== 'history' || !slug) return
setHistoryLoading(true) setHistoryLoading(true)
api.housekeeping.list(slug, { status: 'done', date: historyDate }) Promise.all([
.then(setHistoryTasks) api.housekeeping.list(slug, { status: 'done', date: historyDate }),
api.housekeeping.list(slug, { category: 'maintenance', status: 'active', date: historyDate }),
]).then(([done, activeMaint]) => {
const merged = [...done]
for (const t of activeMaint) {
if (!merged.some(x => x.id === t.id)) merged.push(t)
}
setHistoryTasks(merged)
})
.catch(console.error) .catch(console.error)
.finally(() => setHistoryLoading(false)) .finally(() => setHistoryLoading(false))
}, [activeTab, historyDate, slug]) }, [activeTab, historyDate, slug])
@@ -866,6 +874,7 @@ function TaskCard({ task, onStatusChange, onReport }: {
function HistoryCard({ task }: { task: HousekeepingTask }) { function HistoryCard({ task }: { task: HousekeepingTask }) {
const taskAny = task as unknown as Record<string, string> const taskAny = task as unknown as Record<string, string>
const isMaintenance = task.type === 'maintenance' || (taskAny.category === 'maintenance') const isMaintenance = task.type === 'maintenance' || (taskAny.category === 'maintenance')
const isActive = task.status === 'pending' || task.status === 'in_progress'
const completedTime = task.completedAt const completedTime = task.completedAt
? new Date(task.completedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) ? new Date(task.completedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
@@ -879,10 +888,10 @@ function HistoryCard({ task }: { task: HousekeepingTask }) {
const photos = task.photos ?? [] const photos = task.photos ?? []
return ( return (
<div className={cn('card p-3.5 opacity-90', isMaintenance && 'border-l-4 border-l-orange-400 dark:border-l-orange-500')}> <div className={cn('card p-3.5', isMaintenance && 'border-l-4 border-l-orange-400 dark:border-l-orange-500', !isActive && 'opacity-90')}>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{isMaintenance {isMaintenance
? <Wrench size={16} className="text-orange-500 shrink-0 mt-0.5" /> ? <Wrench size={16} className={cn('shrink-0 mt-0.5', isActive ? 'text-orange-400' : 'text-orange-500')} />
: <CheckCircle2 size={16} className="text-emerald-500 shrink-0 mt-0.5" /> : <CheckCircle2 size={16} className="text-emerald-500 shrink-0 mt-0.5" />
} }
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -894,7 +903,17 @@ function HistoryCard({ task }: { task: HousekeepingTask }) {
<Badge className={PRIORITY_COLORS[task.priority] ?? PRIORITY_COLORS.medium}> <Badge className={PRIORITY_COLORS[task.priority] ?? PRIORITY_COLORS.medium}>
{PRIORITY_LABELS[task.priority] ?? task.priority} {PRIORITY_LABELS[task.priority] ?? task.priority}
</Badge> </Badge>
{isActive && isMaintenance && (
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">
{task.status === 'in_progress' ? 'В работе' : 'Ожидает'}
</span>
)}
</div> </div>
{isActive && isMaintenance && (
<p className="text-[11px] text-amber-700 dark:text-amber-400 mt-1 flex items-center gap-1">
<AlertTriangle size={10} className="shrink-0" /> Замечание по номеру тех. задача не выполнена
</p>
)}
{task.notes && ( {task.notes && (
<p className="text-xs text-slate-600 dark:text-slate-300 mt-1.5 bg-slate-50 dark:bg-slate-700/40 rounded-lg px-2 py-1.5"> <p className="text-xs text-slate-600 dark:text-slate-300 mt-1.5 bg-slate-50 dark:bg-slate-700/40 rounded-lg px-2 py-1.5">
{task.notes} {task.notes}

View File

@@ -126,12 +126,28 @@ export function TechnicalPage() {
} }
} }
const filtered = tasks.filter(t => { const filtered = tasks
const s = t.status as string .filter(t => {
if (filterStatus === 'active') return s !== 'done' && s !== 'cancelled' const s = t.status as string
if (filterStatus === 'done') return s === 'done' if (filterStatus === 'active') return s !== 'done' && s !== 'cancelled'
return true if (filterStatus === 'done') return s === 'done'
}) return true
})
.sort((a, b) => {
const aRec = a as unknown as Record<string, string>
const bRec = b as unknown as Record<string, string>
// Done tasks: sort by completedAt DESC
if (a.status === 'done' && b.status === 'done') {
const aT = aRec.completedAt ?? aRec.completed_at ?? ''
const bT = bRec.completedAt ?? bRec.completed_at ?? ''
return bT.localeCompare(aT)
}
// Active tasks: urgent first, then by dueDate
const priorityOrder: Record<string, number> = { urgent: 0, medium: 1, low: 2 }
const pa = priorityOrder[String(aRec.priority ?? 'medium')] ?? 1
const pb = priorityOrder[String(bRec.priority ?? 'medium')] ?? 1
return pa - pb
})
return ( return (
<div className="p-4 md:p-6 max-w-4xl mx-auto"> <div className="p-4 md:p-6 max-w-4xl mx-auto">
@@ -278,19 +294,9 @@ export function TechnicalPage() {
{(problemPhotos.length > 0 || !isDone) && ( {(problemPhotos.length > 0 || !isDone) && (
<div className="flex flex-wrap gap-2 items-center"> <div className="flex flex-wrap gap-2 items-center">
{problemPhotos.map(url => ( {problemPhotos.map(url => (
<div key={url} className="relative group"> <a key={url} href={url} target="_blank" rel="noreferrer">
<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" />
<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>
</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 && ( {!isDone && (
<PhotoUploadButton isUploading={isUploading} onFile={file => handlePhotoUpload(task.id, file)} /> <PhotoUploadButton isUploading={isUploading} onFile={file => handlePhotoUpload(task.id, file)} />
@@ -406,12 +412,25 @@ function TaskCreateModal({
const [rooms, setRooms] = useState<{ id: string; number: string }[]>([]) const [rooms, setRooms] = useState<{ id: string; number: string }[]>([])
const [users, setUsers] = useState<{ id: string; name: string }[]>([]) const [users, setUsers] = useState<{ id: string; name: string }[]>([])
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [photos, setPhotos] = useState<string[]>([])
const [photoUploading, setPhotoUploading] = useState(false)
const photoRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
api.rooms.list(slug).then(r => setRooms(r.map(rm => ({ id: rm.id, number: rm.number })))).catch(() => {}) api.rooms.list(slug).then(r => setRooms(r.map(rm => ({ id: rm.id, number: rm.number })))).catch(() => {})
api.users.list(slug).then(u => setUsers(u)).catch(() => {}) api.users.list(slug).then(u => setUsers(u)).catch(() => {})
}, [slug]) }, [slug])
const handlePhotoUpload = async (file: File) => {
setPhotoUploading(true)
try {
const url = await api.upload.photo(file, 'tasks')
setPhotos(prev => [...prev, url])
} catch { /* ignore */ } finally {
setPhotoUploading(false)
}
}
const handleSave = async () => { const handleSave = async () => {
if (!form.notes.trim()) return if (!form.notes.trim()) return
setSaving(true) setSaving(true)
@@ -424,6 +443,7 @@ function TaskCreateModal({
due_date: form.due_date || undefined, due_date: form.due_date || undefined,
assignee_id: form.assignee_id || undefined, assignee_id: form.assignee_id || undefined,
category: 'maintenance', category: 'maintenance',
photos,
}) })
onCreated(task) onCreated(task)
} catch (err) { } catch (err) {
@@ -450,7 +470,7 @@ function TaskCreateModal({
<div> <div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Приоритет</label> <label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Приоритет</label>
<select className="input" value={form.priority} onChange={e => setForm(p => ({ ...p, priority: e.target.value }))}> <select className="input" value={form.priority} onChange={e => setForm(p => ({ ...p, priority: e.target.value }))}>
<option value="urgent">Срочно</option> <option value="urgent">Экстренно!</option>
<option value="medium">Средний</option> <option value="medium">Средний</option>
<option value="low">Низкий</option> <option value="low">Низкий</option>
</select> </select>
@@ -474,9 +494,33 @@ function TaskCreateModal({
{users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)} {users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
</select> </select>
</div> </div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Фото (необязательно)</label>
<input ref={photoRef} type="file" accept="image/*" className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) { handlePhotoUpload(f); e.target.value = '' } }} />
<div className="flex flex-wrap gap-2 items-center">
{photos.map((url, i) => (
<div key={i} className="relative group">
<img src={url} alt="" className="w-16 h-16 object-cover rounded-lg border border-slate-200 dark:border-slate-600" />
<button
onClick={() => setPhotos(prev => prev.filter((_, j) => j !== i))}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
><XIcon size={11} /></button>
</div>
))}
<button
onClick={() => photoRef.current?.click()}
disabled={photoUploading}
className="w-16 h-16 rounded-lg border-2 border-dashed border-slate-300 dark:border-slate-600 flex flex-col items-center justify-center gap-1 text-slate-400 hover:border-brand-400 hover:text-brand-500 transition-colors disabled:opacity-50"
>
{photoUploading ? <Loader2 size={16} className="animate-spin" /> : <Camera size={16} />}
{!photoUploading && <span className="text-[10px]">Фото</span>}
</button>
</div>
</div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<button onClick={onClose} className="btn-secondary">Отмена</button> <button onClick={onClose} className="btn-secondary">Отмена</button>
<button onClick={handleSave} disabled={saving || !form.notes.trim()} className="btn-primary"> <button onClick={handleSave} disabled={saving || !form.notes.trim() || photoUploading} className="btn-primary">
{saving ? 'Создание...' : 'Создать'} {saving ? 'Создание...' : 'Создать'}
</button> </button>
</div> </div>