359 lines
13 KiB
TypeScript
359 lines
13 KiB
TypeScript
|
|
|
|||
|
|
import React, { useState, useEffect } from 'react';
|
|||
|
|
import { WorkPhoto } from '../../types';
|
|||
|
|
import { apiClient } from '../../services/apiClient';
|
|||
|
|
import { Plus, X, Image as ImageIcon, Loader2, Calendar, Building2, FileText } from 'lucide-react';
|
|||
|
|
|
|||
|
|
interface WorkPhotosManagerProps {
|
|||
|
|
buildingId?: string;
|
|||
|
|
residentReportId?: number;
|
|||
|
|
onPhotoSelect?: (photo: WorkPhoto) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export const WorkPhotosManager: React.FC<WorkPhotosManagerProps> = ({
|
|||
|
|
buildingId,
|
|||
|
|
residentReportId,
|
|||
|
|
onPhotoSelect
|
|||
|
|
}) => {
|
|||
|
|
const [photos, setPhotos] = useState<WorkPhoto[]>([]);
|
|||
|
|
const [isLoading, setIsLoading] = useState(true);
|
|||
|
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
loadPhotos();
|
|||
|
|
}, [buildingId, residentReportId]);
|
|||
|
|
|
|||
|
|
const loadPhotos = async () => {
|
|||
|
|
try {
|
|||
|
|
setIsLoading(true);
|
|||
|
|
const params = new URLSearchParams();
|
|||
|
|
if (buildingId) params.append('building_id', buildingId);
|
|||
|
|
if (residentReportId) params.append('resident_report_id', String(residentReportId));
|
|||
|
|
|
|||
|
|
const queryString = params.toString();
|
|||
|
|
const path = `/pr/work-photos${queryString ? `?${queryString}` : ''}`;
|
|||
|
|
const data = await apiClient.get<WorkPhoto[]>(path);
|
|||
|
|
setPhotos(data);
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Error loading work photos:', err);
|
|||
|
|
setPhotos([]);
|
|||
|
|
} finally {
|
|||
|
|
setIsLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleDelete = async (id: number) => {
|
|||
|
|
if (!confirm('Удалить фото отчет?')) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await apiClient.delete(`/pr/work-photos/${id}`);
|
|||
|
|
await loadPhotos();
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Error deleting work photo:', err);
|
|||
|
|
alert('Ошибка удаления фото отчета');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
<div className="flex justify-between items-center">
|
|||
|
|
<h4 className="font-black text-slate-800 text-sm">Фото отчеты работ</h4>
|
|||
|
|
<button
|
|||
|
|
onClick={() => setShowCreateForm(true)}
|
|||
|
|
className="px-4 py-2 bg-primary-600 text-white rounded-xl text-xs font-black uppercase tracking-wider flex items-center gap-2"
|
|||
|
|
>
|
|||
|
|
<Plus className="w-4 h-4" />
|
|||
|
|
Добавить
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{showCreateForm && (
|
|||
|
|
<WorkPhotoCreateForm
|
|||
|
|
buildingId={buildingId}
|
|||
|
|
residentReportId={residentReportId}
|
|||
|
|
onClose={() => setShowCreateForm(false)}
|
|||
|
|
onSuccess={() => {
|
|||
|
|
setShowCreateForm(false);
|
|||
|
|
loadPhotos();
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{isLoading ? (
|
|||
|
|
<div className="flex justify-center py-8">
|
|||
|
|
<Loader2 className="w-6 h-6 animate-spin text-slate-400" />
|
|||
|
|
</div>
|
|||
|
|
) : photos.length === 0 ? (
|
|||
|
|
<div className="text-center py-8 text-slate-400 text-sm">
|
|||
|
|
<ImageIcon className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
|||
|
|
<p>Фото отчеты не найдены</p>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|||
|
|
{photos.map(photo => (
|
|||
|
|
<WorkPhotoCard
|
|||
|
|
key={photo.id}
|
|||
|
|
photo={photo}
|
|||
|
|
onDelete={handleDelete}
|
|||
|
|
onSelect={onPhotoSelect}
|
|||
|
|
/>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
interface WorkPhotoCardProps {
|
|||
|
|
photo: WorkPhoto;
|
|||
|
|
onDelete: (id: number) => void;
|
|||
|
|
onSelect?: (photo: WorkPhoto) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const WorkPhotoCard: React.FC<WorkPhotoCardProps> = ({ photo, onDelete, onSelect }) => {
|
|||
|
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000/api';
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm hover:shadow-md transition-all">
|
|||
|
|
<div className="flex justify-between items-start mb-3">
|
|||
|
|
<div className="flex-1">
|
|||
|
|
<h5 className="font-black text-slate-800 text-sm mb-1">{photo.workName}</h5>
|
|||
|
|
<div className="flex items-center gap-2 text-xs text-slate-500">
|
|||
|
|
<Calendar className="w-3 h-3" />
|
|||
|
|
<span>{new Date(photo.workDate).toLocaleDateString('ru-RU')}</span>
|
|||
|
|
</div>
|
|||
|
|
{photo.description && (
|
|||
|
|
<p className="text-xs text-slate-600 mt-2">{photo.description}</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<button
|
|||
|
|
onClick={() => onDelete(photo.id)}
|
|||
|
|
className="p-1.5 text-slate-400 hover:text-red-500 transition-colors"
|
|||
|
|
>
|
|||
|
|
<X className="w-4 h-4" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="grid grid-cols-2 gap-2">
|
|||
|
|
{photo.photoBeforeUrl ? (
|
|||
|
|
<div className="relative aspect-video bg-slate-100 rounded-lg overflow-hidden">
|
|||
|
|
<img
|
|||
|
|
src={`${API_BASE_URL.replace('/api', '')}${photo.photoBeforeUrl}`}
|
|||
|
|
alt="До"
|
|||
|
|
className="w-full h-full object-cover"
|
|||
|
|
/>
|
|||
|
|
<div className="absolute bottom-1 left-1 bg-black/50 text-white text-[10px] px-1.5 py-0.5 rounded">
|
|||
|
|
До
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="aspect-video bg-slate-100 rounded-lg flex items-center justify-center">
|
|||
|
|
<ImageIcon className="w-8 h-8 text-slate-300" />
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{photo.photoAfterUrl ? (
|
|||
|
|
<div className="relative aspect-video bg-slate-100 rounded-lg overflow-hidden">
|
|||
|
|
<img
|
|||
|
|
src={`${API_BASE_URL.replace('/api', '')}${photo.photoAfterUrl}`}
|
|||
|
|
alt="После"
|
|||
|
|
className="w-full h-full object-cover"
|
|||
|
|
/>
|
|||
|
|
<div className="absolute bottom-1 left-1 bg-black/50 text-white text-[10px] px-1.5 py-0.5 rounded">
|
|||
|
|
После
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="aspect-video bg-slate-100 rounded-lg flex items-center justify-center">
|
|||
|
|
<ImageIcon className="w-8 h-8 text-slate-300" />
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
interface WorkPhotoCreateFormProps {
|
|||
|
|
buildingId?: string;
|
|||
|
|
residentReportId?: number;
|
|||
|
|
onClose: () => void;
|
|||
|
|
onSuccess: () => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const WorkPhotoCreateForm: React.FC<WorkPhotoCreateFormProps> = ({
|
|||
|
|
buildingId,
|
|||
|
|
residentReportId,
|
|||
|
|
onClose,
|
|||
|
|
onSuccess
|
|||
|
|
}) => {
|
|||
|
|
const [formData, setFormData] = useState({
|
|||
|
|
building_id: buildingId || '',
|
|||
|
|
resident_report_id: residentReportId || '',
|
|||
|
|
work_name: '',
|
|||
|
|
work_date: new Date().toISOString().split('T')[0],
|
|||
|
|
description: ''
|
|||
|
|
});
|
|||
|
|
const [photoBefore, setPhotoBefore] = useState<File | null>(null);
|
|||
|
|
const [photoAfter, setPhotoAfter] = useState<File | null>(null);
|
|||
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|||
|
|
|
|||
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
|
|||
|
|
if (!formData.building_id || !formData.work_name || !formData.work_date) {
|
|||
|
|
alert('Заполните обязательные поля');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
setIsSubmitting(true);
|
|||
|
|
|
|||
|
|
const formDataToSend = new FormData();
|
|||
|
|
formDataToSend.append('building_id', formData.building_id);
|
|||
|
|
if (formData.resident_report_id) {
|
|||
|
|
formDataToSend.append('resident_report_id', String(formData.resident_report_id));
|
|||
|
|
}
|
|||
|
|
formDataToSend.append('work_name', formData.work_name);
|
|||
|
|
formDataToSend.append('work_date', formData.work_date);
|
|||
|
|
if (formData.description) {
|
|||
|
|
formDataToSend.append('description', formData.description);
|
|||
|
|
}
|
|||
|
|
if (photoBefore) {
|
|||
|
|
formDataToSend.append('photo_before', photoBefore);
|
|||
|
|
}
|
|||
|
|
if (photoAfter) {
|
|||
|
|
formDataToSend.append('photo_after', photoAfter);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000/api';
|
|||
|
|
const response = await fetch(`${API_BASE_URL}/pr/work-photos`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
body: formDataToSend
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
throw new Error('Ошибка создания фото отчета');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onSuccess();
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Error creating work photo:', err);
|
|||
|
|
alert('Ошибка создания фото отчета');
|
|||
|
|
} finally {
|
|||
|
|
setIsSubmitting(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|||
|
|
<div className="bg-white rounded-3xl p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
|||
|
|
<div className="flex justify-between items-center mb-6">
|
|||
|
|
<h3 className="text-xl font-black text-slate-800">Добавить фото отчет</h3>
|
|||
|
|
<button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-xl">
|
|||
|
|
<X className="w-5 h-5 text-slate-400" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|||
|
|
{!buildingId && (
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
|
|||
|
|
Дом (ID)
|
|||
|
|
</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={formData.building_id}
|
|||
|
|
onChange={e => setFormData({ ...formData, building_id: e.target.value })}
|
|||
|
|
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
|
|||
|
|
required
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
|
|||
|
|
Название работы
|
|||
|
|
</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={formData.work_name}
|
|||
|
|
onChange={e => setFormData({ ...formData, work_name: e.target.value })}
|
|||
|
|
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
|
|||
|
|
placeholder="Например: Ремонт подъезда"
|
|||
|
|
required
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
|
|||
|
|
Дата работы
|
|||
|
|
</label>
|
|||
|
|
<input
|
|||
|
|
type="date"
|
|||
|
|
value={formData.work_date}
|
|||
|
|
onChange={e => setFormData({ ...formData, work_date: e.target.value })}
|
|||
|
|
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
|
|||
|
|
required
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
|
|||
|
|
Описание (что сделано)
|
|||
|
|
</label>
|
|||
|
|
<textarea
|
|||
|
|
value={formData.description}
|
|||
|
|
onChange={e => setFormData({ ...formData, description: e.target.value })}
|
|||
|
|
className="w-full p-3 border border-slate-200 rounded-xl text-sm resize-none"
|
|||
|
|
rows={3}
|
|||
|
|
placeholder="Опишите выполненные работы..."
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="grid grid-cols-2 gap-4">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
|
|||
|
|
Фото "До"
|
|||
|
|
</label>
|
|||
|
|
<input
|
|||
|
|
type="file"
|
|||
|
|
accept="image/*"
|
|||
|
|
onChange={e => setPhotoBefore(e.target.files?.[0] || null)}
|
|||
|
|
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
|
|||
|
|
Фото "После"
|
|||
|
|
</label>
|
|||
|
|
<input
|
|||
|
|
type="file"
|
|||
|
|
accept="image/*"
|
|||
|
|
onChange={e => setPhotoAfter(e.target.files?.[0] || null)}
|
|||
|
|
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex gap-3 pt-4">
|
|||
|
|
<button
|
|||
|
|
type="submit"
|
|||
|
|
disabled={isSubmitting}
|
|||
|
|
className="flex-1 px-6 py-3 bg-primary-600 text-white rounded-xl text-xs font-black uppercase tracking-widest disabled:opacity-50"
|
|||
|
|
>
|
|||
|
|
{isSubmitting ? 'Создание...' : 'Создать фото отчет'}
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={onClose}
|
|||
|
|
className="px-6 py-3 bg-slate-100 text-slate-600 rounded-xl text-xs font-black uppercase tracking-widest"
|
|||
|
|
>
|
|||
|
|
Отмена
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|