Files
hotelsync/src/pages/TechnicalPage.tsx

640 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}