Files
mkd/components/pr/WorkPhotosManager.tsx
2026-02-04 00:17:04 +05:00

359 lines
13 KiB
TypeScript
Executable File
Raw Permalink 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 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>
);
};