Files
mkd/components/building/ResidentsView.tsx
2026-02-04 00:17:04 +05:00

631 lines
40 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, { 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>
);
};