Initial commit MKD fixes
This commit is contained in:
55
components/objects/BuildingsRegistry.tsx
Executable file
55
components/objects/BuildingsRegistry.tsx
Executable file
@@ -0,0 +1,55 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Building, DomaApplication } from '../../types';
|
||||
import { PerformanceCard } from './PerformanceCard';
|
||||
import { Search, Filter } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
buildings: Building[];
|
||||
applications: DomaApplication[];
|
||||
onSelectBuilding: (b: Building) => void;
|
||||
}
|
||||
|
||||
export const BuildingsRegistry: React.FC<Props> = ({ buildings, applications, onSelectBuilding }) => {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filtered = buildings.filter(b =>
|
||||
b.passport.address.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const getAppsForBuilding = (building: Building) => {
|
||||
return applications.filter(app => app.address === building.passport.address);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
<div className="flex gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по адресу дома..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<button className="p-2.5 bg-white border border-slate-200 rounded-xl text-slate-500"><Filter className="w-5 h-5"/></button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{filtered.map(b => (
|
||||
<PerformanceCard
|
||||
key={b.id}
|
||||
title={b.passport.address}
|
||||
subtitle={`${b.passport.general.floors} эт. • ${b.passport.general.totalArea} м²`}
|
||||
applications={getAppsForBuilding(b)}
|
||||
onClick={() => onSelectBuilding(b)}
|
||||
type="building"
|
||||
/>
|
||||
))}
|
||||
{filtered.length === 0 && <p className="py-10 text-center text-slate-400 text-sm">Дома не найдены</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
108
components/objects/DeleteConfirmModal.tsx
Executable file
108
components/objects/DeleteConfirmModal.tsx
Executable file
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, X } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
itemName: string;
|
||||
isLoading?: boolean;
|
||||
warningMessage?: string;
|
||||
}
|
||||
|
||||
export const DeleteConfirmModal: React.FC<Props> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
itemName,
|
||||
isLoading = false,
|
||||
warningMessage,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/80 backdrop-blur-md animate-fade-in"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-2xl w-full max-w-md shadow-2xl animate-slide-up"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-slate-200">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-red-50 rounded-lg">
|
||||
<AlertTriangle className="w-6 h-6 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-black text-slate-900">{title}</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="p-2 hover:bg-slate-100 rounded-full transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<div className="bg-slate-50 rounded-xl p-4 border border-slate-200">
|
||||
<p className="text-sm font-bold text-slate-700">
|
||||
<span className="text-slate-500">Удалить:</span> {itemName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{warningMessage && (
|
||||
<div className="mt-4 bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<p className="text-sm text-amber-800 font-medium">{warningMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-sm text-slate-600">
|
||||
Это действие нельзя отменить. Все данные, связанные с этим элементом, будут удалены.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-slate-200 flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-slate-200 text-slate-700 font-bold hover:bg-slate-50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={isLoading}
|
||||
className="flex-1 px-4 py-3 rounded-xl bg-red-600 text-white font-bold hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white/50 border-t-white rounded-full animate-spin" />
|
||||
<span>Удаление...</span>
|
||||
</>
|
||||
) : (
|
||||
'Удалить'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
117
components/objects/DistrictStaffModal.tsx
Executable file
117
components/objects/DistrictStaffModal.tsx
Executable file
@@ -0,0 +1,117 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Employee, District } from '../../types';
|
||||
import { X, User, Phone, MessageCircle, MapPin } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
district: District;
|
||||
employees: Employee[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DistrictStaffModal: React.FC<Props> = ({ district, employees, onClose }) => {
|
||||
const districtEmployees = employees.filter(emp => {
|
||||
const ids = emp.assignedDistrictIds?.length ? emp.assignedDistrictIds : (emp.assignedDistrictId ? [emp.assignedDistrictId] : []);
|
||||
return ids.includes(district.id);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onClose}>
|
||||
<div
|
||||
className="bg-white rounded-2xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-slate-200">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-800">Персонал участка</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">{district.name}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{districtEmployees.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<User className="w-12 h-12 mx-auto mb-4 opacity-20" />
|
||||
<p className="font-bold">На этом участке нет закрепленных сотрудников</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{districtEmployees.map(emp => (
|
||||
<div
|
||||
key={emp.id}
|
||||
className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-slate-100 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="w-12 h-12 rounded-full bg-white flex items-center justify-center text-primary-600 font-black border border-slate-200 shrink-0">
|
||||
{emp.photoUrl ? (
|
||||
<img
|
||||
src={emp.photoUrl.startsWith('http')
|
||||
? emp.photoUrl
|
||||
: `${import.meta.env.VITE_API_BASE_URL?.replace('/api', '') || 'http://localhost:4000'}${emp.photoUrl}`}
|
||||
alt={emp.name}
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
const parent = target.parentElement;
|
||||
if (parent) {
|
||||
const fallback = document.createElement('div');
|
||||
fallback.className = 'w-12 h-12 rounded-full bg-white flex items-center justify-center text-primary-600 font-black border border-slate-200';
|
||||
fallback.textContent = emp.name.split(' ').map(n => n[0]).join('');
|
||||
parent.appendChild(fallback);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
emp.name.split(' ').map(n => n[0]).join('')
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-slate-800 text-sm">{emp.name}</p>
|
||||
<p className="text-xs text-slate-500 flex items-center gap-1 mt-1">
|
||||
<MapPin className="w-3 h-3"/> {emp.position}
|
||||
</p>
|
||||
{emp.phone && (
|
||||
<p className="text-xs text-slate-400 mt-1">{emp.phone}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{emp.phone && (
|
||||
<a
|
||||
href={`tel:${emp.phone}`}
|
||||
className="p-2 text-emerald-600 hover:bg-emerald-50 rounded-lg transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Phone className="w-4 h-4"/>
|
||||
</a>
|
||||
)}
|
||||
{emp.messengerLogins && emp.messengerLogins.length > 0 && (
|
||||
<button
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// Здесь можно добавить логику открытия мессенджера
|
||||
}}
|
||||
>
|
||||
<MessageCircle className="w-4 h-4"/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
202
components/objects/DistrictsSummary.tsx
Executable file
202
components/objects/DistrictsSummary.tsx
Executable file
@@ -0,0 +1,202 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { District, Building, DomaApplication, Employee } from '../../types';
|
||||
import { PerformanceCard } from './PerformanceCard';
|
||||
import { EditDistrictModal } from './EditDistrictModal';
|
||||
import { backendApi } from '../../services/apiClient';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
aggregatedData: any;
|
||||
onSelectDistrict: (d: District) => void;
|
||||
canManage: boolean;
|
||||
onAddDistrict: (payload: { name: string; managerName: string }) => void;
|
||||
onDeleteDistrict: (d: District) => void;
|
||||
onViewStaff: (d: District) => void;
|
||||
onDistrictUpdated?: () => void;
|
||||
role: string;
|
||||
employees?: Employee[];
|
||||
}
|
||||
|
||||
export const DistrictsSummary: React.FC<Props> = ({
|
||||
aggregatedData,
|
||||
onSelectDistrict,
|
||||
canManage,
|
||||
onAddDistrict,
|
||||
onDeleteDistrict,
|
||||
onViewStaff,
|
||||
onDistrictUpdated,
|
||||
role,
|
||||
employees = [],
|
||||
}) => {
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [editDistrict, setEditDistrict] = useState<District | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [managerName, setManagerName] = useState('Не назначен');
|
||||
const [availableEmployees, setAvailableEmployees] = useState<Employee[]>([]);
|
||||
const [loadingEmployees, setLoadingEmployees] = useState(false);
|
||||
|
||||
// Загружаем список сотрудников при открытии формы
|
||||
useEffect(() => {
|
||||
if (isCreateOpen) {
|
||||
fetchEmployees();
|
||||
}
|
||||
}, [isCreateOpen]);
|
||||
|
||||
const fetchEmployees = async () => {
|
||||
try {
|
||||
setLoadingEmployees(true);
|
||||
const data = await backendApi.getEmployees();
|
||||
setAvailableEmployees(Array.isArray(data) ? data : []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching employees:', error);
|
||||
setAvailableEmployees([]);
|
||||
} finally {
|
||||
setLoadingEmployees(false);
|
||||
}
|
||||
};
|
||||
|
||||
const UNASSIGNED_MANAGER = 'Не назначен';
|
||||
|
||||
// Фильтруем сотрудников: только мастера и начальники участка
|
||||
const eligibleManagers = availableEmployees.filter(emp => {
|
||||
if (emp.status !== 'active') return false;
|
||||
const positionLower = emp.position.toLowerCase();
|
||||
return positionLower.includes('мастер') || positionLower.includes('начальник участка');
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
onAddDistrict({ name: name.trim(), managerName: managerName === '' ? UNASSIGNED_MANAGER : managerName.trim() });
|
||||
setIsCreateOpen(false);
|
||||
setName('');
|
||||
setManagerName(UNASSIGNED_MANAGER);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="flex justify-between items-end px-1">
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-xl font-bold text-slate-800">Обзор участков</h2>
|
||||
<span className="text-[10px] text-slate-500 font-medium uppercase mt-1">
|
||||
{role === 'MASTER' ? 'Моя зона ответственности' : 'Контроль по всей компании'}
|
||||
</span>
|
||||
</div>
|
||||
{canManage && (
|
||||
<button
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
className="bg-primary-600 text-white p-2 rounded-xl shadow-lg flex items-center gap-2 px-3 text-xs font-bold active:scale-95 transition-transform"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Участок
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCreateOpen && canManage && (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white border border-slate-200 rounded-2xl p-4 flex flex-col md:flex-row gap-3 items-stretch md:items-end shadow-sm"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<label className="block text-[10px] font-black uppercase text-slate-400 mb-1">
|
||||
Название участка
|
||||
</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Например: Участок №3 (Южный)"
|
||||
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-[10px] font-black uppercase text-slate-400 mb-1">
|
||||
Начальник участка
|
||||
</label>
|
||||
{loadingEmployees ? (
|
||||
<div className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm text-slate-400 bg-slate-50">
|
||||
Загрузка сотрудников...
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={managerName}
|
||||
onChange={(e) => setManagerName(e.target.value)}
|
||||
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:ring-2 focus:ring-primary-500 outline-none bg-white"
|
||||
>
|
||||
<option value={UNASSIGNED_MANAGER}>Не назначен</option>
|
||||
{eligibleManagers.length === 0 ? (
|
||||
<option value="" disabled>Нет доступных мастеров или начальников участка</option>
|
||||
) : (
|
||||
eligibleManagers.map(emp => (
|
||||
<option key={emp.id} value={emp.name}>
|
||||
{emp.name} - {emp.position}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsCreateOpen(false);
|
||||
setName('');
|
||||
setManagerName('Не назначен');
|
||||
}}
|
||||
className="px-4 py-2 rounded-xl border border-slate-200 text-xs font-bold text-slate-500 bg-white hover:bg-slate-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 rounded-xl bg-primary-600 text-white text-xs font-bold hover:bg-primary-700 active:scale-95 transition-transform"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{Object.values(aggregatedData).map((data: any) => {
|
||||
const assignedToDistrict = employees.filter(emp => {
|
||||
const ids = emp.assignedDistrictIds?.length ? emp.assignedDistrictIds : (emp.assignedDistrictId ? [emp.assignedDistrictId] : []);
|
||||
return ids.includes(data.district.id);
|
||||
});
|
||||
const staffCount = assignedToDistrict.length;
|
||||
const managerFromDb = data.district.managerName?.trim();
|
||||
const managerDisplay = (managerFromDb && managerFromDb !== UNASSIGNED_MANAGER)
|
||||
? managerFromDb
|
||||
: (assignedToDistrict.length > 0 ? assignedToDistrict[0].name : UNASSIGNED_MANAGER);
|
||||
return (
|
||||
<PerformanceCard
|
||||
key={data.district.id}
|
||||
title={data.district.name}
|
||||
subtitle={managerDisplay}
|
||||
applications={data.applications}
|
||||
onClick={() => onSelectDistrict(data.district)}
|
||||
type="district"
|
||||
buildingCount={data.buildings.length}
|
||||
staffCount={staffCount}
|
||||
onDelete={canManage ? () => onDeleteDistrict(data.district) : undefined}
|
||||
onViewStaff={() => onViewStaff(data.district)}
|
||||
onEdit={canManage ? () => setEditDistrict(data.district) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{editDistrict && (
|
||||
<EditDistrictModal
|
||||
district={editDistrict}
|
||||
onClose={() => setEditDistrict(null)}
|
||||
onSaved={() => {
|
||||
onDistrictUpdated?.();
|
||||
}}
|
||||
employees={employees}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
101
components/objects/EditDistrictModal.tsx
Executable file
101
components/objects/EditDistrictModal.tsx
Executable file
@@ -0,0 +1,101 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { District, Employee } from '../../types';
|
||||
import { backendApi } from '../../services/apiClient';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
const UNASSIGNED_MANAGER = 'Не назначен';
|
||||
|
||||
interface Props {
|
||||
district: District | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
employees: Employee[];
|
||||
}
|
||||
|
||||
export const EditDistrictModal: React.FC<Props> = ({ district, onClose, onSaved, employees }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [managerName, setManagerName] = useState(UNASSIGNED_MANAGER);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (district) {
|
||||
setName(district.name);
|
||||
setManagerName(district.managerName?.trim() || UNASSIGNED_MANAGER);
|
||||
}
|
||||
}, [district]);
|
||||
|
||||
const eligibleManagers = employees.filter(emp => {
|
||||
if (emp.status !== 'active') return false;
|
||||
const positionLower = (emp.position || '').toLowerCase();
|
||||
return positionLower.includes('мастер') || positionLower.includes('начальник участка');
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!district || !name.trim()) return;
|
||||
try {
|
||||
setSaving(true);
|
||||
await backendApi.updateDistrict(district.id, {
|
||||
name: name.trim(),
|
||||
managerName: managerName === '' ? UNASSIGNED_MANAGER : managerName.trim(),
|
||||
});
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Ошибка сохранения участка');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!district) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/80 backdrop-blur-md" onClick={onClose}>
|
||||
<div
|
||||
className="bg-white rounded-2xl w-full max-w-md shadow-2xl"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-between items-center px-6 py-4 border-b border-slate-200">
|
||||
<h2 className="text-lg font-black text-slate-800">Редактировать участок</h2>
|
||||
<button type="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="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-[10px] font-black uppercase text-slate-500 mb-1">Название участка</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-black uppercase text-slate-500 mb-1">Начальник участка</label>
|
||||
<select
|
||||
value={managerName}
|
||||
onChange={e => setManagerName(e.target.value)}
|
||||
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:ring-2 focus:ring-primary-500 outline-none bg-white"
|
||||
>
|
||||
<option value={UNASSIGNED_MANAGER}>Не назначен</option>
|
||||
{eligibleManagers.map(emp => (
|
||||
<option key={emp.id} value={emp.name}>{emp.name} — {emp.position}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button type="button" onClick={onClose} className="px-4 py-2 rounded-xl border border-slate-200 text-slate-600 text-sm font-bold hover:bg-slate-50">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="px-4 py-2 rounded-xl bg-primary-600 text-white text-sm font-bold hover:bg-primary-700 disabled:opacity-50">
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
214
components/objects/MoveBuildingsModal.tsx
Executable file
214
components/objects/MoveBuildingsModal.tsx
Executable file
@@ -0,0 +1,214 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { District, Building } from '../../types';
|
||||
import { MapPin, ArrowRight, X, AlertCircle } from 'lucide-react';
|
||||
import { backendApi } from '../../services/apiClient';
|
||||
import { storageService } from '../../services/storageService';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onComplete: () => void;
|
||||
sourceDistrict: District;
|
||||
buildings: Building[];
|
||||
allDistricts: District[];
|
||||
}
|
||||
|
||||
export const MoveBuildingsModal: React.FC<Props> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onComplete,
|
||||
sourceDistrict,
|
||||
buildings,
|
||||
allDistricts,
|
||||
}) => {
|
||||
const [targetDistrictId, setTargetDistrictId] = useState<string>('');
|
||||
const [isMoving, setIsMoving] = useState(false);
|
||||
|
||||
// Исключаем текущий участок из списка
|
||||
const availableDistricts = allDistricts.filter(d => d.id !== sourceDistrict.id);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && availableDistricts.length > 0 && !targetDistrictId) {
|
||||
setTargetDistrictId(availableDistricts[0].id);
|
||||
}
|
||||
}, [isOpen, availableDistricts, targetDistrictId]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleMove = async () => {
|
||||
if (!targetDistrictId || buildings.length === 0) return;
|
||||
|
||||
setIsMoving(true);
|
||||
try {
|
||||
// Перемещаем каждый дом
|
||||
for (const building of buildings) {
|
||||
const updatedBuilding = {
|
||||
...building,
|
||||
districtId: targetDistrictId,
|
||||
};
|
||||
|
||||
// Обновляем в localStorage
|
||||
storageService.saveBuildingData(updatedBuilding);
|
||||
|
||||
// Обновляем на сервере
|
||||
try {
|
||||
await backendApi.updateBuilding(updatedBuilding);
|
||||
} catch (error) {
|
||||
console.error(`Failed to move building ${building.id}:`, error);
|
||||
// Продолжаем с другими домами
|
||||
}
|
||||
}
|
||||
|
||||
onComplete();
|
||||
} catch (error) {
|
||||
console.error('Failed to move buildings:', error);
|
||||
alert('Ошибка при перемещении домов. Попробуйте еще раз.');
|
||||
} finally {
|
||||
setIsMoving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (availableDistricts.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/80 backdrop-blur-md animate-fade-in"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-2xl w-full max-w-md shadow-2xl animate-slide-up"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-amber-50 rounded-lg">
|
||||
<AlertCircle className="w-6 h-6 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-black text-slate-900">Нет доступных участков</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Для перемещения домов нужен хотя бы один другой участок
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
Создайте новый участок, чтобы переместить {buildings.length} {buildings.length === 1 ? 'дом' : 'домов'} из участка "{sourceDistrict.name}".
|
||||
</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full px-4 py-3 rounded-xl bg-slate-100 text-slate-700 font-bold hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
Понятно
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/80 backdrop-blur-md animate-fade-in"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-2xl w-full max-w-lg shadow-2xl animate-slide-up max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-slate-200 sticky top-0 bg-white rounded-t-2xl">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary-50 rounded-lg">
|
||||
<MapPin className="w-6 h-6 text-primary-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-black text-slate-900">Перемещение домов</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Выберите участок для перемещения {buildings.length} {buildings.length === 1 ? 'дома' : 'домов'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isMoving}
|
||||
className="p-2 hover:bg-slate-100 rounded-full transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="bg-slate-50 rounded-xl p-4 border border-slate-200">
|
||||
<p className="text-xs font-bold text-slate-500 uppercase mb-2">Из участка:</p>
|
||||
<p className="text-sm font-bold text-slate-800">{sourceDistrict.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<ArrowRight className="w-5 h-5 text-slate-400" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase mb-2">
|
||||
В участок:
|
||||
</label>
|
||||
<select
|
||||
value={targetDistrictId}
|
||||
onChange={(e) => setTargetDistrictId(e.target.value)}
|
||||
disabled={isMoving}
|
||||
className="w-full p-3 bg-white border border-slate-300 rounded-xl text-sm font-bold text-slate-800 outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-50"
|
||||
>
|
||||
{availableDistricts.map(district => (
|
||||
<option key={district.id} value={district.id}>
|
||||
{district.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{buildings.length > 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<p className="text-xs font-bold text-amber-800 mb-2">Дома для перемещения:</p>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||
{buildings.map(building => (
|
||||
<div key={building.id} className="text-sm text-amber-900">
|
||||
• {building.passport.address}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-slate-200 flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isMoving}
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-slate-200 text-slate-700 font-bold hover:bg-slate-50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMove}
|
||||
disabled={isMoving || !targetDistrictId}
|
||||
className="flex-1 px-4 py-3 rounded-xl bg-primary-600 text-white font-bold hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{isMoving ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white/50 border-t-white rounded-full animate-spin" />
|
||||
<span>Перемещение...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
<span>Переместить</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
107
components/objects/PerformanceCard.tsx
Executable file
107
components/objects/PerformanceCard.tsx
Executable file
@@ -0,0 +1,107 @@
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { DomaApplication } from '../../types';
|
||||
import { LayoutGrid, Building2, Inbox, AlertTriangle, ChevronRight, Users, Trash2, Pencil } from 'lucide-react';
|
||||
|
||||
const UNASSIGNED_MANAGER = 'Не назначен';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
applications: DomaApplication[];
|
||||
onClick: () => void;
|
||||
type: 'district' | 'building';
|
||||
buildingCount?: number;
|
||||
staffCount?: number;
|
||||
onDelete?: () => void;
|
||||
onViewStaff?: () => void;
|
||||
onEdit?: () => void;
|
||||
}
|
||||
|
||||
export const PerformanceCard: React.FC<Props> = ({ title, subtitle, applications, onClick, type, buildingCount, staffCount, onDelete, onViewStaff, onEdit }) => {
|
||||
const stats = useMemo(() => {
|
||||
const now = new Date();
|
||||
const active = applications.filter(a => a.status === 'new' || a.status === 'in_progress');
|
||||
const overdue = active.filter(a => a.deadlineAt && new Date(a.deadlineAt) < now);
|
||||
const newApps = applications.filter(a => a.status === 'new');
|
||||
const performance = active.length > 0 ? Math.round((1 - (overdue.length / active.length)) * 100) : 100;
|
||||
return { overdue, newApps, performance };
|
||||
}, [applications]);
|
||||
|
||||
const perfColor = stats.performance > 85 ? 'text-emerald-500' : stats.performance > 60 ? 'text-amber-500' : 'text-red-500';
|
||||
const Icon = type === 'district' ? LayoutGrid : Building2;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow cursor-pointer active:scale-[0.99] flex gap-4 items-center group relative"
|
||||
style={{ position: 'relative' }}
|
||||
>
|
||||
<div className="p-3 bg-primary-50 rounded-lg text-primary-600 hidden sm:block">
|
||||
<Icon className="w-6 h-6"/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pr-12">
|
||||
<h4 className="font-bold text-slate-800 truncate">{title}</h4>
|
||||
{(subtitle != null && subtitle !== '') && (
|
||||
<p className={`text-xs truncate ${subtitle === UNASSIGNED_MANAGER ? 'text-red-600 font-semibold' : 'text-slate-500'}`}>{subtitle}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2 text-xs">
|
||||
{type === 'district' && (
|
||||
<div className="flex items-center gap-1.5 text-slate-500">
|
||||
<Building2 className="w-3.5 h-3.5" /> {buildingCount} домов
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 text-slate-500">
|
||||
<Inbox className="w-3.5 h-3.5 text-blue-500" /> <span className="font-bold text-slate-700">{stats.newApps.length}</span> новых
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-slate-500">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-red-500" /> <span className="font-bold text-slate-700">{stats.overdue.length}</span> просрочено
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right flex items-center gap-4">
|
||||
{type === 'district' && onViewStaff && (
|
||||
<div className="flex items-center gap-2">
|
||||
{staffCount !== undefined && (
|
||||
<span className="text-sm font-bold text-slate-600 bg-slate-100 px-2 py-1 rounded-lg">
|
||||
{staffCount}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={(e) => { e.stopPropagation(); onViewStaff(); }} className="p-2 bg-slate-50 hover:bg-primary-50 text-slate-400 hover:text-primary-600 rounded-lg transition-colors border border-slate-100">
|
||||
<Users className="w-5 h-5"/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden md:block">
|
||||
<p className={`text-2xl font-black ${perfColor}`}>{stats.performance}%</p>
|
||||
<p className="text-[10px] text-slate-400 font-bold uppercase">Успеваемость</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-slate-300 group-hover:text-primary-400 transition-colors" />
|
||||
</div>
|
||||
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1 z-30">
|
||||
{type === 'district' && onEdit && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); e.preventDefault(); onEdit(); }}
|
||||
className="p-1.5 bg-white hover:bg-primary-50 text-slate-400 hover:text-primary-600 rounded-full transition-all border border-slate-200 shadow-md hover:shadow-lg"
|
||||
title="Редактировать участок"
|
||||
type="button"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); e.preventDefault(); onDelete(); }}
|
||||
className="p-1.5 bg-white hover:bg-red-50 text-slate-400 hover:text-red-500 rounded-full transition-all border border-slate-200 shadow-md hover:shadow-lg"
|
||||
title="Удалить"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
39
components/objects/StaffRegistry.tsx
Executable file
39
components/objects/StaffRegistry.tsx
Executable file
@@ -0,0 +1,39 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Employee, District } from '../../types';
|
||||
import { User, Phone, MessageCircle, MapPin } from 'lucide-react';
|
||||
import { MOCK_EMPLOYEES } from '../../constants';
|
||||
|
||||
export const StaffRegistry: React.FC<{ districts: District[] }> = ({ districts }) => {
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm mb-6">
|
||||
<h3 className="font-bold text-slate-700 text-sm mb-4">Штат сотрудников по участкам</h3>
|
||||
<div className="space-y-3">
|
||||
{MOCK_EMPLOYEES.map(emp => {
|
||||
const district = districts.find(d => d.id === emp.assignedDistrictId);
|
||||
return (
|
||||
<div key={emp.id} className="flex items-center justify-between p-3 bg-slate-50 rounded-xl border border-slate-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-white flex items-center justify-center text-primary-600 font-black border border-slate-200">
|
||||
{emp.name.split(' ').map(n => n[0]).join('')}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-slate-800 text-sm">{emp.name}</p>
|
||||
<p className="text-[10px] text-slate-500 flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3"/> {district?.name || 'Не привязан'} • {emp.position}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button className="p-2 text-emerald-600 hover:bg-emerald-50 rounded-lg"><Phone className="w-4 h-4"/></button>
|
||||
<button className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg"><MessageCircle className="w-4 h-4"/></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user