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:
@@ -241,6 +241,7 @@ export function RoomsPage() {
|
||||
open={modalOpen}
|
||||
room={editingRoom}
|
||||
existingNumbers={rooms.map(r => r.number)}
|
||||
slug={slug}
|
||||
onClose={() => { setModalOpen(false); setSaveError(null) }}
|
||||
onSave={handleSave}
|
||||
onDelete={editingRoom ? () => handleDelete(editingRoom.id) : undefined}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user