Files
mkd/components/building/ResidentsView.tsx

631 lines
40 KiB
TypeScript
Raw Normal View History

2026-02-04 00:17:04 +05:00
import React, { useMemo, useState } from 'react';
import { Building, PersonalAccount } from '../../types';
import { Smile, Frown, Meh, MessageCircle, X, Check, UserCheck, Search, User, Phone, Edit2, Save } from 'lucide-react';
export const ResidentsView: React.FC<{ building: Building, setBuilding: React.Dispatch<React.SetStateAction<Building>>, isEditing: boolean }> = ({ building, setBuilding, isEditing }) => {
const accounts = building.accounts || [];
const [showSelectModal, setShowSelectModal] = useState(false);
const [selectedContacts, setSelectedContacts] = useState<Map<string, 'chairman' | 'activist' | 'resident'>>(new Map());
const [searchTerm, setSearchTerm] = useState('');
const [selectedResident, setSelectedResident] = useState<{ id: string; name: string; apartment: string; phone?: string; accountId?: string; personType?: 'owner' | 'registered'; personIndex?: number } | null>(null);
const [isEditingResident, setIsEditingResident] = useState(false);
const [editFormData, setEditFormData] = useState({ fullName: '', phone: '' });
const contactsFromAccounts = useMemo(() => {
const res: Array<{ name: string; apartment: string; phone?: string; mood: 'happy' | 'angry' | 'neutral'; accountId?: string; personType?: 'owner' | 'registered'; personIndex?: number }> = [];
const sentimentToMood = (sentiment?: string): 'happy' | 'angry' | 'neutral' => {
if (!sentiment) return 'neutral';
if (sentiment === 'positive' || sentiment === 'loyal') return 'happy';
if (sentiment === 'negative' || sentiment === 'toxic') return 'angry';
return 'neutral';
};
for (const acc of accounts) {
const apt = acc.apartmentNumber || '';
// Owners
for (let i = 0; i < (acc.owners || []).length; i++) {
const owner = acc.owners[i];
if (!owner?.fullName) continue;
res.push({
name: owner.fullName,
apartment: apt,
phone: owner.phone,
mood: sentimentToMood(owner.residentProfile?.sentiment),
accountId: acc.id,
personType: 'owner',
personIndex: i
});
}
// Registered
for (let i = 0; i < (acc.registered || []).length; i++) {
const reg = acc.registered[i];
if (!reg?.fullName) continue;
res.push({
name: reg.fullName,
apartment: apt,
phone: reg.phone,
mood: sentimentToMood(reg.residentProfile?.sentiment),
accountId: acc.id,
personType: 'registered',
personIndex: i
});
}
}
// unique by name+apartment (берем первый найденный)
const uniq = new Map<string, { name: string; apartment: string; phone?: string; mood: 'happy' | 'angry' | 'neutral'; accountId?: string; personType?: 'owner' | 'registered'; personIndex?: number }>();
for (const c of res) {
const key = `${c.apartment}::${c.name}`.toLowerCase();
if (!uniq.has(key)) uniq.set(key, c);
}
return Array.from(uniq.values());
}, [accounts]);
// Получаем список жителей, которых еще нет в списке
const availableContacts = useMemo(() => {
const existing = building.residents || [];
const existingKeys = new Set(existing.map(r => `${r.apartment}::${r.name}`.toLowerCase()));
return contactsFromAccounts.filter(c => c.name && !existingKeys.has(`${c.apartment}::${c.name}`.toLowerCase()));
}, [contactsFromAccounts, building.residents]);
// Фильтруем контакты по поисковому запросу
const filteredAvailableContacts = useMemo(() => {
if (!searchTerm.trim()) return availableContacts;
const term = searchTerm.toLowerCase();
return availableContacts.filter(c =>
c.name.toLowerCase().includes(term) ||
c.apartment.toLowerCase().includes(term)
);
}, [availableContacts, searchTerm]);
// Находим данные жителя из лицевых счетов (для получения телефона)
const findResidentData = (residentName: string, apartment: string): { phone?: string; accountId?: string; personType?: 'owner' | 'registered'; personIndex?: number } => {
// Сначала ищем в contactsFromAccounts (быстрее)
const contact = contactsFromAccounts.find(c =>
c.name === residentName && c.apartment === apartment
);
if (contact) {
return {
phone: contact.phone,
accountId: contact.accountId,
personType: contact.personType,
personIndex: contact.personIndex
};
}
// Если не нашли, ищем напрямую в accounts
for (const acc of accounts) {
if (acc.apartmentNumber !== apartment) continue;
// Проверяем собственников
for (let i = 0; i < (acc.owners || []).length; i++) {
const owner = acc.owners[i];
if (owner?.fullName === residentName) {
return {
phone: owner.phone,
accountId: acc.id,
personType: 'owner',
personIndex: i
};
}
}
// Проверяем прописанных
for (let i = 0; i < (acc.registered || []).length; i++) {
const reg = acc.registered[i];
if (reg?.fullName === residentName) {
return {
phone: reg.phone,
accountId: acc.id,
personType: 'registered',
personIndex: i
};
}
}
}
return {};
};
const handleOpenSelectModal = () => {
setSelectedContacts(new Map());
setShowSelectModal(true);
};
const toggleContactSelection = (key: string) => {
setSelectedContacts(prev => {
const newMap = new Map(prev);
if (newMap.has(key)) {
newMap.delete(key);
} else {
newMap.set(key, 'resident'); // По умолчанию житель
}
return newMap;
});
};
const setContactRole = (key: string, role: 'chairman' | 'activist' | 'resident') => {
setSelectedContacts(prev => {
const newMap = new Map(prev);
if (newMap.has(key)) {
newMap.set(key, role);
}
return newMap;
});
};
const handleAddSelectedContacts = () => {
if (selectedContacts.size === 0) {
alert('Выберите хотя бы одного жителя');
return;
}
setBuilding((prev) => {
const existing = prev.residents || [];
const existingKeys = new Set(existing.map(r => `${r.apartment}::${r.name}`.toLowerCase()));
const toAdd = Array.from(selectedContacts.entries())
.map(([key, role], index) => {
const contact = availableContacts.find(c =>
`${c.apartment}::${c.name}`.toLowerCase() === key
);
if (!contact || existingKeys.has(key)) return null;
return {
id: `acc-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}`,
name: contact.name,
role: role,
apartment: contact.apartment,
mood: contact.mood,
lastContact: '—',
};
})
.filter((item): item is NonNullable<typeof item> => item !== null);
if (toAdd.length === 0) return prev;
setShowSelectModal(false);
setSelectedContacts(new Map());
return { ...prev, residents: [...toAdd, ...existing], isDirty: true };
});
};
return (
<div className="space-y-6 animate-fade-in">
<div className="flex justify-between items-center">
<h3 className="font-bold text-slate-700 text-sm uppercase">Актив дома (Жители)</h3>
<span className="text-xs bg-slate-100 px-2 py-1 rounded font-bold text-slate-500">NPS: {building.nps}</span>
</div>
<div className="grid grid-cols-1 gap-3">
{building.residents.map(r => {
const residentData = findResidentData(r.name, r.apartment);
return (
<div
key={r.id}
className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex items-center justify-between cursor-pointer hover:border-primary-300 transition-colors"
onClick={() => {
const data = findResidentData(r.name, r.apartment);
setSelectedResident({
id: r.id,
name: r.name,
apartment: r.apartment,
phone: data.phone,
accountId: data.accountId,
personType: data.personType,
personIndex: data.personIndex
});
setEditFormData({ fullName: r.name, phone: data.phone || '' });
setIsEditingResident(false);
}}
>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-white font-bold ${r.mood === 'happy' ? 'bg-emerald-400' : r.mood === 'angry' ? 'bg-red-400' : 'bg-slate-400'}`}>
{r.mood === 'happy' ? <Smile className="w-6 h-6"/> : r.mood === 'angry' ? <Frown className="w-6 h-6"/> : <Meh className="w-6 h-6"/>}
</div>
<div className="flex-1">
<h4 className="font-bold text-slate-800 text-sm">{r.name}</h4>
<div className="flex items-center gap-2 mt-1">
<p className="text-xs text-slate-500">
Кв. {r.apartment}
</p>
{isEditing ? (
<select
value={r.role}
onChange={(e) => {
setBuilding(prev => ({
...prev,
residents: prev.residents.map(res =>
res.id === r.id
? { ...res, role: e.target.value as 'chairman' | 'activist' | 'resident' }
: res
),
isDirty: true
}));
}}
onClick={(e) => e.stopPropagation()}
className="text-xs font-bold px-2 py-0.5 rounded border border-slate-300 bg-white focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="resident">Житель</option>
<option value="activist">Активист</option>
<option value="chairman">Председатель</option>
</select>
) : (
<p className="text-xs text-slate-500">
{r.role === 'chairman' ? 'Председатель' : r.role === 'activist' ? 'Активист' : 'Житель'}
</p>
)}
</div>
</div>
</div>
<div className="text-right flex items-center gap-2">
{isEditing && (
<button
onClick={(e) => {
e.stopPropagation();
if (confirm('Удалить этого жителя из списка?')) {
setBuilding(prev => ({
...prev,
residents: prev.residents.filter(res => res.id !== r.id),
isDirty: true
}));
}
}}
className="p-2 bg-red-50 rounded-full text-red-400 hover:text-red-600 hover:bg-red-100 transition-colors"
>
<X className="w-4 h-4"/>
</button>
)}
<button
onClick={(e) => e.stopPropagation()}
className="p-2 bg-slate-50 rounded-full text-slate-400 hover:text-primary-600 hover:bg-primary-50 transition-colors"
>
<MessageCircle className="w-5 h-5"/>
</button>
</div>
</div>
);
})}
<button
type="button"
onClick={handleOpenSelectModal}
disabled={availableContacts.length === 0}
className="w-full py-3 border border-dashed border-slate-300 rounded-xl text-slate-400 text-sm font-bold hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
+ Добавить контакты из лицевых счетов
</button>
</div>
{/* Модальное окно выбора жителей */}
{showSelectModal && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in" onClick={() => setShowSelectModal(false)}>
<div className="bg-white rounded-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden shadow-2xl animate-slide-up flex flex-col" onClick={e => e.stopPropagation()}>
<div className="p-6 border-b border-slate-200 flex justify-between items-center">
<div>
<h3 className="text-lg font-bold text-slate-800">Выберите жителей для совета дома</h3>
<p className="text-sm text-slate-500 mt-1">Отметьте жителей и назначьте им роли</p>
</div>
<button onClick={() => {
setShowSelectModal(false);
setSearchTerm('');
}} className="p-2 hover:bg-slate-100 rounded-full">
<X className="w-5 h-5 text-slate-400"/>
</button>
</div>
{/* Поиск */}
<div className="px-6 pt-4 pb-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Поиск по имени или квартире..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500 text-sm bg-white"
/>
</div>
</div>
<div className="flex-1 overflow-auto p-6">
{availableContacts.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<UserCheck className="w-12 h-12 mx-auto mb-3 opacity-20"/>
<p>Все жители уже добавлены</p>
</div>
) : filteredAvailableContacts.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<Search className="w-12 h-12 mx-auto mb-3 opacity-20"/>
<p>Ничего не найдено</p>
</div>
) : (
<div className="space-y-2">
{filteredAvailableContacts.map((contact) => {
const contactData = findResidentData(contact.name, contact.apartment);
const key = `${contact.apartment}::${contact.name}`.toLowerCase();
const isSelected = selectedContacts.has(key);
const role = selectedContacts.get(key) || 'resident';
return (
<div
key={key}
className={`p-4 border-2 rounded-xl transition-all ${
isSelected
? 'border-primary-500 bg-primary-50'
: 'border-slate-200 hover:border-slate-300'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
<div
onClick={() => toggleContactSelection(key)}
className={`w-5 h-5 rounded border-2 flex items-center justify-center flex-shrink-0 mt-0.5 cursor-pointer ${
isSelected
? 'bg-primary-500 border-primary-500'
: 'border-slate-300'
}`}
>
{isSelected && (
<Check className="w-3 h-3 text-white"/>
)}
</div>
<div className="flex items-center gap-3 flex-1">
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-white font-bold ${
contact.mood === 'happy' ? 'bg-emerald-400' : contact.mood === 'angry' ? 'bg-red-400' : 'bg-slate-400'
}`}>
{contact.mood === 'happy' ? <Smile className="w-6 h-6"/> : contact.mood === 'angry' ? <Frown className="w-6 h-6"/> : <Meh className="w-6 h-6"/>}
</div>
<div className="flex-1">
<div className="font-bold text-slate-800">{contact.name}</div>
<div className="text-sm text-slate-500">Кв. {contact.apartment}</div>
{contactData.phone && (
<div className="text-xs text-slate-400 flex items-center gap-1 mt-0.5">
<Phone className="w-3 h-3"/>
{contactData.phone}
</div>
)}
</div>
</div>
</div>
{isSelected && (
<div className="flex items-center gap-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<label className="text-xs text-slate-500 font-bold whitespace-nowrap">Роль:</label>
<select
value={role}
onChange={(e) => setContactRole(key, e.target.value as 'chairman' | 'activist' | 'resident')}
onClick={(e) => e.stopPropagation()}
className="px-3 py-1.5 border border-slate-300 rounded-lg text-sm font-bold text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="resident">Житель</option>
<option value="activist">Активист</option>
<option value="chairman">Председатель</option>
</select>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
{selectedContacts.size > 0 && (
<div className="p-6 border-t border-slate-200 bg-slate-50 flex justify-between items-center">
<div className="text-sm text-slate-600">
Выбрано жителей: <span className="font-bold text-slate-800">{selectedContacts.size}</span>
</div>
<div className="flex gap-3">
<button
onClick={() => {
setShowSelectModal(false);
setSelectedContacts(new Map());
setSearchTerm('');
}}
className="px-4 py-2 text-slate-600 font-bold bg-white border border-slate-200 rounded-xl hover:bg-slate-50"
>
Отмена
</button>
<button
onClick={handleAddSelectedContacts}
className="px-4 py-2 bg-primary-600 text-white font-bold rounded-xl hover:bg-primary-700 flex items-center gap-2"
>
<UserCheck className="w-4 h-4"/> Добавить выбранных
</button>
</div>
</div>
)}
</div>
</div>
)}
{/* Модальное окно карточки жителя */}
{selectedResident && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in" onClick={() => {
setSelectedResident(null);
setIsEditingResident(false);
}}>
<div className="bg-white rounded-2xl w-full max-w-md shadow-2xl animate-slide-up" onClick={e => e.stopPropagation()}>
<div className="p-6 border-b border-slate-200 flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-primary-100 flex items-center justify-center">
<User className="w-6 h-6 text-primary-600"/>
</div>
<div>
<h3 className="text-lg font-bold text-slate-800">Карточка жителя</h3>
<p className="text-xs text-slate-500">Кв. {selectedResident.apartment}</p>
</div>
</div>
<button
onClick={() => {
setSelectedResident(null);
setIsEditingResident(false);
}}
className="p-2 hover:bg-slate-100 rounded-full"
>
<X className="w-5 h-5 text-slate-400"/>
</button>
</div>
<div className="p-6 space-y-4">
{isEditingResident ? (
<>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-2">
ФИО *
</label>
<input
type="text"
value={editFormData.fullName}
onChange={(e) => setEditFormData({ ...editFormData, fullName: e.target.value })}
className="w-full p-3 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
placeholder="Иванов Иван Иванович"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-2">
Телефон
</label>
<input
type="tel"
value={editFormData.phone}
onChange={(e) => setEditFormData({ ...editFormData, phone: e.target.value })}
className="w-full p-3 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
placeholder="+7 (999) 123-45-67"
/>
</div>
<div className="flex gap-3 pt-2">
<button
onClick={() => {
// Сохраняем изменения
if (!editFormData.fullName.trim()) {
alert('ФИО обязательно для заполнения');
return;
}
// Обновляем данные в accounts
if (selectedResident.accountId && selectedResident.personType && selectedResident.personIndex !== undefined) {
const account = accounts.find(a => a.id === selectedResident.accountId);
if (account) {
if (selectedResident.personType === 'owner') {
const updatedOwners = [...account.owners];
updatedOwners[selectedResident.personIndex] = {
...updatedOwners[selectedResident.personIndex],
fullName: editFormData.fullName,
phone: editFormData.phone
};
setBuilding(prev => ({
...prev,
accounts: prev.accounts.map(acc =>
acc.id === account.id
? { ...acc, owners: updatedOwners }
: acc
),
residents: prev.residents.map(res =>
res.id === selectedResident.id
? { ...res, name: editFormData.fullName }
: res
),
isDirty: true
}));
} else if (selectedResident.personType === 'registered') {
const updatedRegistered = [...(account.registered || [])];
updatedRegistered[selectedResident.personIndex] = {
...updatedRegistered[selectedResident.personIndex],
fullName: editFormData.fullName,
phone: editFormData.phone
};
setBuilding(prev => ({
...prev,
accounts: prev.accounts.map(acc =>
acc.id === account.id
? { ...acc, registered: updatedRegistered }
: acc
),
residents: prev.residents.map(res =>
res.id === selectedResident.id
? { ...res, name: editFormData.fullName }
: res
),
isDirty: true
}));
}
}
} else {
// Если не найдено в accounts, просто обновляем residents
setBuilding(prev => ({
...prev,
residents: prev.residents.map(res =>
res.id === selectedResident.id
? { ...res, name: editFormData.fullName }
: res
),
isDirty: true
}));
}
setSelectedResident({
...selectedResident,
name: editFormData.fullName,
phone: editFormData.phone
});
setIsEditingResident(false);
}}
className="flex-1 bg-primary-600 text-white px-4 py-2.5 rounded-xl font-bold text-sm hover:bg-primary-700 transition-colors flex items-center justify-center gap-2"
>
<Save className="w-4 h-4"/> Сохранить
</button>
<button
onClick={() => {
setIsEditingResident(false);
setEditFormData({ fullName: selectedResident.name, phone: selectedResident.phone || '' });
}}
className="px-4 py-2.5 bg-slate-100 text-slate-600 rounded-xl font-bold text-sm hover:bg-slate-200 transition-colors"
>
Отмена
</button>
</div>
</>
) : (
<>
<div className="space-y-3">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">
ФИО
</label>
<p className="text-sm font-bold text-slate-800">{selectedResident.name}</p>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1 flex items-center gap-1">
<Phone className="w-3 h-3"/>
Телефон
</label>
<p className="text-sm text-slate-700">{selectedResident.phone || 'Не указан'}</p>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">
Квартира
</label>
<p className="text-sm text-slate-700">Кв. {selectedResident.apartment}</p>
</div>
</div>
{isEditing && (
<button
onClick={() => setIsEditingResident(true)}
className="w-full mt-4 bg-primary-600 text-white px-4 py-2.5 rounded-xl font-bold text-sm hover:bg-primary-700 transition-colors flex items-center justify-center gap-2"
>
<Edit2 className="w-4 h-4"/> Редактировать
</button>
)}
</>
)}
</div>
</div>
</div>
)}
</div>
);
};