feat: room history tab + tech task resolution comment

- RoomModal: new "История" tab showing all housekeeping and maintenance tasks for the room
  - Groups tasks by date (newest first), expandable cards
  - Shows: assignee, start time, end time, duration (started_at → completed_at)
  - For maintenance: description, problem/solution photos with lightbox viewer
  - Timing grid: start / completion timestamps
- Backend GET /housekeeping: add room_id filter param
- TechnicalPage CompletionModal: add resolution comment field saved to task notes
- api.ts: housekeeping.list() accepts roomId param

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 11:32:11 +03:00
parent 96961046ed
commit 14f0b8e0f2
5 changed files with 320 additions and 37 deletions

View File

@@ -296,14 +296,13 @@ export function TechnicalPage() {
<CompletionModal
requirePhoto={requirePhoto}
onClose={() => setCompletingTask(null)}
onConfirm={async (completionPhotos) => {
onConfirm={async (completionPhotos, comment) => {
const task = tasks.find(t => t.id === completingTask)
const currentPhotos = task?.photos ?? []
const allPhotos = [...currentPhotos, ...completionPhotos]
const updated = await api.housekeeping.update(slug, completingTask, {
status: 'done',
photos: allPhotos,
})
const patch: import('../lib/api').HkPayload = { status: 'done', photos: allPhotos }
if (comment.trim()) patch.notes = comment.trim()
const updated = await api.housekeeping.update(slug, completingTask, patch)
setTasks(prev => prev.map(t => t.id === completingTask ? updated : t))
setCompletingTask(null)
}}
@@ -439,9 +438,10 @@ function CompletionModal({
}: {
requirePhoto: boolean
onClose: () => void
onConfirm: (photos: string[]) => Promise<void>
onConfirm: (photos: string[], comment: string) => Promise<void>
}) {
const [photos, setPhotos] = useState<string[]>([])
const [comment, setComment] = useState('')
const [uploading, setUploading] = useState(false)
const [saving, setSaving] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
@@ -459,7 +459,7 @@ function CompletionModal({
const handleConfirm = async () => {
if (requirePhoto && photos.length === 0) return
setSaving(true)
try { await onConfirm(photos) } catch { /* ignore */ } finally { setSaving(false) }
try { await onConfirm(photos, comment) } catch { /* ignore */ } finally { setSaving(false) }
}
return (
@@ -468,9 +468,22 @@ function CompletionModal({
<p className="text-sm text-slate-600 dark:text-slate-400">
{requirePhoto
? 'Прикрепите фото выполненной работы — это обязательно по настройкам отеля.'
: 'Вы можете прикрепить фото выполненной работы (необязательно).'}
: 'Опишите что было сделано и при необходимости прикрепите фото.'}
</p>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">
Комментарий к выполнению
</label>
<textarea
className="w-full text-sm border border-slate-200 dark:border-slate-600 rounded-lg p-2.5 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 resize-none focus:outline-none focus:border-brand-400"
rows={3}
placeholder="Что было сделано, какие материалы использованы..."
value={comment}
onChange={e => setComment(e.target.value)}
/>
</div>
<input
ref={inputRef}
type="file"
@@ -479,26 +492,31 @@ function CompletionModal({
onChange={e => { const f = e.target.files?.[0]; if (f) { uploadPhoto(f); e.target.value = '' } }}
/>
<div className="flex flex-wrap gap-2 items-center">
{photos.map((url, i) => (
<div key={i} className="relative group">
<a href={url} target="_blank" rel="noreferrer">
<img src={url} alt="" className="w-20 h-20 object-cover rounded-lg border border-slate-200 dark:border-slate-600" />
</a>
<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"
><XIcon size={11} /></button>
</div>
))}
<button
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="w-20 h-20 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"
>
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Camera size={18} />}
<span className="text-[10px]">{uploading ? '' : 'Добавить'}</span>
</button>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">
Фото выполненной работы{requirePhoto ? ' *' : ' (необязательно)'}
</label>
<div className="flex flex-wrap gap-2 items-center">
{photos.map((url, i) => (
<div key={i} className="relative group">
<a href={url} target="_blank" rel="noreferrer">
<img src={url} alt="" className="w-20 h-20 object-cover rounded-lg border border-slate-200 dark:border-slate-600" />
</a>
<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"
><XIcon size={11} /></button>
</div>
))}
<button
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="w-20 h-20 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"
>
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Camera size={18} />}
<span className="text-[10px]">{uploading ? '' : 'Добавить'}</span>
</button>
</div>
</div>
{requirePhoto && photos.length === 0 && (