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

317 lines
15 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, Loader2, Image as ImageIcon, Calendar, Building2, FileText, X } from 'lucide-react';
const UPLOADS_BASE = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000/api').replace(/\/api\/?$/, '') || 'http://localhost:4000';
export const WorkPhotosDirectory: React.FC = () => {
const [photos, setPhotos] = useState<WorkPhoto[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false);
const loadPhotos = async () => {
try {
setIsLoading(true);
setLoadError(null);
const data = await apiClient.get<WorkPhoto[]>('/pr/work-photos');
setPhotos(Array.isArray(data) ? data : []);
} catch (err: any) {
console.error('Error loading work photos:', err);
setPhotos([]);
setLoadError(err?.message || 'Не удалось загрузить список. Проверьте подключение к серверу.');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadPhotos();
}, []);
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">
<h3 className="font-black text-slate-800 text-sm uppercase tracking-wider flex items-center gap-2">
<FileText className="w-4 h-4 text-primary-500" />
Справочник фотоотчётов (до / после)
</h3>
<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
onClose={() => setShowCreateForm(false)}
onSuccess={() => {
setShowCreateForm(false);
loadPhotos();
}}
/>
)}
{loadError && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 flex items-center justify-between gap-4">
<p className="text-sm text-amber-800">{loadError}</p>
<button
type="button"
onClick={() => loadPhotos()}
className="px-4 py-2 bg-amber-600 text-white rounded-xl text-xs font-bold hover:bg-amber-700"
>
Повторить
</button>
</div>
)}
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-slate-400" />
</div>
) : photos.length === 0 && !loadError ? (
<div className="text-center py-12 text-slate-400 bg-slate-50 rounded-2xl border border-slate-100">
<ImageIcon className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p className="text-sm font-bold">Записей пока нет</p>
<p className="text-xs mt-1">Добавьте фотоотчёт с полями: фото до, фото после, описание работы, дата работы, дом.</p>
</div>
) : photos.length === 0 ? null : (
<div className="bg-white rounded-2xl border border-slate-200 overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50/80">
<th className="px-4 py-3 font-black text-slate-600 uppercase tracking-wider whitespace-nowrap">Фото до</th>
<th className="px-4 py-3 font-black text-slate-600 uppercase tracking-wider whitespace-nowrap">Фото после</th>
<th className="px-4 py-3 font-black text-slate-600 uppercase tracking-wider whitespace-nowrap">Описание работы</th>
<th className="px-4 py-3 font-black text-slate-600 uppercase tracking-wider whitespace-nowrap">Дата работы</th>
<th className="px-4 py-3 font-black text-slate-600 uppercase tracking-wider whitespace-nowrap">Дом</th>
<th className="px-4 py-3 font-black text-slate-600 uppercase tracking-wider w-20"> </th>
</tr>
</thead>
<tbody>
{photos.map((photo) => (
<tr key={photo.id} className="border-b border-slate-100 hover:bg-slate-50/50 transition-colors">
<td className="px-4 py-3 align-top">
{(photo as any).photoBeforeUrl || photo.photoBeforeUrl ? (
<img
src={UPLOADS_BASE + ((photo as any).photoBeforeUrl || photo.photoBeforeUrl)}
alt="До"
className="w-24 h-20 object-cover rounded-lg border border-slate-200"
/>
) : (
<div className="w-24 h-20 rounded-lg border border-dashed border-slate-200 flex items-center justify-center bg-slate-50">
<ImageIcon className="w-6 h-6 text-slate-300" />
</div>
)}
</td>
<td className="px-4 py-3 align-top">
{(photo as any).photoAfterUrl || photo.photoAfterUrl ? (
<img
src={UPLOADS_BASE + ((photo as any).photoAfterUrl || photo.photoAfterUrl)}
alt="После"
className="w-24 h-20 object-cover rounded-lg border border-slate-200"
/>
) : (
<div className="w-24 h-20 rounded-lg border border-dashed border-slate-200 flex items-center justify-center bg-slate-50">
<ImageIcon className="w-6 h-6 text-slate-300" />
</div>
)}
</td>
<td className="px-4 py-3 max-w-xs">
<span className="font-bold text-slate-800">{(photo as any).workName || photo.workName}</span>
{((photo as any).description || photo.description) && (
<p className="text-slate-600 text-xs mt-1">{(photo as any).description || photo.description}</p>
)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-slate-700">
<span className="flex items-center gap-1">
<Calendar className="w-3.5 h-3.5 text-slate-400" />
{new Date((photo as any).workDate || photo.workDate).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' })}
</span>
</td>
<td className="px-4 py-3">
<span className="flex items-center gap-1 text-slate-700">
<Building2 className="w-3.5 h-3.5 text-slate-400" />
{(photo as any).address || photo.address || (photo as any).buildingId || photo.buildingId || '—'}
</span>
</td>
<td className="px-4 py-3">
<button
onClick={() => handleDelete(photo.id)}
className="p-1.5 text-slate-400 hover:text-red-500 rounded-lg hover:bg-red-50 transition-colors"
title="Удалить"
>
<X className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
};
interface WorkPhotoCreateFormProps {
onClose: () => void;
onSuccess: () => void;
}
const WorkPhotoCreateForm: React.FC<WorkPhotoCreateFormProps> = ({ onClose, onSuccess }) => {
const [formData, setFormData] = useState({
building_id: '',
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 [buildings, setBuildings] = useState<{ id: string; address?: string }[]>([]);
useEffect(() => {
apiClient.get<{ id: string; passport?: { address?: string } }[]>('/buildings').then((list) => {
setBuildings((list || []).map((b) => ({ id: b.id, address: b.passport?.address })));
}).catch(() => setBuildings([]));
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.building_id || !formData.work_name || !formData.work_date) {
alert('Заполните: дом, описание работы, дата работы');
return;
}
if (!photoBefore || !photoAfter) {
alert('Загрузите оба фото: «До» и «После»');
return;
}
try {
setIsSubmitting(true);
const fd = new FormData();
fd.append('building_id', formData.building_id);
fd.append('work_name', formData.work_name);
fd.append('work_date', formData.work_date);
if (formData.description) fd.append('description', formData.description);
fd.append('photo_before', photoBefore);
fd.append('photo_after', photoAfter);
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000/api';
const res = await fetch(`${API_BASE}/pr/work-photos`, { method: 'POST', body: fd });
if (!res.ok) throw new Error('Ошибка создания');
onSuccess();
} catch (err) {
console.error(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-2xl p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-xl">
<div className="flex justify-between items-center mb-6">
<h3 className="text-lg 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-500" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-bold text-slate-600 uppercase tracking-wider mb-2">Дом *</label>
<select
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
>
<option value="">Выберите дом</option>
{buildings.map((b) => (
<option key={b.id} value={b.id}>{b.address || b.id}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-bold 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-bold 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-bold 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={2}
placeholder="Что сделано..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold 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"
required
/>
</div>
<div>
<label className="block text-xs font-bold 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"
required
/>
</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 disabled:opacity-50">
{isSubmitting ? 'Создание...' : 'Создать'}
</button>
<button type="button" onClick={onClose} className="px-6 py-3 border border-slate-200 rounded-xl text-xs font-bold text-slate-600">
Отмена
</button>
</div>
</form>
</div>
</div>
);
};