640 lines
29 KiB
TypeScript
640 lines
29 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react'
|
||
import { Plus, Wrench, Clock, CheckCircle2, AlertTriangle, Trash2, Camera, X as XIcon, Loader2 } from 'lucide-react'
|
||
import { format } from 'date-fns'
|
||
import { ru } from 'date-fns/locale'
|
||
import { api } from '../lib/api'
|
||
import { useAuth } from '../contexts/AuthContext'
|
||
import { useHotelSocket } from '../hooks/useHotelSocket'
|
||
import type { WsMessage } from '../hooks/useHotelSocket'
|
||
import type { HousekeepingTask } from '../types'
|
||
import { cn } from '../lib/utils'
|
||
import { Modal } from '../components/ui/Modal'
|
||
|
||
function normalizeTask(raw: Record<string, unknown>): Record<string, unknown> {
|
||
return {
|
||
...raw,
|
||
roomNumber: raw.roomNumber ?? raw.room_number,
|
||
roomId: raw.roomId ?? raw.room_id,
|
||
assigneeName: raw.assigneeName ?? raw.assignee_name,
|
||
dueDate: raw.dueDate ?? raw.due_date,
|
||
completedAt: raw.completedAt ?? raw.completed_at,
|
||
startedAt: raw.startedAt ?? raw.started_at,
|
||
resolutionNotes: raw.resolutionNotes ?? raw.resolution_notes,
|
||
resolutionPhotos: raw.resolutionPhotos ?? raw.resolution_photos,
|
||
}
|
||
}
|
||
|
||
const PRIORITY_LABELS: Record<string, { label: string; color: string }> = {
|
||
urgent: { label: 'Экстренно!', color: 'text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400 border border-red-200 dark:border-red-800' },
|
||
medium: { label: 'Средний', color: 'text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 dark:text-yellow-400 border border-yellow-200 dark:border-yellow-800' },
|
||
low: { label: 'Низкий', color: 'text-slate-500 bg-slate-100 dark:bg-slate-700 dark:text-slate-400 border border-slate-200 dark:border-slate-600' },
|
||
}
|
||
|
||
const STATUS_LABELS: Record<string, { label: string; icon: React.ElementType; color: string }> = {
|
||
pending: { label: 'Ожидает', icon: Clock, color: 'text-slate-500' },
|
||
in_progress: { label: 'В работе', icon: Wrench, color: 'text-blue-600' },
|
||
done: { label: 'Завершено', icon: CheckCircle2, color: 'text-emerald-600' },
|
||
cancelled: { label: 'Отменено', icon: AlertTriangle, color: 'text-slate-400' },
|
||
}
|
||
|
||
export function TechnicalPage() {
|
||
const { user, session } = useAuth()
|
||
const slug = user?.hotelSlug ?? ''
|
||
|
||
const [tasks, setTasks] = useState<HousekeepingTask[]>([])
|
||
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
|
||
api.housekeeping.list(slug, { category: 'maintenance' })
|
||
.then(setTasks)
|
||
.catch(console.error)
|
||
}, [slug])
|
||
|
||
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 = normalizeTask(msg.task as Record<string, unknown>)
|
||
if (task.category === 'maintenance') {
|
||
setTasks(prev => prev.some(t => t.id === task.id) ? prev : [task as unknown as HousekeepingTask, ...prev])
|
||
}
|
||
} else if (msg.type === 'housekeeping_updated') {
|
||
const task = normalizeTask(msg.task as Record<string, unknown>)
|
||
if (task.category === 'maintenance') {
|
||
setTasks(prev => prev.map(t => t.id === task.id ? task as unknown as HousekeepingTask : t))
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
useHotelSocket({ slug, token: session?.token ?? null, onMessage: handleWsMessage })
|
||
|
||
const handleStatusChange = async (taskId: string, status: string) => {
|
||
try {
|
||
const updated = await api.housekeeping.update(slug, taskId, { status })
|
||
setTasks(prev => prev.map(t => t.id === taskId ? updated : t))
|
||
} catch (err) {
|
||
console.error(err)
|
||
}
|
||
}
|
||
|
||
const handleDelete = async (taskId: string) => {
|
||
if (!confirm('Отменить задачу? Она останется в истории со статусом «Отменено».')) return
|
||
try {
|
||
const updated = await api.housekeeping.update(slug, taskId, { status: 'cancelled' })
|
||
setTasks(prev => prev.map(t => t.id === taskId ? updated : t))
|
||
} catch (err) {
|
||
console.error(err)
|
||
}
|
||
}
|
||
|
||
const handlePhotoUpload = async (taskId: string, file: File) => {
|
||
setUploadingFor(taskId)
|
||
try {
|
||
const url = await api.upload.photo(file, 'tasks')
|
||
const task = tasks.find(t => t.id === taskId)
|
||
const currentPhotos = task?.photos ?? []
|
||
const updated = await api.housekeeping.update(slug, taskId, { photos: [...currentPhotos, url] })
|
||
setTasks(prev => prev.map(t => t.id === taskId ? updated : t))
|
||
} catch (err) {
|
||
console.error('Photo upload failed:', err)
|
||
} finally {
|
||
setUploadingFor(null)
|
||
}
|
||
}
|
||
|
||
const handlePhotoRemove = async (taskId: string, photoUrl: string) => {
|
||
const task = tasks.find(t => t.id === taskId)
|
||
if (!task) return
|
||
const newPhotos = (task.photos ?? []).filter(p => p !== photoUrl)
|
||
try {
|
||
const updated = await api.housekeeping.update(slug, taskId, { photos: newPhotos })
|
||
setTasks(prev => prev.map(t => t.id === taskId ? updated : t))
|
||
} catch (err) {
|
||
console.error(err)
|
||
}
|
||
}
|
||
|
||
const filtered = tasks
|
||
.filter(t => {
|
||
const s = t.status as string
|
||
if (filterStatus === 'active') return s !== 'done' && s !== 'cancelled'
|
||
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 (
|
||
<div className="p-4 md:p-6 max-w-4xl mx-auto">
|
||
<div className="flex items-center justify-between mb-5">
|
||
<div>
|
||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Технические задачи</h1>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">Заявки для технического персонала</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowModal(true)}
|
||
className="btn-primary flex items-center gap-2"
|
||
>
|
||
<Plus size={16} />
|
||
<span className="hidden sm:inline">Создать задачу</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Filters */}
|
||
<div className="flex gap-2 mb-4">
|
||
{[
|
||
{ id: 'active', label: 'Активные' },
|
||
{ id: 'done', label: 'Выполненные' },
|
||
{ id: 'all', label: 'Все' },
|
||
].map(f => (
|
||
<button
|
||
key={f.id}
|
||
onClick={() => setFilterStatus(f.id)}
|
||
className={cn(
|
||
'px-3 py-1.5 rounded-lg text-sm font-medium transition-colors',
|
||
filterStatus === f.id
|
||
? 'bg-brand-600 text-white'
|
||
: 'bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700',
|
||
)}
|
||
>
|
||
{f.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Task list */}
|
||
<div className="space-y-3">
|
||
{filtered.length === 0 ? (
|
||
<div className="card p-10 text-center">
|
||
<Wrench size={32} className="mx-auto text-slate-300 dark:text-slate-600 mb-3" />
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">Нет технических задач</p>
|
||
</div>
|
||
) : filtered.map(task => {
|
||
const taskRecord = task as unknown as Record<string, string | string[]>
|
||
const pMeta = PRIORITY_LABELS[String(taskRecord.priority ?? 'medium')] ?? PRIORITY_LABELS.medium
|
||
const sMeta = STATUS_LABELS[task.status ?? 'pending']
|
||
const SIcon = sMeta.icon
|
||
const roomLabel = (taskRecord.roomNumber || taskRecord.room_number)
|
||
? `Номер ${taskRecord.roomNumber ?? taskRecord.room_number}`
|
||
: 'Без номера'
|
||
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 isDone = task.status === 'done'
|
||
|
||
const startedAt = task.startedAt ?? taskRecord.started_at as string | undefined
|
||
const completedAt = task.completedAt ?? taskRecord.completed_at as string | undefined
|
||
const durationMin = startedAt && completedAt
|
||
? Math.round((new Date(completedAt as string).getTime() - new Date(startedAt as string).getTime()) / 60000)
|
||
: null
|
||
|
||
return (
|
||
<div key={task.id} className="card overflow-hidden">
|
||
{/* ── Card header ── */}
|
||
<div className="flex items-start gap-3 p-4">
|
||
<div className={cn('mt-0.5 shrink-0', sMeta.color)}>
|
||
<SIcon size={18} />
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className={cn('text-[11px] font-medium px-1.5 py-0.5 rounded-md shrink-0', pMeta.color)}>
|
||
{pMeta.label}
|
||
</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>
|
||
{String(taskRecord.priority) === 'urgent' && !isDone && (taskRecord.roomNumber || taskRecord.room_number) && (
|
||
<span className="text-[11px] font-medium px-1.5 py-0.5 rounded-md shrink-0 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 border border-red-200 dark:border-red-700">
|
||
🔒 Номер закрыт
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-3 mt-1.5 text-xs text-slate-500 dark:text-slate-400 flex-wrap">
|
||
<span className="font-medium text-slate-700 dark:text-slate-300">{roomLabel}</span>
|
||
{task.dueDate && (
|
||
<span>{format(new Date(task.dueDate), 'd MMM', { locale: ru })}</span>
|
||
)}
|
||
{taskRecord.assigneeName && (
|
||
<span>→ {String(taskRecord.assigneeName)}</span>
|
||
)}
|
||
{durationMin !== null && durationMin > 0 && (
|
||
<span className="flex items-center gap-1 text-slate-400">
|
||
<Clock size={10} />
|
||
{durationMin < 60 ? `${durationMin} мин` : `${Math.floor(durationMin / 60)} ч ${durationMin % 60} мин`}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-1 shrink-0">
|
||
<select
|
||
value={task.status ?? 'pending'}
|
||
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>
|
||
<option value="in_progress">В работе</option>
|
||
<option value="done">Выполнено</option>
|
||
<option value="cancelled">Отменено</option>
|
||
</select>
|
||
<button
|
||
onClick={() => handleDelete(task.id)}
|
||
className="p-1.5 rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Задача (problem) section ── */}
|
||
<div className="mx-4 mb-3 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||
<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">
|
||
<AlertTriangle size={13} className="text-orange-500 shrink-0" />
|
||
<span className="text-[11px] font-semibold text-slate-600 dark:text-slate-400 uppercase tracking-wide">Задача</span>
|
||
</div>
|
||
<div className="p-3 space-y-2.5">
|
||
{task.notes ? (
|
||
<p className="text-sm text-slate-700 dark:text-slate-300">{task.notes}</p>
|
||
) : (
|
||
<p className="text-xs text-slate-400 dark:text-slate-500 italic">Описание не указано</p>
|
||
)}
|
||
{(problemPhotos.length > 0 || !isDone) && (
|
||
<div className="flex flex-wrap gap-2 items-center">
|
||
{problemPhotos.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-slate-200 dark:border-slate-600 hover:opacity-90 transition-opacity" />
|
||
</a>
|
||
))}
|
||
{!isDone && (
|
||
<PhotoUploadButton isUploading={isUploading} onFile={file => handlePhotoUpload(task.id, file)} />
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Решение (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>
|
||
|
||
{showModal && (
|
||
<TaskCreateModal
|
||
slug={slug}
|
||
onClose={() => setShowModal(false)}
|
||
onCreated={task => {
|
||
setTasks(prev => [task, ...prev])
|
||
setShowModal(false)
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{completingTask && (
|
||
<CompletionModal
|
||
requirePhoto={requirePhoto}
|
||
onClose={() => setCompletingTask(null)}
|
||
onConfirm={async (completionPhotos, comment) => {
|
||
const patch: import('../lib/api').HkPayload = {
|
||
status: 'done',
|
||
resolution_photos: completionPhotos,
|
||
}
|
||
if (comment.trim()) patch.resolution_notes = comment.trim()
|
||
const updated = await api.housekeeping.update(slug, completingTask, patch)
|
||
setTasks(prev => prev.map(t => t.id === completingTask ? updated : t))
|
||
setCompletingTask(null)
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PhotoUploadButton({ isUploading, onFile }: { isUploading: boolean; onFile: (f: File) => void }) {
|
||
const ref = useRef<HTMLInputElement>(null)
|
||
return (
|
||
<>
|
||
<input
|
||
ref={ref}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={e => { const f = e.target.files?.[0]; if (f) { onFile(f); e.target.value = '' } }}
|
||
/>
|
||
<button
|
||
onClick={() => ref.current?.click()}
|
||
disabled={isUploading}
|
||
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"
|
||
>
|
||
{isUploading ? <Loader2 size={16} className="animate-spin" /> : <Camera size={16} />}
|
||
<span className="text-[10px]">{isUploading ? '' : 'Фото'}</span>
|
||
</button>
|
||
</>
|
||
)
|
||
}
|
||
|
||
function TaskCreateModal({
|
||
slug, onClose, onCreated,
|
||
}: {
|
||
slug: string
|
||
onClose: () => void
|
||
onCreated: (t: HousekeepingTask) => void
|
||
}) {
|
||
const [form, setForm] = useState({
|
||
notes: '',
|
||
priority: 'medium',
|
||
room_id: '',
|
||
due_date: format(new Date(), 'yyyy-MM-dd'),
|
||
assignee_id: '',
|
||
})
|
||
const [rooms, setRooms] = useState<{ id: string; number: string }[]>([])
|
||
const [users, setUsers] = useState<{ id: string; name: string }[]>([])
|
||
const [saving, setSaving] = useState(false)
|
||
const [photos, setPhotos] = useState<string[]>([])
|
||
const [photoUploading, setPhotoUploading] = useState(false)
|
||
const photoRef = useRef<HTMLInputElement>(null)
|
||
|
||
useEffect(() => {
|
||
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(() => {})
|
||
}, [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 () => {
|
||
if (!form.notes.trim()) return
|
||
setSaving(true)
|
||
try {
|
||
const task = await api.housekeeping.create(slug, {
|
||
room_id: form.room_id || undefined,
|
||
type: 'maintenance',
|
||
priority: form.priority,
|
||
notes: form.notes,
|
||
due_date: form.due_date || undefined,
|
||
assignee_id: form.assignee_id || undefined,
|
||
category: 'maintenance',
|
||
photos,
|
||
})
|
||
onCreated(task)
|
||
} catch (err) {
|
||
console.error(err)
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Modal open onClose={onClose} title="Новая техническая задача">
|
||
<div className="space-y-4 p-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Описание задачи *</label>
|
||
<textarea
|
||
className="input resize-none"
|
||
rows={3}
|
||
placeholder="Опишите проблему или задачу..."
|
||
value={form.notes}
|
||
onChange={e => setForm(p => ({ ...p, notes: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<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 }))}>
|
||
<option value="urgent">Экстренно!</option>
|
||
<option value="medium">Средний</option>
|
||
<option value="low">Низкий</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Дата</label>
|
||
<input type="date" className="input" value={form.due_date} onChange={e => setForm(p => ({ ...p, due_date: e.target.value }))} />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Номер (если нужен)</label>
|
||
<select className="input" value={form.room_id} onChange={e => setForm(p => ({ ...p, room_id: e.target.value }))}>
|
||
<option value="">— Не выбран —</option>
|
||
{rooms.map(r => <option key={r.id} value={r.id}>Номер {r.number}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Исполнитель</label>
|
||
<select className="input" value={form.assignee_id} onChange={e => setForm(p => ({ ...p, assignee_id: e.target.value }))}>
|
||
<option value="">— Не назначен —</option>
|
||
{users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||
</select>
|
||
</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">
|
||
<button onClick={onClose} className="btn-secondary">Отмена</button>
|
||
<button onClick={handleSave} disabled={saving || !form.notes.trim() || photoUploading} className="btn-primary">
|
||
{saving ? 'Создание...' : 'Создать'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
function CompletionModal({
|
||
requirePhoto,
|
||
onClose,
|
||
onConfirm,
|
||
}: {
|
||
requirePhoto: boolean
|
||
onClose: () => 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)
|
||
|
||
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, comment) } 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>
|
||
|
||
<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"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={e => { const f = e.target.files?.[0]; if (f) { uploadPhoto(f); e.target.value = '' } }}
|
||
/>
|
||
|
||
<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 && (
|
||
<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>
|
||
)
|
||
}
|