feat: cleaning tooltip, report photos, fix tech task creation, completion photos
- Calendar: hover tooltip on '🧹 убирается' shows who is cleaning (assignee name) fetched from active HK tasks on mount, updated via WS housekeeping_updated events - HousekeepingPage: 'Сообщить о неисправности' now creates a real maintenance task in the DB (category=maintenance) + sends WS so TechnicalPage receives it instantly; photo upload added to the report form - RoomContextMenu: photo upload added to tech task creation form (camera button, preview thumbnails); photos passed through to API on task creation - TechnicalPage: selecting 'Выполнено' now opens CompletionModal with photo upload; technician can attach completion photos before confirming; if require_completion_photo setting is on — at least one photo is required - Settings (HousekeepingPage plans tab): new toggle 'Обязательное фото при завершении тех. задачи' saved to housekeeping_settings.require_completion_photo (migration 027) - Backend: housekeeping-settings updated with require_completion_photo field Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,8 @@ export function TechnicalPage() {
|
||||
const [filterStatus, setFilterStatus] = useState<string>('active')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [uploadingFor, setUploadingFor] = useState<string | null>(null)
|
||||
const [completingTask, setCompletingTask] = useState<string | null>(null)
|
||||
const [requirePhoto, setRequirePhoto] = useState(false)
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!slug) return
|
||||
@@ -41,6 +43,13 @@ export function TechnicalPage() {
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return
|
||||
api.housekeeping.getSettings(slug)
|
||||
.then(s => setRequirePhoto(s.require_completion_photo ?? false))
|
||||
.catch(() => {})
|
||||
}, [slug])
|
||||
|
||||
const handleWsMessage = useCallback((msg: WsMessage) => {
|
||||
if (msg.type === 'housekeeping_task_created') {
|
||||
const task = msg.task as unknown as HousekeepingTask & { category?: string }
|
||||
@@ -194,7 +203,11 @@ export function TechnicalPage() {
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<select
|
||||
value={task.status ?? 'pending'}
|
||||
onChange={e => handleStatusChange(task.id, e.target.value)}
|
||||
onChange={e => {
|
||||
const v = e.target.value
|
||||
if (v === 'done') { setCompletingTask(task.id) }
|
||||
else { handleStatusChange(task.id, v) }
|
||||
}}
|
||||
className="text-xs border border-slate-200 dark:border-slate-600 rounded-lg px-2 py-1 bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400"
|
||||
>
|
||||
<option value="pending">Ожидает</option>
|
||||
@@ -252,6 +265,24 @@ export function TechnicalPage() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{completingTask && (
|
||||
<CompletionModal
|
||||
requirePhoto={requirePhoto}
|
||||
onClose={() => setCompletingTask(null)}
|
||||
onConfirm={async (completionPhotos) => {
|
||||
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,
|
||||
})
|
||||
setTasks(prev => prev.map(t => t.id === completingTask ? updated : t))
|
||||
setCompletingTask(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -374,3 +405,93 @@ function TaskCreateModal({
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function CompletionModal({
|
||||
requirePhoto,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
requirePhoto: boolean
|
||||
onClose: () => void
|
||||
onConfirm: (photos: string[]) => Promise<void>
|
||||
}) {
|
||||
const [photos, setPhotos] = useState<string[]>([])
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const uploadPhoto = async (file: File) => {
|
||||
setUploading(true)
|
||||
try {
|
||||
const url = await api.upload.photo(file, 'tasks')
|
||||
setPhotos(prev => [...prev, url])
|
||||
} catch { /* ignore */ } finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (requirePhoto && photos.length === 0) return
|
||||
setSaving(true)
|
||||
try { await onConfirm(photos) } catch { /* ignore */ } finally { setSaving(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title="Завершение задачи">
|
||||
<div className="space-y-4 p-4">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{requirePhoto
|
||||
? 'Прикрепите фото выполненной работы — это обязательно по настройкам отеля.'
|
||||
: 'Вы можете прикрепить фото выполненной работы (необязательно).'}
|
||||
</p>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
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>
|
||||
|
||||
{requirePhoto && photos.length === 0 && (
|
||||
<p className="text-xs text-red-500 flex items-center gap-1">
|
||||
<AlertTriangle size={12} /> Необходимо прикрепить хотя бы одно фото
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-end pt-1">
|
||||
<button onClick={onClose} className="btn-secondary">Отмена</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={saving || uploading || (requirePhoto && photos.length === 0)}
|
||||
className="btn-primary"
|
||||
>
|
||||
{saving ? 'Сохранение...' : 'Завершить задачу'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user