Initial commit MKD fixes

This commit is contained in:
Arsen
2026-02-04 00:17:04 +05:00
commit de94ad707b
312 changed files with 138754 additions and 0 deletions

View File

@@ -0,0 +1,212 @@
import React, { useState } from 'react';
import { X } from 'lucide-react';
import { backendApi } from '../../services/apiClient';
interface Props {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
ossId: string;
ossAddress: string;
agendaItems?: string[];
}
export const AddBallotModal: React.FC<Props> = ({ isOpen, onClose, onSuccess, ossId, ossAddress, agendaItems = [] }) => {
const [formData, setFormData] = useState({
apartment: '',
ownerName: '',
area: 0,
voteResult: '' as '' | 'for' | 'against' | 'abstain',
notes: '',
votesByItem: {} as Record<string, 'for' | 'against' | 'abstain' | ''>,
});
const [loading, setLoading] = useState(false);
const hasAgendaItems = Array.isArray(agendaItems) && agendaItems.length > 0;
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.apartment || !formData.area) {
alert('Заполните обязательные поля: номер помещения и площадь');
return;
}
try {
setLoading(true);
const votesByItem: Record<string, string> = {};
if (hasAgendaItems && formData.votesByItem) {
Object.entries(formData.votesByItem).forEach(([idx, v]: [string, string]) => {
if (v && ['for', 'against', 'abstain'].includes(v)) votesByItem[idx] = v;
});
}
await backendApi.submitOSSBallot(ossId, {
apartment: formData.apartment,
owner_name: formData.ownerName || undefined,
area: formData.area,
vote_result: hasAgendaItems ? undefined : (formData.voteResult || undefined),
notes: formData.notes || undefined,
votes_by_item: Object.keys(votesByItem).length > 0 ? votesByItem : undefined,
});
window.dispatchEvent(new CustomEvent('mkd-oss-changed'));
onSuccess();
onClose();
setFormData({
apartment: '',
ownerName: '',
area: 0,
voteResult: '',
notes: '',
votesByItem: {},
});
} catch (error: any) {
console.error('Error submitting ballot:', error);
const errorMessage = error?.message || error?.error || 'Ошибка при внесении бюллетеня';
alert(errorMessage);
} finally {
setLoading(false);
}
};
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="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex justify-between items-center">
<div>
<h3 className="text-lg font-black text-slate-800">Внести бюллетень</h3>
<p className="text-xs text-slate-500 mt-1">{ossAddress}</p>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-xl transition-colors"
>
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Номер помещения *
</label>
<input
type="text"
required
value={formData.apartment}
onChange={(e) => setFormData({ ...formData, apartment: e.target.value })}
placeholder="15, 25А, 101"
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
ФИО собственника
</label>
<input
type="text"
value={formData.ownerName}
onChange={(e) => setFormData({ ...formData, ownerName: e.target.value })}
placeholder="Иванов Иван Иванович"
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Площадь помещения (м²) *
</label>
<input
type="number"
required
min="0"
step="0.01"
value={formData.area}
onChange={(e) => setFormData({ ...formData, area: parseFloat(e.target.value) || 0 })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
{hasAgendaItems ? (
<div className="space-y-2">
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Голос по пунктам повестки
</label>
{agendaItems.map((text, idx) => (
<div key={idx} className="flex items-center gap-2">
<span className="text-xs text-slate-600 w-6">{idx + 1}.</span>
<span className="text-xs text-slate-500 flex-1 truncate" title={text}>{text || 'Пункт ' + (idx + 1)}</span>
<select
value={formData.votesByItem[String(idx)] ?? ''}
onChange={(e) => setFormData({
...formData,
votesByItem: { ...formData.votesByItem, [String(idx)]: e.target.value as any },
})}
className="p-1.5 rounded-lg border border-slate-200 text-sm w-32"
>
<option value=""></option>
<option value="for">За</option>
<option value="against">Против</option>
<option value="abstain">Воздержался</option>
</select>
</div>
))}
</div>
) : (
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Результат голосования
</label>
<select
value={formData.voteResult}
onChange={(e) => setFormData({ ...formData, voteResult: e.target.value as any })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="">Не указано</option>
<option value="for">За</option>
<option value="against">Против</option>
<option value="abstain">Воздержался</option>
</select>
</div>
)}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Примечания
</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
rows={2}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none resize-none"
/>
</div>
<div className="flex gap-3 justify-end pt-4 border-t border-slate-200">
<button
type="button"
onClick={onClose}
className="px-6 py-2.5 bg-slate-100 text-slate-700 rounded-xl text-sm font-bold hover:bg-slate-200 transition-colors"
>
Отмена
</button>
<button
type="submit"
disabled={loading}
className="px-6 py-2.5 bg-primary-600 text-white rounded-xl text-sm font-bold hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Сохранение...' : 'Внести'}
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,307 @@
import React, { useState, useEffect } from 'react';
import { X } from 'lucide-react';
import { DevPipelineItem, DevPipelineStatus, Employee } from '../../types';
import { backendApi } from '../../services/apiClient';
interface Props {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
export const AddPipelineObjectModal: React.FC<Props> = ({ isOpen, onClose, onSuccess }) => {
const [formData, setFormData] = useState({
address: '',
type: 'old' as 'old' | 'new',
floors: 5,
area: 0,
apartments: 0,
status: 'incoming' as DevPipelineStatus,
probability: 0,
expectedRevenue: 0,
manager: '',
notes: '',
});
const [employees, setEmployees] = useState<Employee[]>([]);
const [loading, setLoading] = useState(false);
const [loadingEmployees, setLoadingEmployees] = useState(true);
useEffect(() => {
if (isOpen) {
fetchEmployees();
}
}, [isOpen]);
const fetchEmployees = async () => {
try {
setLoadingEmployees(true);
const data = await backendApi.getEmployees();
setEmployees(data.filter(emp => emp.status === 'active'));
} catch (error) {
console.error('Error fetching employees:', error);
setEmployees([]);
} finally {
setLoadingEmployees(false);
}
};
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.address || !formData.manager) {
alert('Заполните обязательные поля: адрес и менеджер');
return;
}
if (formData.area <= 0 || formData.apartments <= 0 || formData.floors <= 0) {
alert('Площадь, количество квартир и этажность должны быть больше 0');
return;
}
try {
setLoading(true);
// Преобразуем данные для отправки на бэкенд (snake_case для бэкенда)
const payload = {
address: formData.address.trim(),
type: formData.type,
floors: formData.floors,
area: formData.area,
apartments: formData.apartments,
status: formData.status,
probability: formData.probability,
expected_revenue: formData.expectedRevenue,
manager: formData.manager.trim(),
notes: formData.notes.trim() || null,
};
await backendApi.createDevelopmentPipeline(payload);
window.dispatchEvent(new CustomEvent('mkd-pipeline-changed'));
window.dispatchEvent(new CustomEvent('mkd-dev-summary-changed'));
onSuccess();
onClose();
// Сброс формы
setFormData({
address: '',
type: 'old',
floors: 5,
area: 0,
apartments: 0,
status: 'incoming',
probability: 0,
expectedRevenue: 0,
manager: '',
notes: '',
});
} catch (error: any) {
console.error('Error creating pipeline object:', error);
const errorMessage = error?.message || error?.error || 'Ошибка при создании объекта';
alert(errorMessage);
} finally {
setLoading(false);
}
};
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-2xl shadow-2xl animate-slide-up max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex justify-between items-center">
<h3 className="text-lg font-black text-slate-800">Добавить объект в воронку</h3>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-xl transition-colors"
>
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Адрес *
</label>
<input
type="text"
required
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
placeholder="ул. Примерная, д. 1"
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Тип объекта
</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as 'old' | 'new' })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="old">Вторичка</option>
<option value="new">Новостройка</option>
</select>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Этажность
</label>
<input
type="number"
required
min="1"
value={formData.floors}
onChange={(e) => setFormData({ ...formData, floors: parseInt(e.target.value) || 0 })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Площадь (м²)
</label>
<input
type="number"
required
min="0"
step="0.01"
value={formData.area}
onChange={(e) => setFormData({ ...formData, area: parseFloat(e.target.value) || 0 })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Количество квартир
</label>
<input
type="number"
required
min="0"
value={formData.apartments}
onChange={(e) => setFormData({ ...formData, apartments: parseInt(e.target.value) || 0 })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Этап воронки
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as DevPipelineStatus })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="incoming">Входящие</option>
<option value="analysis">Анализ</option>
<option value="agenda_approval">Согласование повестки</option>
<option value="in_person">Очная часть</option>
<option value="absentee">Заочная часть</option>
<option value="protocol_formation">Формирование протокола</option>
<option value="protocol_to_gzhi">Отправка протокола в ГЖИ</option>
<option value="gzhi_order">Приказ ГЖИ</option>
<option value="success">Успех</option>
<option value="failure">Провал</option>
</select>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Вероятность (%)
</label>
<input
type="number"
required
min="0"
max="100"
value={formData.probability}
onChange={(e) => setFormData({ ...formData, probability: parseInt(e.target.value) || 0 })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Ожидаемая выручка ()
</label>
<input
type="number"
required
min="0"
value={formData.expectedRevenue}
onChange={(e) => setFormData({ ...formData, expectedRevenue: parseFloat(e.target.value) || 0 })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Менеджер *
</label>
{loadingEmployees ? (
<div className="w-full p-2.5 rounded-xl border border-slate-200 text-sm text-slate-400">
Загрузка...
</div>
) : (
<select
required
value={formData.manager}
onChange={(e) => setFormData({ ...formData, manager: e.target.value })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="">Выберите менеджера</option>
{employees.map(emp => (
<option key={emp.id} value={emp.name}>
{emp.name} {emp.position ? `(${emp.position})` : ''}
</option>
))}
</select>
)}
</div>
<div className="md:col-span-2">
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Примечания
</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
rows={3}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none resize-none"
/>
</div>
</div>
<div className="flex gap-3 justify-end pt-4 border-t border-slate-200">
<button
type="button"
onClick={onClose}
className="px-6 py-2.5 bg-slate-100 text-slate-700 rounded-xl text-sm font-bold hover:bg-slate-200 transition-colors"
>
Отмена
</button>
<button
type="submit"
disabled={loading}
className="px-6 py-2.5 bg-primary-600 text-white rounded-xl text-sm font-bold hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Создание...' : 'Создать'}
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,293 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { X, AlertTriangle, CheckCircle2, TrendingUp, BarChart3 } from 'lucide-react';
import { DevAuditData, InspectionData } from '../../types';
import { backendApi } from '../../services/apiClient';
import {
AUDIT_INSPECTION_SCHEMA,
AUDIT_STATUS_LABELS,
INSPECTION_SCORE_LABELS,
calcCategoryAverage,
calcComplexityIndexFromCategoryAverages,
calcWearPercentFromCategoryAverages,
calculateTariffFromAudit,
formatCategoryOverall,
normalizeRatingToScore,
} from './auditInspectionSchema';
interface Props {
auditId: string | null;
onClose: () => void;
onSaved: () => void;
}
const STATUS_OPTIONS: Array<'new' | 'in_progress' | 'completed'> = ['new', 'in_progress', 'completed'];
export const AuditCardModal: React.FC<Props> = ({ auditId, onClose, onSaved }) => {
const [audit, setAudit] = useState<DevAuditData | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<Partial<DevAuditData> & { inspectionData?: InspectionData }>({});
const fetchAudit = useCallback(async () => {
if (!auditId) return;
try {
setLoading(true);
const data = await backendApi.getDevelopmentAudit(auditId);
setAudit(data);
setForm({
status: data.status,
wearPercent: data.wearPercent,
projectedMargin: data.projectedMargin,
inspectionData: data.inspectionData ?? {},
});
} catch (e) {
console.error(e);
setAudit(null);
} finally {
setLoading(false);
}
}, [auditId]);
useEffect(() => {
fetchAudit();
}, [fetchAudit]);
const handleSave = async () => {
if (!auditId) return;
try {
setSaving(true);
await backendApi.updateDevelopmentAudit(auditId, {
status: form.status,
wearPercent: computedWearPercent ?? form.wearPercent ?? 0,
projectedMargin: form.projectedMargin,
inspectionData: form.inspectionData ?? undefined,
});
onSaved();
} catch (e) {
console.error(e);
alert('Ошибка сохранения');
} finally {
setSaving(false);
}
};
const updateInspection = (categoryKey: string, subKey: string, field: string, value: unknown) => {
setForm(prev => {
const data = { ...(prev.inspectionData || {}) };
const cat = data[categoryKey as keyof InspectionData] || { subItems: [] };
const subItems = [...(cat.subItems || [])];
let item = subItems.find(s => s.key === subKey);
if (!item) {
item = { key: subKey, label: '', rating: null, description: null, noAccess: false, notPresent: false };
subItems.push(item);
}
if (field === 'rating' && value !== null && value !== '') {
const n = typeof value === 'number' ? value : parseInt(String(value), 10);
value = Number.isFinite(n) && n >= 1 && n <= 5 ? n : null;
}
item = { ...item, [field]: value };
const idx = subItems.findIndex(s => s.key === subKey);
if (idx >= 0) subItems[idx] = item; else subItems.push(item);
data[categoryKey as keyof InspectionData] = { ...cat, subItems };
return { ...prev, inspectionData: data };
});
};
const getSubItem = (categoryKey: string, subKey: string) => {
const cat = (form.inspectionData || {})[categoryKey as keyof InspectionData];
const item = cat?.subItems?.find(s => s.key === subKey);
return item;
};
/** Итог по пункту = среднее арифметическое подпунктов (только 15, без noAccess/notPresent) */
const getCategoryOverall = (categoryKey: string): number | null => {
const cat = (form.inspectionData || {})[categoryKey as keyof InspectionData];
return calcCategoryAverage(cat?.subItems || []);
};
/** Износ % и индекс сложности из средних по пунктам осмотра: (5 среднее) / 4 × 100 */
const categoryAverages = useMemo(() => {
const data = form.inspectionData || {};
return AUDIT_INSPECTION_SCHEMA.map(cat => calcCategoryAverage((data[cat.key] as { subItems?: unknown[] })?.subItems || []));
}, [form.inspectionData]);
const computedWearPercent = useMemo(() => calcWearPercentFromCategoryAverages(categoryAverages), [categoryAverages]);
const computedComplexityIndex = useMemo(() => calcComplexityIndexFromCategoryAverages(categoryAverages), [categoryAverages]);
/** Прогнозный тариф: автоматически при изменении маржи, износа и индекса сложности */
const computedTariff = useMemo(() => {
const wear = computedWearPercent ?? 0;
const complexity = computedComplexityIndex ?? 50;
const margin = form.projectedMargin ?? 15;
return calculateTariffFromAudit({ wearPercent: wear, complexityIndex: complexity, projectedMargin: margin });
}, [computedWearPercent, computedComplexityIndex, form.projectedMargin]);
if (!auditId) return null;
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-4xl max-h-[90vh] overflow-y-auto shadow-2xl animate-slide-up"
onClick={e => e.stopPropagation()}
>
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex justify-between items-center z-10">
<h2 className="text-lg font-black text-slate-800">{audit?.address ?? 'Аудит'}</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>
{loading ? (
<div className="p-12 text-center text-slate-400">Загрузка...</div>
) : audit ? (
<div className="p-6 space-y-6">
{/* Статус */}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">Статус аудита</label>
<select
value={form.status ?? 'new'}
onChange={e => setForm(f => ({ ...f, status: e.target.value as DevAuditData['status'] }))}
className="w-full max-w-xs p-2.5 rounded-xl border border-slate-200 text-sm"
>
{STATUS_OPTIONS.map(s => (
<option key={s} value={s}>{AUDIT_STATUS_LABELS[s]}</option>
))}
</select>
</div>
{/* Износ дома — только из данных пунктов осмотра (средние 15 → %), без ручного ввода */}
<div className="grid grid-cols-2 gap-4">
<div className="bg-slate-50 p-4 rounded-3xl border border-slate-100">
<p className="text-[9px] font-black text-slate-400 uppercase mb-1">Износ дома, %</p>
<div className="flex items-center gap-2">
<span className="w-24 text-2xl font-black tabular-nums">
{computedWearPercent != null ? computedWearPercent : '—'}
</span>
<span className="text-2xl font-black text-slate-400">%</span>
{computedWearPercent != null && (computedWearPercent > 50 ? <AlertTriangle className="w-5 h-5 text-red-500" /> : <CheckCircle2 className="w-5 h-5 text-emerald-500" />)}
</div>
<p className="text-[9px] text-slate-500 mt-1">Из средних по пунктам осмотра (15)</p>
</div>
<div className="bg-slate-50 p-4 rounded-3xl border border-slate-100">
<p className="text-[9px] font-black text-slate-400 uppercase mb-1">Расчётная маржа, %</p>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={100}
step={0.5}
value={form.projectedMargin ?? ''}
onChange={e => setForm(f => ({ ...f, projectedMargin: parseFloat(e.target.value) || 0 }))}
className="w-24 text-2xl font-black border-0 bg-transparent p-0 focus:ring-0 text-primary-600"
/>
<span className="text-2xl font-black text-slate-400">%</span>
<TrendingUp className="w-5 h-5 text-primary-400" />
</div>
</div>
</div>
{/* Индекс сложности (из осмотра) и прогнозный тариф (автоматически при изменении маржи, износа, сложности) */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="bg-slate-50 p-4 rounded-3xl border border-slate-100">
<p className="text-[9px] font-black text-slate-400 uppercase mb-1">Индекс сложности дома (0100)</p>
<div className="flex items-center gap-2">
<span className="w-24 text-2xl font-black tabular-nums">
{computedComplexityIndex != null ? computedComplexityIndex : '—'}
</span>
<span className="text-2xl font-black text-slate-400">%</span>
</div>
<p className="text-[9px] text-slate-500 mt-1">Из средних по пунктам осмотра (15)</p>
</div>
<div className="bg-slate-900 text-white p-4 rounded-2xl">
<p className="text-[9px] font-black text-slate-400 uppercase mb-1">Прогнозный тариф</p>
<p className="text-xl font-black">{computedTariff} <span className="text-xs font-normal text-slate-400">/м²</span></p>
<p className="text-[9px] text-slate-400 mt-1 flex items-center gap-1"><BarChart3 className="w-3.5 h-3.5"/> Меняется при изменении маржи, износа и индекса сложности</p>
</div>
</div>
{/* Пункты осмотра */}
<div>
<h3 className="text-sm font-black text-slate-700 uppercase tracking-wider mb-4">Пункты осмотра</h3>
<div className="space-y-6">
{AUDIT_INSPECTION_SCHEMA.map(cat => (
<div key={cat.key} className="border border-slate-200 rounded-2xl p-4 bg-slate-50/50">
<div className="flex justify-between items-center mb-3">
<span className="font-bold text-slate-800">{cat.label}</span>
<span className="text-sm font-bold text-slate-600" title="Среднее арифметическое по подпунктам (только оценки 15)">
Среднее: {formatCategoryOverall(getCategoryOverall(cat.key))}
</span>
</div>
<div className="space-y-3">
{cat.subItems.map(sub => {
const item = getSubItem(cat.key, sub.key);
const ratingVal = normalizeRatingToScore(item?.rating) ?? null;
return (
<div key={sub.key} className="bg-white rounded-xl p-3 border border-slate-100">
<div className="flex flex-wrap items-center gap-2 mb-2">
<span className="text-xs font-bold text-slate-600">{sub.label}</span>
<select
value={ratingVal ?? ''}
onChange={e => {
const v = e.target.value;
updateInspection(cat.key, sub.key, 'rating', v === '' ? null : parseInt(v, 10));
}}
className="text-[10px] rounded-lg border border-slate-200 px-2 py-1"
>
<option value="">Оценка 15</option>
{[1, 2, 3, 4, 5].map(n => (
<option key={n} value={n}>{INSPECTION_SCORE_LABELS[n]}</option>
))}
</select>
<label className="flex items-center gap-1 text-[10px] text-slate-500">
<input
type="checkbox"
checked={item?.noAccess ?? false}
onChange={e => updateInspection(cat.key, sub.key, 'noAccess', e.target.checked)}
/>
Нет доступа
</label>
<label className="flex items-center gap-1 text-[10px] text-slate-500">
<input
type="checkbox"
checked={item?.notPresent ?? false}
onChange={e => updateInspection(cat.key, sub.key, 'notPresent', e.target.checked)}
/>
Отсутствует в доме
</label>
</div>
<textarea
placeholder="Описание"
value={item?.description ?? ''}
onChange={e => updateInspection(cat.key, sub.key, 'description', e.target.value || null)}
className="w-full text-xs rounded-lg border border-slate-200 p-2 resize-none h-16"
/>
</div>
);
})}
</div>
</div>
))}
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-slate-200">
<button type="button" onClick={onClose} className="px-5 py-2.5 bg-slate-100 text-slate-700 rounded-xl text-sm font-bold hover:bg-slate-200">
Закрыть
</button>
<button
type="button"
onClick={handleSave}
disabled={saving}
className="px-5 py-2.5 bg-primary-600 text-white rounded-xl text-sm font-bold hover:bg-primary-700 disabled:opacity-50"
>
{saving ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</div>
) : (
<div className="p-12 text-center text-slate-500">Аудит не найден</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,289 @@
import React, { useState, useRef } from 'react';
import { X, Plus, Trash2, Upload, Download } from 'lucide-react';
import { backendApi } from '../../services/apiClient';
export interface BulkBallotRow {
apartment: string;
ownerName: string;
area: number;
voteResult: '' | 'for' | 'against' | 'abstain';
notes: string;
}
interface Props {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
ossId: string;
ossAddress: string;
agendaItems?: string[];
}
const emptyRow = (): BulkBallotRow => ({
apartment: '',
ownerName: '',
area: 0,
voteResult: '',
notes: '',
});
export const BulkBallotModal: React.FC<Props> = ({ isOpen, onClose, onSuccess, ossId, ossAddress, agendaItems: _agendaItems }) => {
const [rows, setRows] = useState<BulkBallotRow[]>([emptyRow()]);
const [loading, setLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
if (!isOpen) return null;
const addRow = () => setRows((r) => [...r, emptyRow()]);
const removeRow = (index: number) => {
if (rows.length <= 1) return;
setRows((r) => r.filter((_, i) => i !== index));
};
const updateRow = (index: number, field: keyof BulkBallotRow, value: string | number) => {
setRows((r) => {
const next = [...r];
next[index] = { ...next[index], [field]: value };
return next;
});
};
const validRows = rows.filter((r) => r.apartment.trim() && r.area > 0);
const handleImportCSV = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const text = String(reader.result);
const lines = text.split(/\r?\n/).filter((line) => line.trim());
if (lines.length < 2) return;
const sep = lines[0].includes(';') ? ';' : ',';
const cols = (line: string) => line.split(sep).map((c) => c.replace(/^"|"$/g, '').trim());
const headerCols = cols(lines[0]).map((c) => c.toLowerCase());
const colIndex = (names: string[]) => headerCols.findIndex((h) => names.some((n) => h.includes(n)));
const getCol = (names: string[], arr: string[]) => {
const idx = colIndex(names);
return idx >= 0 && arr[idx] !== undefined ? arr[idx] : '';
};
const newRows: BulkBallotRow[] = [];
for (let i = 1; i < lines.length; i++) {
const arr = cols(lines[i]);
const apartment = getCol(['apartment', 'помещение', 'кв'], arr) || arr[0] || '';
const ownerName = getCol(['owner_name', 'owner', 'собственник', 'фио'], arr) || arr[1] || '';
const areaStr = getCol(['area', 'площадь', 'площадь (м²)', 'м²'], arr) || arr[2] || '0';
const area = parseFloat(areaStr.replace(/,/, '.')) || 0;
const voteStr = (getCol(['vote_result', 'vote', 'голос', 'результат'], arr) || arr[3] || '').toLowerCase();
let voteResult: '' | 'for' | 'against' | 'abstain' = '';
if (voteStr === 'for' || voteStr === 'за') voteResult = 'for';
else if (voteStr === 'against' || voteStr === 'против') voteResult = 'against';
else if (voteStr === 'abstain' || voteStr === 'воздержался') voteResult = 'abstain';
const notes = getCol(['notes', 'примечания'], arr) || arr[4] || '';
if (apartment.trim() || area > 0) {
newRows.push({ apartment, ownerName, area, voteResult, notes });
}
}
if (newRows.length > 0) setRows(newRows);
};
reader.readAsText(file, 'UTF-8');
e.target.value = '';
};
const handleDownloadTemplate = () => {
const header = 'Помещение;Собственник;Площадь (м²);Голос;Примечания';
const sample = '15;Иванов И.И.;45.2;За;\n25;Петров П.П.;52;Против;\n101;;38.5;Воздержался;';
const csv = [header, sample].join('\n');
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = аблон_бюллетеней.csv';
link.click();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (validRows.length === 0) {
alert('Добавьте хотя бы одну строку с помещением и площадью > 0');
return;
}
try {
setLoading(true);
const ballots = validRows.map((r) => ({
apartment: r.apartment.trim(),
owner_name: r.ownerName.trim() || undefined,
area: r.area,
vote_result: r.voteResult || undefined,
notes: r.notes.trim() || undefined,
}));
const result = await backendApi.submitOSSBallotsBulk(ossId, ballots);
alert(`Внесено: ${result.inserted} новых, обновлено: ${result.updated}. Всего: ${result.total}`);
window.dispatchEvent(new CustomEvent('mkd-oss-changed'));
onSuccess();
onClose();
setRows([emptyRow()]);
} catch (error: any) {
console.error('Error submitting bulk ballots:', error);
const msg = error?.response?.data?.error || error?.message || error?.error || 'Ошибка при загрузке бюллетеней';
alert(msg);
} finally {
setLoading(false);
}
};
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-4xl shadow-2xl animate-slide-up max-h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex justify-between items-center rounded-t-2xl">
<div>
<h3 className="text-lg font-black text-slate-800">Массовый ввод бюллетеней</h3>
<p className="text-xs text-slate-500 mt-1">{ossAddress}</p>
</div>
<button type="button" onClick={onClose} className="p-2 hover:bg-slate-100 rounded-xl transition-colors">
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
<div className="px-6 py-4 flex gap-2 flex-wrap border-b border-slate-100">
<button
type="button"
onClick={addRow}
className="px-3 py-2 bg-slate-100 text-slate-700 rounded-xl text-xs font-bold flex items-center gap-2 hover:bg-slate-200"
>
<Plus className="w-4 h-4" /> Добавить строку
</button>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="px-3 py-2 bg-primary-50 text-primary-700 rounded-xl text-xs font-bold flex items-center gap-2 hover:bg-primary-100"
>
<Upload className="w-4 h-4" /> Импорт CSV
</button>
<input
ref={fileInputRef}
type="file"
accept=".csv,.txt"
className="hidden"
onChange={handleImportCSV}
/>
<button
type="button"
onClick={handleDownloadTemplate}
className="px-3 py-2 bg-slate-100 text-slate-600 rounded-xl text-xs font-bold flex items-center gap-2 hover:bg-slate-200"
>
<Download className="w-4 h-4" /> Скачать шаблон
</button>
</div>
<form onSubmit={handleSubmit} className="flex-1 overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto px-6 py-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b-2 border-slate-200">
<th className="text-left py-2 px-2 text-[10px] font-black text-slate-500 uppercase">Помещение *</th>
<th className="text-left py-2 px-2 text-[10px] font-black text-slate-500 uppercase">Собственник</th>
<th className="text-left py-2 px-2 text-[10px] font-black text-slate-500 uppercase">Площадь (м²) *</th>
<th className="text-left py-2 px-2 text-[10px] font-black text-slate-500 uppercase">Голос</th>
<th className="text-left py-2 px-2 text-[10px] font-black text-slate-500 uppercase">Примечания</th>
<th className="w-10" />
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={index} className="border-b border-slate-100">
<td className="py-1.5 px-2">
<input
type="text"
value={row.apartment}
onChange={(e) => updateRow(index, 'apartment', e.target.value)}
placeholder="15, 25А"
className="w-full p-1.5 rounded-lg border border-slate-200 text-sm"
/>
</td>
<td className="py-1.5 px-2">
<input
type="text"
value={row.ownerName}
onChange={(e) => updateRow(index, 'ownerName', e.target.value)}
placeholder="ФИО"
className="w-full p-1.5 rounded-lg border border-slate-200 text-sm"
/>
</td>
<td className="py-1.5 px-2">
<input
type="number"
min="0"
step="0.01"
value={row.area || ''}
onChange={(e) => updateRow(index, 'area', parseFloat(e.target.value) || 0)}
placeholder="0"
className="w-20 p-1.5 rounded-lg border border-slate-200 text-sm"
/>
</td>
<td className="py-1.5 px-2">
<select
value={row.voteResult}
onChange={(e) => updateRow(index, 'voteResult', e.target.value as any)}
className="p-1.5 rounded-lg border border-slate-200 text-sm bg-white"
>
<option value=""></option>
<option value="for">За</option>
<option value="against">Против</option>
<option value="abstain">Воздержался</option>
</select>
</td>
<td className="py-1.5 px-2">
<input
type="text"
value={row.notes}
onChange={(e) => updateRow(index, 'notes', e.target.value)}
placeholder="—"
className="w-full p-1.5 rounded-lg border border-slate-200 text-sm"
/>
</td>
<td className="py-1.5 px-1">
<button
type="button"
onClick={() => removeRow(index)}
disabled={rows.length === 1}
className="p-1.5 text-slate-400 hover:text-red-600 disabled:opacity-40"
title="Удалить строку"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
<p className="text-xs text-slate-400 mt-2">
Строки без помещения или с площадью 0 не отправляются. Формат CSV: разделитель «;», колонки: Помещение;Собственник;Площадь (м²);Голос (За/Против/Воздержался);Примечания
</p>
</div>
<div className="sticky bottom-0 bg-white border-t border-slate-200 px-6 py-4 flex justify-between items-center rounded-b-2xl">
<span className="text-xs text-slate-500">
К отправке: <strong>{validRows.length}</strong> строк
</span>
<div className="flex gap-2">
<button type="button" onClick={onClose} className="px-6 py-2.5 bg-slate-100 text-slate-700 rounded-xl text-sm font-bold hover:bg-slate-200">
Отмена
</button>
<button
type="submit"
disabled={loading || validRows.length === 0}
className="px-6 py-2.5 bg-primary-600 text-white rounded-xl text-sm font-bold hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Отправка...' : 'Внести бюллетени'}
</button>
</div>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,442 @@
import React, { useState, useEffect } from 'react';
import { X, Plus, Trash2 } from 'lucide-react';
import { DevOSSSession } from '../../types';
import { Building } from '../../types';
import { backendApi } from '../../services/apiClient';
interface Props {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
export const CreateOSSModal: React.FC<Props> = ({ isOpen, onClose, onSuccess }) => {
const [formData, setFormData] = useState({
buildingId: '',
address: '',
startDate: '',
endDate: '',
quorumTotal: 0,
type: 'extraordinary' as 'annual' | 'extraordinary',
description: '',
source: 'existing' as 'existing' | 'pipeline',
agendaItems: [] as string[],
});
const [buildings, setBuildings] = useState<Building[]>([]);
const [pipelineAddresses, setPipelineAddresses] = useState<{id: string, address: string}[]>([]);
const [loading, setLoading] = useState(false);
const [loadingBuildings, setLoadingBuildings] = useState(true);
useEffect(() => {
if (isOpen) {
fetchData();
}
}, [isOpen]);
const fetchData = async () => {
try {
setLoadingBuildings(true);
// Загружаем существующие дома
const buildingsData = await backendApi.getBuildings();
setBuildings(buildingsData);
// Загружаем адреса из воронки
try {
const pipelineData = await backendApi.getDevelopmentPipeline();
setPipelineAddresses(pipelineData.map(p => ({ id: p.id, address: p.address })));
} catch (err) {
console.warn('Failed to load pipeline addresses:', err);
}
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoadingBuildings(false);
}
};
if (!isOpen) return null;
const handleSourceChange = (source: 'existing' | 'pipeline') => {
setFormData({ ...formData, source, buildingId: '', address: '' });
};
const handleBuildingChange = (value: string) => {
if (formData.source === 'existing') {
const building = buildings.find(b => b.id === value);
const totalArea = building?.passport?.general?.totalArea;
setFormData({
...formData,
buildingId: value,
address: building?.passport?.address || '',
quorumTotal: totalArea && totalArea > 0 ? totalArea : 0
});
} else {
const pipelineItem = pipelineAddresses.find(p => p.id === value);
setFormData({
...formData,
buildingId: value,
address: pipelineItem?.address || '',
// Для объектов из воронки площадь не заполняется автоматически
});
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Улучшенная валидация
const address = formData.address?.trim();
const startDate = formData.startDate?.trim();
const endDate = formData.endDate?.trim() || defaultEndDate;
const quorumTotal = formData.quorumTotal;
if (!address || address.length === 0) {
alert('Заполните обязательное поле: адрес');
return;
}
if (!startDate || startDate.length === 0) {
alert('Заполните обязательное поле: дата начала');
return;
}
if (!endDate || endDate.length === 0) {
alert('Заполните обязательное поле: дата окончания');
return;
}
if (!quorumTotal || quorumTotal <= 0) {
alert('Заполните обязательное поле: общая площадь (должна быть больше 0)');
return;
}
if (new Date(endDate) <= new Date(startDate)) {
alert('Дата окончания должна быть позже даты начала');
return;
}
try {
setLoading(true);
const agenda = formData.agendaItems.filter((t) => t.trim()).length > 0 ? formData.agendaItems.filter((t) => t.trim()) : undefined;
const result = await backendApi.createDevelopmentOSS({
address,
buildingId: formData.source === 'existing' ? formData.buildingId : null,
building_id: formData.source === 'existing' ? formData.buildingId : null,
startDate: startDate,
start_date: startDate,
endDate: endDate,
end_date: endDate,
quorumTotal,
quorum_total: quorumTotal,
quorumCurrent: 0,
type: formData.type,
status: 'planned' as const,
description: formData.description?.trim() || null,
agenda_items: agenda,
agendaItems: agenda,
} as any);
console.log('OSS created successfully:', result);
// Закрываем модальное окно и сбрасываем форму перед вызовом onSuccess
// чтобы избежать проблем с обновлением состояния
setFormData({
buildingId: '',
address: '',
startDate: '',
endDate: '',
quorumTotal: 0,
type: 'extraordinary',
description: '',
source: 'existing',
agendaItems: [],
});
onClose();
// Вызываем onSuccess после небольшой задержки, чтобы модальное окно успело закрыться
setTimeout(() => {
window.dispatchEvent(new CustomEvent('mkd-oss-changed'));
window.dispatchEvent(new CustomEvent('mkd-dev-summary-changed'));
onSuccess();
}, 100);
} catch (error: any) {
console.error('Error creating OSS:', error);
const errorMessage = error?.response?.data?.error || error?.message || error?.error || 'Ошибка при создании ОСС';
alert(`Ошибка при создании ОСС: ${errorMessage}`);
} finally {
setLoading(false);
}
};
// Вычисляем дату окончания по умолчанию (через 30 дней)
const defaultEndDate = formData.startDate
? new Date(new Date(formData.startDate).getTime() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[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-2xl shadow-2xl animate-slide-up max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex justify-between items-center">
<h3 className="text-lg font-black text-slate-800">Создать собрание собственников (ОСС)</h3>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-xl transition-colors"
>
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* Выбор источника */}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Тип ОСС
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleSourceChange('existing')}
className={`flex-1 p-3 rounded-xl border-2 transition-all ${
formData.source === 'existing'
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-slate-200 bg-white text-slate-600'
}`}
>
<div className="font-bold text-sm mb-1">Существующий дом</div>
<div className="text-[10px] text-slate-500">Для поднятия тарифов</div>
</button>
<button
type="button"
onClick={() => handleSourceChange('pipeline')}
className={`flex-1 p-3 rounded-xl border-2 transition-all ${
formData.source === 'pipeline'
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-slate-200 bg-white text-slate-600'
}`}
>
<div className="font-bold text-sm mb-1">Из воронки</div>
<div className="text-[10px] text-slate-500">Новый объект</div>
</button>
</div>
</div>
{/* Выбор дома/адреса */}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
{formData.source === 'existing' ? 'Дом *' : 'Объект из воронки *'}
</label>
{loadingBuildings ? (
<div className="w-full p-2.5 rounded-xl border border-slate-200 text-sm text-slate-400">
Загрузка...
</div>
) : (
<>
{formData.source === 'existing' ? (
<select
required
value={formData.buildingId}
onChange={(e) => handleBuildingChange(e.target.value)}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="">Выберите дом</option>
{buildings.map(building => (
<option key={building.id} value={building.id}>
{building.passport?.address || building.id}
</option>
))}
</select>
) : (
<select
required
value={formData.buildingId}
onChange={(e) => handleBuildingChange(e.target.value)}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="">Выберите объект из воронки</option>
{pipelineAddresses.map(item => (
<option key={item.id} value={item.id}>
{item.address}
</option>
))}
</select>
)}
{formData.source === 'pipeline' && pipelineAddresses.length === 0 && (
<p className="text-xs text-amber-600 mt-2">Нет объектов в воронке. Сначала добавьте объект в воронку.</p>
)}
</>
)}
</div>
{/* Адрес (автозаполняется, но можно редактировать) */}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Адрес *
</label>
<input
type="text"
required
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
placeholder="ул. Примерная, д. 1"
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
{/* Тип ОСС */}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Тип собрания
</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as 'annual' | 'extraordinary' })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="annual">Ежегодное отчетное собрание</option>
<option value="extraordinary">Внеочередное ОСС</option>
</select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Дата начала */}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Дата начала *
</label>
<input
type="date"
required
value={formData.startDate}
onChange={(e) => setFormData({ ...formData, startDate: e.target.value })}
min={new Date().toISOString().split('T')[0]}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
{/* Дата окончания */}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Дата окончания *
</label>
<input
type="date"
required
value={formData.endDate || (formData.startDate ? defaultEndDate : '')}
onChange={(e) => setFormData({ ...formData, endDate: e.target.value })}
min={formData.startDate || new Date().toISOString().split('T')[0]}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
{formData.startDate && !formData.endDate && (
<p className="text-xs text-slate-400 mt-1">Рекомендуемая дата: {defaultEndDate}</p>
)}
</div>
</div>
{/* Общая площадь для кворума */}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Общая площадь дома (м²) *
</label>
<input
type="number"
required
min="1"
step="0.01"
value={formData.quorumTotal || ''}
onChange={(e) => {
const value = e.target.value;
setFormData({ ...formData, quorumTotal: value ? parseFloat(value) : 0 });
}}
placeholder="25000"
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
<p className="text-xs text-slate-400 mt-1">Для расчета кворума (50% + 1 м²). Минимум: 1 м²</p>
{formData.quorumTotal <= 0 && formData.quorumTotal !== 0 && (
<p className="text-xs text-amber-600 mt-1">Площадь должна быть больше 0</p>
)}
</div>
{/* Пункты повестки (голосование по каждому) */}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Пункты повестки (голос по каждому)
</label>
<p className="text-xs text-slate-400 mb-2">Добавьте пункты в бюллетене можно будет указать «За» / «Против» / «Воздержался» по каждому.</p>
<div className="space-y-2">
{(formData.agendaItems.length === 0 ? [''] : formData.agendaItems).map((text, idx) => (
<div key={idx} className="flex gap-2 items-center">
<span className="text-[10px] font-bold text-slate-400 w-6">{idx + 1}.</span>
<input
type="text"
value={text}
onChange={(e) => {
const next = [...(formData.agendaItems.length ? formData.agendaItems : [''])];
next[idx] = e.target.value;
setFormData({ ...formData, agendaItems: next });
}}
placeholder="Формулировка пункта"
className="flex-1 p-2 rounded-xl border border-slate-200 text-sm"
/>
<button
type="button"
onClick={() => {
const next = formData.agendaItems.length ? formData.agendaItems.filter((_, i) => i !== idx) : [];
setFormData({ ...formData, agendaItems: next });
}}
className="p-2 text-slate-400 hover:text-red-600"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
<button
type="button"
onClick={() => setFormData({ ...formData, agendaItems: [...(formData.agendaItems.length ? formData.agendaItems : ['']), ''] })}
className="flex items-center gap-2 text-xs font-bold text-primary-600 hover:text-primary-700"
>
<Plus className="w-4 h-4" /> Добавить пункт
</button>
</div>
</div>
{/* Описание */}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Описание
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
placeholder="Внеочередное ОСС по вопросу смены УК"
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none resize-none"
/>
</div>
<div className="flex gap-3 justify-end pt-4 border-t border-slate-200">
<button
type="button"
onClick={onClose}
className="px-6 py-2.5 bg-slate-100 text-slate-700 rounded-xl text-sm font-bold hover:bg-slate-200 transition-colors"
>
Отмена
</button>
<button
type="submit"
disabled={loading}
className="px-6 py-2.5 bg-primary-600 text-white rounded-xl text-sm font-bold hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Создание...' : 'Создать ОСС'}
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,232 @@
import React, { useState, useEffect } from 'react';
import { TrendingUp, Vote, Users, Briefcase, Map as MapIcon, Target, ChevronRight } from 'lucide-react';
import { backendApi } from '../../services/apiClient';
import { readCache, saveCache } from '../../hooks/useCachedFetch';
import { REFRESH_EVENTS } from '../../constants/refreshEvents';
interface Props {
onNavigate: (tab: any) => void;
}
const CACHE_KEY = 'mkd_dev_summary_cache';
const CACHE_DEFAULT = { summary: { growthM2: 0, ossSuccessRate: 0, cac: 15000, potentialRevenue: 0 }, locations: [] };
export const DevSummary: React.FC<Props> = ({ onNavigate }) => {
const cached = readCache<{ summary: any; locations: any[] }>(CACHE_KEY, CACHE_DEFAULT);
const [summary, setSummary] = useState(cached.summary || CACHE_DEFAULT.summary);
const [locations, setLocations] = useState<any[]>(cached.locations || []);
const hasCache = (cached.locations?.length ?? 0) > 0;
const [loading, setLoading] = useState(!hasCache);
useEffect(() => {
const fetchData = async () => {
if (!hasCache) setLoading(true);
try {
const [summaryData, locationsData] = await Promise.all([
backendApi.getDevelopmentSummary(),
backendApi.getDevelopmentLocations()
]);
setSummary(summaryData);
setLocations(locationsData);
saveCache(CACHE_KEY, { summary: summaryData, locations: locationsData });
} catch (error) {
console.error('Error fetching development summary:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
useEffect(() => {
const onRefresh = () => {
(async () => {
try {
const [summaryData, locationsData] = await Promise.all([
backendApi.getDevelopmentSummary(),
backendApi.getDevelopmentLocations()
]);
setSummary(summaryData);
setLocations(locationsData);
saveCache(CACHE_KEY, { summary: summaryData, locations: locationsData });
} catch (e) {
console.error('Error fetching development summary:', e);
}
})();
};
window.addEventListener(REFRESH_EVENTS.devSummary, onRefresh);
return () => window.removeEventListener(REFRESH_EVENTS.devSummary, onRefresh);
}, []);
useEffect(() => {
const interval = setInterval(async () => {
try {
const [summaryData, locationsData] = await Promise.all([
backendApi.getDevelopmentSummary(),
backendApi.getDevelopmentLocations()
]);
setSummary(summaryData);
setLocations(locationsData);
saveCache(CACHE_KEY, { summary: summaryData, locations: locationsData });
} catch (e) {
console.error('Error fetching development summary:', e);
}
}, 10 * 1000);
return () => clearInterval(interval);
}, []);
return (
<div className="space-y-6 animate-fade-in">
{/* KPI Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
icon={TrendingUp}
label="Прирост фонда"
value={loading ? '...' : `+${summary.growthM2} м²`}
color="text-emerald-600"
bg="bg-emerald-50"
/>
<StatCard
icon={Vote}
label="Успех ОСС"
value={loading ? '...' : `${summary.ossSuccessRate}%`}
color="text-blue-600"
bg="bg-blue-50"
/>
<StatCard
icon={Users}
label="CAC (За дом)"
value={loading ? '...' : `${summary.cac.toLocaleString()}`}
color="text-amber-600"
bg="bg-amber-50"
/>
<StatCard
icon={Briefcase}
label="Выручка в воронке"
value={loading ? '...' : `${(summary.potentialRevenue/1000).toFixed(0)}k ₽`}
color="text-violet-600"
bg="bg-violet-50"
/>
</div>
{/* Capture Map Widget */}
<div className="bg-slate-900 rounded-[2rem] p-8 text-white shadow-xl relative overflow-hidden min-h-[350px]">
<div className="relative z-10 max-w-sm">
<div className="flex items-center gap-2 text-primary-400 mb-2">
<MapIcon className="w-5 h-5"/>
<span className="text-[10px] font-black uppercase tracking-widest">Стратегия экспансии</span>
</div>
<h3 className="text-3xl font-black mb-4 leading-tight">Карта присутствия</h3>
<p className="text-xs text-slate-400 font-medium mb-8">Интерактивный мониторинг конкурентов и зон активного голосования в Центральном районе.</p>
</div>
{/* Visual Map Simulation */}
<div className="absolute top-10 right-10 left-10 md:left-auto md:w-1/2 bottom-10 bg-slate-800/50 rounded-2xl border border-slate-700 overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.03)_1px,transparent_1px)] bg-[size:30px_30px]"></div>
{/* Map Pointers - динамические из БД */}
{locations.length > 0 ? (
locations.slice(0, 10).map((loc, idx) => {
// Распределяем точки по карте (упрощенная логика)
const positions = [
{ top: '20%', left: '30%' },
{ top: '45%', left: '60%' },
{ top: '70%', left: '25%' },
{ top: '35%', left: '80%' },
{ top: '15%', left: '70%' },
{ top: '60%', left: '40%' },
{ top: '30%', left: '50%' },
{ top: '50%', left: '20%' },
{ top: '80%', left: '60%' },
{ top: '10%', left: '40%' },
];
const pos = positions[idx % positions.length];
const label = loc.status === 'voting' ? 'ОСС ИДЕТ' : undefined;
return (
<MapPointer
key={loc.id || idx}
top={pos.top}
left={pos.left}
status={loc.status}
label={label}
address={loc.address}
/>
);
})
) : (
// Fallback статические точки, если нет данных
<>
<MapPointer top="20%" left="30%" status="ours" />
<MapPointer top="45%" left="60%" status="ours" />
<MapPointer top="70%" left="25%" status="voting" label="ОСС ИДЕТ" />
<MapPointer top="35%" left="80%" status="competitor" />
<MapPointer top="15%" left="70%" status="competitor" />
</>
)}
<div className="absolute bottom-4 right-4 bg-slate-900/90 p-3 rounded-xl border border-slate-700 text-[9px] space-y-1.5 shadow-2xl">
<div className="flex items-center gap-2"><div className="w-2 h-2 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.6)]"/> Наш фонд</div>
<div className="flex items-center gap-2"><div className="w-2 h-2 rounded-full bg-amber-400 animate-pulse"/> Проходит ОСС</div>
<div className="flex items-center gap-2"><div className="w-2 h-2 rounded-full bg-red-500"/> Другие УК</div>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<button onClick={() => onNavigate('pipeline')} className="bg-white p-6 rounded-3xl border border-slate-200 shadow-sm flex items-center justify-between group hover:border-primary-400 transition-all">
<div className="flex items-center gap-4">
<div className="p-3 bg-primary-50 text-primary-600 rounded-2xl group-hover:bg-primary-600 group-hover:text-white transition-colors">
<Target className="w-6 h-6"/>
</div>
<div className="text-left">
<p className="font-bold text-slate-800">Перейти в воронку</p>
<p className="text-xs text-slate-500">Управление лидами</p>
</div>
</div>
<ChevronRight className="w-5 h-5 text-slate-300 group-hover:text-primary-500 group-hover:translate-x-1 transition-all"/>
</button>
<button onClick={() => onNavigate('oss')} className="bg-white p-6 rounded-3xl border border-slate-200 shadow-sm flex items-center justify-between group hover:border-primary-400 transition-all">
<div className="flex items-center gap-4">
<div className="p-3 bg-amber-50 text-amber-600 rounded-2xl group-hover:bg-amber-500 group-hover:text-white transition-colors">
<Vote className="w-6 h-6"/>
</div>
<div className="text-left">
<p className="font-bold text-slate-800">Центр ОСС</p>
<p className="text-xs text-slate-500">Контроль текущих голосований</p>
</div>
</div>
<ChevronRight className="w-5 h-5 text-slate-300 group-hover:text-primary-500 group-hover:translate-x-1 transition-all"/>
</button>
</div>
</div>
);
};
const StatCard = ({ icon: Icon, label, value, color, bg }: any) => (
<div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm">
<div className="flex justify-between items-start mb-2">
<div className={`p-2 ${bg} ${color} rounded-lg`}><Icon className="w-5 h-5"/></div>
</div>
<p className="text-xl font-black text-slate-800">{value}</p>
<p className="text-[10px] text-slate-500 font-bold uppercase tracking-wider">{label}</p>
</div>
);
const MapPointer = ({ top, left, status, label, address }: any) => (
<div className="absolute group" style={{ top, left }}>
<div className={`w-3 h-3 rounded-full relative cursor-pointer ${
status === 'ours' ? 'bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.5)]' :
status === 'voting' ? 'bg-amber-400 animate-pulse shadow-[0_0_15px_rgba(251,191,36,0.5)]' :
'bg-red-500'
}`}>
{(label || address) && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 bg-white text-slate-900 text-[7px] font-black px-1 rounded shadow-lg whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
{label || address}
</div>
)}
</div>
</div>
);

View File

@@ -0,0 +1,227 @@
import React, { useState, useEffect } from 'react';
import { X, Plus, Trash2 } from 'lucide-react';
import { DevOSSSession } from '../../types';
import { backendApi } from '../../services/apiClient';
interface Props {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
session: DevOSSSession | null;
}
export const EditOSSModal: React.FC<Props> = ({ isOpen, onClose, onSuccess, session }) => {
const [formData, setFormData] = useState({
address: '',
startDate: '',
endDate: '',
status: 'planned' as 'active' | 'planned' | 'completed',
type: 'extraordinary' as 'annual' | 'extraordinary',
description: '',
agendaItems: [] as string[],
});
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isOpen && session) {
setFormData({
address: session.address || '',
startDate: session.startDate ? session.startDate.slice(0, 10) : '',
endDate: session.endDate ? session.endDate.slice(0, 10) : '',
status: session.status || 'planned',
type: session.type || 'extraordinary',
description: (session.description ?? '') || '',
agendaItems: Array.isArray(session.agendaItems) && session.agendaItems.length > 0
? [...session.agendaItems]
: [''],
});
}
}, [isOpen, session]);
if (!isOpen || !session) return null;
const defaultEndDate = formData.startDate
? new Date(new Date(formData.startDate).getTime() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
: '';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const address = formData.address?.trim();
const startDate = formData.startDate?.trim();
const endDate = formData.endDate?.trim() || defaultEndDate;
if (!address || !startDate || !endDate) {
alert('Заполните обязательные поля: адрес, дата начала, дата окончания');
return;
}
if (new Date(endDate) <= new Date(startDate)) {
alert('Дата окончания должна быть позже даты начала');
return;
}
try {
setLoading(true);
const agenda = formData.agendaItems.filter((t) => t.trim()).length > 0
? formData.agendaItems.filter((t) => t.trim())
: undefined;
await backendApi.updateDevelopmentOSS(session.id, {
address,
startDate,
endDate,
status: formData.status,
type: formData.type,
description: formData.description?.trim() || null,
agendaItems: agenda,
});
window.dispatchEvent(new CustomEvent('mkd-oss-changed'));
window.dispatchEvent(new CustomEvent('mkd-dev-summary-changed'));
onClose();
onSuccess();
} catch (error: any) {
console.error('Error updating OSS:', error);
const errorMessage = error?.response?.data?.error || error?.message || error?.error || 'Ошибка при сохранении ОСС';
alert(errorMessage);
} finally {
setLoading(false);
}
};
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-2xl shadow-2xl animate-slide-up max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex justify-between items-center">
<h3 className="text-lg font-black text-slate-800">Редактировать ОСС</h3>
<button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-xl transition-colors">
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">Адрес *</label>
<input
type="text"
required
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
placeholder="ул. Примерная, д. 1"
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">Тип собрания</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as 'annual' | 'extraordinary' })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="annual">Ежегодное отчетное собрание</option>
<option value="extraordinary">Внеочередное ОСС</option>
</select>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">Статус</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as 'active' | 'planned' | 'completed' })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="planned">Запланировано</option>
<option value="active">Идёт голосование</option>
<option value="completed">Завершено</option>
</select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">Дата начала *</label>
<input
type="date"
required
value={formData.startDate}
onChange={(e) => setFormData({ ...formData, startDate: e.target.value })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">Дата окончания *</label>
<input
type="date"
required
value={formData.endDate || (formData.startDate ? defaultEndDate : '')}
onChange={(e) => setFormData({ ...formData, endDate: e.target.value })}
min={formData.startDate || undefined}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">Пункты повестки</label>
<div className="space-y-2">
{(formData.agendaItems.length === 0 ? [''] : formData.agendaItems).map((text, idx) => (
<div key={idx} className="flex gap-2 items-center">
<span className="text-[10px] font-bold text-slate-400 w-6">{idx + 1}.</span>
<input
type="text"
value={text}
onChange={(e) => {
const next = [...(formData.agendaItems.length ? formData.agendaItems : [''])];
next[idx] = e.target.value;
setFormData({ ...formData, agendaItems: next });
}}
placeholder="Формулировка пункта"
className="flex-1 p-2 rounded-xl border border-slate-200 text-sm"
/>
<button
type="button"
onClick={() => {
const next = formData.agendaItems.length ? formData.agendaItems.filter((_, i) => i !== idx) : [];
setFormData({ ...formData, agendaItems: next });
}}
className="p-2 text-slate-400 hover:text-red-600"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
<button
type="button"
onClick={() => setFormData({ ...formData, agendaItems: [...(formData.agendaItems.length ? formData.agendaItems : ['']), ''] })}
className="flex items-center gap-2 text-xs font-bold text-primary-600 hover:text-primary-700"
>
<Plus className="w-4 h-4" /> Добавить пункт
</button>
</div>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">Описание</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
placeholder="Внеочередное ОСС по вопросу смены УК"
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none resize-none"
/>
</div>
<div className="flex gap-3 justify-end pt-4 border-t border-slate-200">
<button type="button" onClick={onClose} className="px-6 py-2.5 bg-slate-100 text-slate-700 rounded-xl text-sm font-bold hover:bg-slate-200 transition-colors">
Отмена
</button>
<button type="submit" disabled={loading} className="px-6 py-2.5 bg-primary-600 text-white rounded-xl text-sm font-bold hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{loading ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,288 @@
import React, { useState, useEffect } from 'react';
import { X, Trash2 } from 'lucide-react';
import { DevPipelineItem, DevPipelineStatus, Employee } from '../../types';
import { backendApi } from '../../services/apiClient';
interface Props {
item: DevPipelineItem | null;
onClose: () => void;
onSuccess: () => void;
}
const STAGES: { id: DevPipelineStatus; label: string }[] = [
{ id: 'incoming', label: 'Входящие' },
{ id: 'analysis', label: 'Анализ' },
{ id: 'agenda_approval', label: 'Согласование повестки' },
{ id: 'in_person', label: 'Очная часть' },
{ id: 'absentee', label: 'Заочная часть' },
{ id: 'protocol_formation', label: 'Формирование протокола' },
{ id: 'protocol_to_gzhi', label: 'Отправка протокола в ГЖИ' },
{ id: 'gzhi_order', label: 'Приказ ГЖИ' },
{ id: 'success', label: 'Успех' },
{ id: 'failure', label: 'Провал' },
];
export const EditPipelineObjectModal: React.FC<Props> = ({ item, onClose, onSuccess }) => {
const [formData, setFormData] = useState<Partial<DevPipelineItem>>({});
const [employees, setEmployees] = useState<Employee[]>([]);
const [loading, setLoading] = useState(false);
const [loadingEmployees, setLoadingEmployees] = useState(true);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
if (item) {
setFormData({
address: item.address,
type: item.type,
floors: item.floors,
area: item.area,
apartments: item.apartments,
status: item.status,
probability: item.probability,
expectedRevenue: item.expectedRevenue,
manager: item.manager,
notes: item.notes ?? '',
});
fetchEmployees();
}
}, [item]);
const fetchEmployees = async () => {
try {
setLoadingEmployees(true);
const data = await backendApi.getEmployees();
setEmployees(data.filter(emp => emp.status === 'active'));
} catch (error) {
console.error('Error fetching employees:', error);
setEmployees([]);
} finally {
setLoadingEmployees(false);
}
};
if (!item) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.address || !formData.manager) {
alert('Заполните обязательные поля: адрес и менеджер');
return;
}
if ((formData.area ?? 0) <= 0 || (formData.apartments ?? 0) <= 0 || (formData.floors ?? 0) <= 0) {
alert('Площадь, количество квартир и этажность должны быть больше 0');
return;
}
try {
setLoading(true);
await backendApi.updateDevelopmentPipeline(item.id, {
address: formData.address,
type: formData.type,
floors: formData.floors,
area: formData.area,
apartments: formData.apartments,
status: formData.status,
probability: formData.probability,
expectedRevenue: formData.expectedRevenue,
manager: formData.manager,
notes: formData.notes ?? undefined,
});
window.dispatchEvent(new CustomEvent('mkd-pipeline-changed'));
onSuccess();
onClose();
} catch (error: unknown) {
const err = error as { message?: string; error?: string };
alert(err?.message || err?.error || 'Ошибка при сохранении');
} finally {
setLoading(false);
}
};
const handleDelete = async () => {
if (!confirm('Удалить объект из воронки?')) return;
try {
setDeleting(true);
await backendApi.deleteDevelopmentPipeline(item.id);
window.dispatchEvent(new CustomEvent('mkd-pipeline-changed'));
onSuccess();
onClose();
} catch (error: unknown) {
const err = error as { message?: string; error?: string };
alert(err?.message || err?.error || 'Ошибка при удалении');
} finally {
setDeleting(false);
}
};
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-2xl shadow-2xl animate-slide-up max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex justify-between items-center">
<h3 className="text-lg font-black text-slate-800">Карточка объекта</h3>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleDelete}
disabled={deleting}
className="p-2 text-red-500 hover:bg-red-50 rounded-xl transition-colors disabled:opacity-50"
title="Удалить"
>
<Trash2 className="w-5 h-5" />
</button>
<button type="button" onClick={onClose} className="p-2 hover:bg-slate-100 rounded-xl transition-colors">
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">Адрес *</label>
<input
type="text"
required
value={formData.address ?? ''}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
placeholder="ул. Примерная, д. 1"
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">Тип объекта</label>
<select
value={formData.type ?? 'old'}
onChange={(e) => setFormData({ ...formData, type: e.target.value as 'old' | 'new' })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="old">Вторичка</option>
<option value="new">Новостройка</option>
</select>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">Этажность</label>
<input
type="number"
required
min={1}
value={formData.floors ?? 0}
onChange={(e) => setFormData({ ...formData, floors: parseInt(e.target.value, 10) || 0 })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">Площадь (м²)</label>
<input
type="number"
required
min={0}
step={0.01}
value={formData.area ?? 0}
onChange={(e) => setFormData({ ...formData, area: parseFloat(e.target.value) || 0 })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">Количество квартир</label>
<input
type="number"
required
min={0}
value={formData.apartments ?? 0}
onChange={(e) => setFormData({ ...formData, apartments: parseInt(e.target.value, 10) || 0 })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">Этап воронки</label>
<select
value={formData.status ?? 'incoming'}
onChange={(e) => setFormData({ ...formData, status: e.target.value as DevPipelineStatus })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
>
{STAGES.map((s) => (
<option key={s.id} value={s.id}>{s.label}</option>
))}
</select>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">Вероятность (%)</label>
<input
type="number"
required
min={0}
max={100}
value={formData.probability ?? 0}
onChange={(e) => setFormData({ ...formData, probability: parseInt(e.target.value, 10) || 0 })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">Ожидаемая выручка ()</label>
<input
type="number"
required
min={0}
value={formData.expectedRevenue ?? 0}
onChange={(e) => setFormData({ ...formData, expectedRevenue: parseFloat(e.target.value) || 0 })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">Менеджер *</label>
{loadingEmployees ? (
<div className="w-full p-2.5 rounded-xl border border-slate-200 text-sm text-slate-400">Загрузка...</div>
) : (
<select
required
value={formData.manager ?? ''}
onChange={(e) => setFormData({ ...formData, manager: e.target.value })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="">Выберите менеджера</option>
{employees.map((emp) => (
<option key={emp.id} value={emp.name}>
{emp.name} {emp.position ? `(${emp.position})` : ''}
</option>
))}
</select>
)}
</div>
<div className="md:col-span-2">
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">Примечания</label>
<textarea
value={formData.notes ?? ''}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
rows={3}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none resize-none"
/>
</div>
</div>
<div className="flex gap-3 justify-end pt-4 border-t border-slate-200">
<button
type="button"
onClick={onClose}
className="px-6 py-2.5 bg-slate-100 text-slate-700 rounded-xl text-sm font-bold hover:bg-slate-200 transition-colors"
>
Отмена
</button>
<button
type="submit"
disabled={loading}
className="px-6 py-2.5 bg-primary-600 text-white rounded-xl text-sm font-bold hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,376 @@
import React, { useState, useEffect } from 'react';
import { X } from 'lucide-react';
import { DevMarketingActivity } from '../../types';
import { Building } from '../../types';
import { backendApi } from '../../services/apiClient';
interface Props {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
activity?: DevMarketingActivity | null;
}
export const MarketingActivityModal: React.FC<Props> = ({ isOpen, onClose, onSuccess, activity }) => {
const [formData, setFormData] = useState({
buildingId: '',
address: '',
activistsCount: 0,
meetingsHeld: 0,
adsDistributed: 0,
competitor: '',
status: 'voting' as 'voting' | 'my_house' | 'competitor_house',
notes: '',
source: 'existing' as 'existing' | 'pipeline',
});
const [buildings, setBuildings] = useState<Building[]>([]);
const [pipelineAddresses, setPipelineAddresses] = useState<{id: string, address: string}[]>([]);
const [loading, setLoading] = useState(false);
const [loadingBuildings, setLoadingBuildings] = useState(true);
const isEditMode = !!activity;
useEffect(() => {
if (isOpen) {
if (activity) {
// Режим редактирования
setFormData({
buildingId: activity.buildingId || '',
address: activity.address,
activistsCount: activity.activistsCount,
meetingsHeld: activity.meetingsHeld,
adsDistributed: activity.adsDistributed,
competitor: activity.competitor || '',
status: activity.status,
notes: '',
source: activity.buildingId ? 'existing' : 'pipeline',
});
}
fetchData();
}
}, [isOpen, activity]);
const fetchData = async () => {
try {
setLoadingBuildings(true);
const buildingsData = await backendApi.getBuildings();
setBuildings(buildingsData);
try {
const pipelineData = await backendApi.getDevelopmentPipeline();
setPipelineAddresses(pipelineData.map(p => ({ id: p.id, address: p.address })));
} catch (err) {
console.warn('Failed to load pipeline addresses:', err);
}
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoadingBuildings(false);
}
};
if (!isOpen) return null;
const handleSourceChange = (source: 'existing' | 'pipeline') => {
setFormData({ ...formData, source, buildingId: '', address: '' });
};
const handleBuildingChange = (value: string) => {
if (formData.source === 'existing') {
const building = buildings.find(b => b.id === value);
setFormData({
...formData,
buildingId: value,
address: building?.passport?.address || ''
});
} else {
const pipelineItem = pipelineAddresses.find(p => p.id === value);
setFormData({
...formData,
buildingId: value,
address: pipelineItem?.address || ''
});
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.address || !formData.status) {
alert('Заполните обязательные поля: адрес и статус');
return;
}
try {
setLoading(true);
if (isEditMode && activity) {
// Обновление существующей активности
await backendApi.updateDevelopmentMarketing(activity.id, {
address: formData.address,
activists_count: formData.activistsCount,
meetings_held: formData.meetingsHeld,
ads_distributed: formData.adsDistributed,
competitor: formData.competitor || null,
status: formData.status,
notes: formData.notes || null,
});
} else {
// Создание новой активности
const payload = {
address: formData.address,
building_id: formData.source === 'existing' ? formData.buildingId : null,
activists_count: formData.activistsCount,
meetings_held: formData.meetingsHeld,
ads_distributed: formData.adsDistributed,
competitor: formData.competitor || null,
status: formData.status,
notes: formData.notes || null,
};
await backendApi.createDevelopmentMarketing(payload);
}
onSuccess();
onClose();
// Сброс формы только если не режим редактирования
if (!isEditMode) {
setFormData({
buildingId: '',
address: '',
activistsCount: 0,
meetingsHeld: 0,
adsDistributed: 0,
competitor: '',
status: 'voting',
notes: '',
source: 'existing',
});
}
} catch (error: any) {
console.error('Error saving marketing activity:', error);
const errorMessage = error?.message || error?.error || 'Ошибка при сохранении';
alert(errorMessage);
} finally {
setLoading(false);
}
};
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-2xl shadow-2xl animate-slide-up max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex justify-between items-center">
<h3 className="text-lg font-black text-slate-800">
{isEditMode ? 'Редактировать активность' : 'Создать маркетинговую активность'}
</h3>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-xl transition-colors"
>
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{!isEditMode && (
<>
{/* Выбор источника */}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Тип объекта
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleSourceChange('existing')}
className={`flex-1 p-3 rounded-xl border-2 transition-all ${
formData.source === 'existing'
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-slate-200 bg-white text-slate-600'
}`}
>
<div className="font-bold text-sm">Существующий дом</div>
</button>
<button
type="button"
onClick={() => handleSourceChange('pipeline')}
className={`flex-1 p-3 rounded-xl border-2 transition-all ${
formData.source === 'pipeline'
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-slate-200 bg-white text-slate-600'
}`}
>
<div className="font-bold text-sm">Из воронки</div>
</button>
</div>
</div>
{/* Выбор дома/адреса */}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
{formData.source === 'existing' ? 'Дом *' : 'Объект из воронки *'}
</label>
{loadingBuildings ? (
<div className="w-full p-2.5 rounded-xl border border-slate-200 text-sm text-slate-400">
Загрузка...
</div>
) : (
<>
{formData.source === 'existing' ? (
<select
required={!isEditMode}
value={formData.buildingId}
onChange={(e) => handleBuildingChange(e.target.value)}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="">Выберите дом</option>
{buildings.map(building => (
<option key={building.id} value={building.id}>
{building.passport?.address || building.id}
</option>
))}
</select>
) : (
<select
required={!isEditMode}
value={formData.buildingId}
onChange={(e) => handleBuildingChange(e.target.value)}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="">Выберите объект из воронки</option>
{pipelineAddresses.map(item => (
<option key={item.id} value={item.id}>
{item.address}
</option>
))}
</select>
)}
</>
)}
</div>
</>
)}
{/* Адрес */}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Адрес *
</label>
<input
type="text"
required
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
{/* Статус */}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Статус *
</label>
<select
required
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="voting">Идет голосование</option>
<option value="my_house">Наш дом</option>
<option value="competitor_house">Дом конкурента</option>
</select>
</div>
{/* Конкурент */}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Текущая УК / Конкурент
</label>
<input
type="text"
value={formData.competitor}
onChange={(e) => setFormData({ ...formData, competitor: e.target.value })}
placeholder="УК ЖилКом, ТСЖ Рассвет и т.д."
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
{/* Метрики */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Количество активистов
</label>
<input
type="number"
min="0"
value={formData.activistsCount}
onChange={(e) => setFormData({ ...formData, activistsCount: parseInt(e.target.value) || 0 })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Проведено встреч
</label>
<input
type="number"
min="0"
value={formData.meetingsHeld}
onChange={(e) => setFormData({ ...formData, meetingsHeld: parseInt(e.target.value) || 0 })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Роздано листовок
</label>
<input
type="number"
min="0"
value={formData.adsDistributed}
onChange={(e) => setFormData({ ...formData, adsDistributed: parseInt(e.target.value) || 0 })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
</div>
{/* Примечания */}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Примечания
</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
rows={3}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none resize-none"
/>
</div>
<div className="flex gap-3 justify-end pt-4 border-t border-slate-200">
<button
type="button"
onClick={onClose}
className="px-6 py-2.5 bg-slate-100 text-slate-700 rounded-xl text-sm font-bold hover:bg-slate-200 transition-colors"
>
Отмена
</button>
<button
type="submit"
disabled={loading}
className="px-6 py-2.5 bg-primary-600 text-white rounded-xl text-sm font-bold hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Сохранение...' : isEditMode ? 'Сохранить' : 'Создать'}
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,225 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { DevMarketingActivity } from '../../types';
import { Megaphone, Users, MessageCircle, Phone, Calendar, ClipboardList, CheckCircle2, MapPin, Plus, Edit, Search, Filter } from 'lucide-react';
import { backendApi } from '../../services/apiClient';
import { MarketingActivityModal } from './MarketingActivityModal';
export const MarketingCampaigns: React.FC = () => {
const [activities, setActivities] = useState<DevMarketingActivity[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingActivity, setEditingActivity] = useState<DevMarketingActivity | null>(null);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const fetchActivities = useCallback(async () => {
try {
setLoading(true);
const params = statusFilter ? { status: statusFilter } : undefined;
const data = await backendApi.getDevelopmentMarketing(params);
setActivities(data);
} catch (error) {
console.error('Error fetching marketing activities:', error);
setActivities([]);
} finally {
setLoading(false);
}
}, [statusFilter]);
useEffect(() => {
fetchActivities();
}, [fetchActivities]);
const filteredActivities = useMemo(() => {
if (!search.trim()) return activities;
const q = search.trim().toLowerCase();
return activities.filter(a => (a.address || '').toLowerCase().includes(q) || (a.competitor || '').toLowerCase().includes(q));
}, [activities, search]);
const handleCreate = () => {
setEditingActivity(null);
setIsModalOpen(true);
};
const handleEdit = (activity: DevMarketingActivity) => {
setEditingActivity(activity);
setIsModalOpen(true);
};
const handleContacts = () => {
alert('В разработке: список контактов по объекту (активисты, председатели).');
};
const handleMeetingsPlan = () => {
alert('В разработке: календарь и список запланированных встреч по объекту.');
};
const totals = useMemo(() => {
const sum = (arr: DevMarketingActivity[], key: keyof DevMarketingActivity) =>
arr.reduce((acc, a) => acc + (Number(a[key]) || 0), 0);
return {
meetings: sum(activities, 'meetingsHeld'),
ads: sum(activities, 'adsDistributed'),
activists: sum(activities, 'activistsCount'),
withMetrics: activities.filter(a => (a.meetingsHeld || 0) > 0 || (a.adsDistributed || 0) > 0 || (a.activistsCount || 0) > 0).length,
};
}, [activities]);
return (
<div className="space-y-6 animate-fade-in">
<div className="flex flex-wrap justify-between items-center gap-3 px-1">
<h3 className="font-black text-slate-500 text-[10px] uppercase tracking-[0.2em]">Работа с активом</h3>
<div className="flex flex-wrap items-center gap-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={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 pr-3 py-2 rounded-xl border border-slate-200 text-sm w-48 focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
<div className="flex items-center gap-1.5">
<Filter className="w-4 h-4 text-slate-400" />
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="text-[10px] font-bold text-slate-600 bg-white border border-slate-200 rounded-lg py-2 px-2.5 focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="">Все статусы</option>
<option value="voting">Идёт голосование</option>
<option value="my_house">Наш дом</option>
<option value="competitor_house">Дом конкурента</option>
</select>
</div>
<button
onClick={handleCreate}
className="bg-primary-600 text-white p-2.5 rounded-xl shadow-lg shadow-primary-500/30 flex items-center gap-2 px-4 text-xs font-black uppercase tracking-wider active:scale-95 transition-all"
>
<Plus className="w-4 h-4" /> Добавить активность
</button>
<button className="p-2 bg-white border border-slate-200 rounded-xl text-slate-400 hover:text-primary-600 transition-colors" title="Отчёты"><ClipboardList className="w-5 h-5"/></button>
</div>
</div>
{loading ? (
<div className="text-center py-20 text-slate-400">
<div className="w-10 h-10 border-2 border-slate-200 rounded-full mx-auto mb-2 animate-spin" />
<p className="text-[10px] font-bold uppercase tracking-widest">Загрузка...</p>
</div>
) : activities.length === 0 ? (
<div className="text-center py-20 text-slate-300">
<div className="w-10 h-10 border-2 border-dashed border-slate-200 rounded-full mx-auto mb-2" />
<p className="text-[10px] font-bold uppercase tracking-widest">Нет маркетинговых активностей</p>
<p className="text-xs text-slate-400 mt-2 max-w-sm mx-auto">Агитация и маркетинг по объектам из воронки и существующим домам.</p>
</div>
) : filteredActivities.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<p className="text-[10px] font-bold uppercase tracking-widest">Нет активностей по фильтру</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filteredActivities.map(m => (
<div key={m.id} className="bg-white p-5 rounded-[2rem] border border-slate-200 shadow-sm relative overflow-hidden group hover:shadow-lg transition-all">
{/* Status Vertical Line */}
<div className={`absolute left-0 top-1/4 bottom-1/4 w-1.5 rounded-r-full ${m.status === 'voting' ? 'bg-amber-500' : 'bg-emerald-500'}`}/>
<div className="pl-4">
<div className="flex justify-between items-start mb-4">
<div>
<h4 className="font-black text-slate-800 text-base leading-tight group-hover:text-primary-600 transition-colors">{m.address}</h4>
<div className="flex items-center gap-1.5 mt-1">
<MapPin className="w-3 h-3 text-slate-400"/>
<p className="text-[10px] text-slate-400 font-bold uppercase">Тек. УК: {m.competitor}</p>
</div>
</div>
<button
onClick={() => handleEdit(m)}
className="p-1.5 text-slate-300 hover:text-primary-600 transition-colors"
title="Редактировать"
>
<Edit className="w-5 h-5"/>
</button>
</div>
<div className="grid grid-cols-3 gap-3 mb-6">
<MarketingMetric icon={Users} value={m.activistsCount} label="Активиста" color="text-primary-600" bg="bg-primary-50" />
<MarketingMetric icon={MessageCircle} value={m.meetingsHeld} label="Встречи" color="text-indigo-600" bg="bg-indigo-50" />
<MarketingMetric icon={Megaphone} value={m.adsDistributed} label="Листовки" color="text-amber-600" bg="bg-amber-50" />
</div>
<div className="flex gap-2 pt-4 border-t border-slate-100">
<button
onClick={handleContacts}
className="flex-1 py-2.5 bg-slate-50 text-slate-700 rounded-xl text-[10px] font-black uppercase flex items-center justify-center gap-2 hover:bg-slate-100 transition-all border border-slate-100"
>
<Phone className="w-3.5 h-3.5"/> Контакты
</button>
<button
onClick={handleMeetingsPlan}
className="flex-1 py-2.5 bg-primary-600 text-white rounded-xl text-[10px] font-black uppercase flex items-center justify-center gap-2 shadow-lg shadow-primary-500/20 active:scale-95 transition-all"
>
<Calendar className="w-3.5 h-3.5"/> План встреч
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Итоги по активностям (реальные данные) */}
<div className="bg-slate-900 rounded-[2rem] p-6 text-white shadow-xl">
<div className="flex items-center gap-2 mb-4">
<CheckCircle2 className="w-5 h-5 text-emerald-400" />
<h3 className="font-bold text-sm uppercase tracking-widest">Итоги по активностям</h3>
</div>
{activities.length === 0 ? (
<p className="text-[11px] text-slate-400">Данные по итогам месяца будут доступны после добавления активностей.</p>
) : (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white/10 rounded-xl p-4">
<p className="text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Встреч проведено</p>
<p className="text-2xl font-black text-white">{totals.meetings}</p>
</div>
<div className="bg-white/10 rounded-xl p-4">
<p className="text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Листовок роздано</p>
<p className="text-2xl font-black text-white">{totals.ads}</p>
</div>
<div className="bg-white/10 rounded-xl p-4">
<p className="text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Активистов</p>
<p className="text-2xl font-black text-white">{totals.activists}</p>
</div>
<div className="bg-white/10 rounded-xl p-4">
<p className="text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Объектов с метриками</p>
<p className="text-2xl font-black text-white">{totals.withMetrics} / {activities.length}</p>
</div>
</div>
)}
</div>
{/* Modal */}
<MarketingActivityModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
setEditingActivity(null);
}}
onSuccess={() => {
fetchActivities();
}}
activity={editingActivity}
/>
</div>
);
};
const MarketingMetric = ({ icon: Icon, value, label, color, bg }: any) => (
<div className={`${bg} p-3 rounded-2xl flex flex-col items-center justify-center border border-white/50`}>
<Icon className={`w-4 h-4 ${color} mb-1`} />
<span className="text-sm font-black text-slate-800 leading-none">{value}</span>
<span className="text-[8px] font-black text-slate-400 uppercase mt-1">{label}</span>
</div>
);

View File

@@ -0,0 +1,289 @@
import React, { useState, useEffect, useCallback } from 'react';
import { DevOSSSession } from '../../types';
import { Vote, Plus, FileText, CheckCircle2, AlertCircle, Calendar, UserPlus, Pencil, Filter } from 'lucide-react';
import { backendApi } from '../../services/apiClient';
import { readCache, saveCache } from '../../hooks/useCachedFetch';
import { REFRESH_EVENTS } from '../../constants/refreshEvents';
import { CreateOSSModal } from './CreateOSSModal';
import { EditOSSModal } from './EditOSSModal';
import { AddBallotModal } from './AddBallotModal';
import { BulkBallotModal } from './BulkBallotModal';
import { OSSRegistryModal } from './OSSRegistryModal';
const CACHE_KEY = 'mkd_dev_oss_cache';
type OSSStatusFilter = '' | 'planned' | 'active' | 'completed';
export const OSSMaster: React.FC = () => {
const cached = readCache<DevOSSSession[]>(CACHE_KEY, []);
const [ossSessions, setOssSessions] = useState<DevOSSSession[]>(cached);
const [loading, setLoading] = useState(cached.length === 0);
const [statusFilter, setStatusFilter] = useState<OSSStatusFilter>('');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editingSession, setEditingSession] = useState<DevOSSSession | null>(null);
const [isBallotModalOpen, setIsBallotModalOpen] = useState(false);
const [isBulkBallotModalOpen, setIsBulkBallotModalOpen] = useState(false);
const [isRegistryModalOpen, setIsRegistryModalOpen] = useState(false);
const [selectedOSS, setSelectedOSS] = useState<{id: string, address: string} | null>(null);
const fetchOSS = useCallback(async (showSpinner = true) => {
try {
if (showSpinner && cached.length === 0) setLoading(true);
const params = statusFilter ? { status: statusFilter } : undefined;
const data = await backendApi.getDevelopmentOSS(params);
const formattedData = Array.isArray(data) ? data : [];
setOssSessions(formattedData);
if (!statusFilter) saveCache(CACHE_KEY, formattedData);
} catch (error) {
console.error('Error fetching OSS sessions:', error);
setOssSessions([]);
} finally {
setLoading(false);
}
}, [statusFilter]);
useEffect(() => {
fetchOSS();
}, [fetchOSS]);
useEffect(() => {
const onRefresh = () => fetchOSS(false);
window.addEventListener(REFRESH_EVENTS.oss, onRefresh);
return () => window.removeEventListener(REFRESH_EVENTS.oss, onRefresh);
}, [fetchOSS]);
useEffect(() => {
const interval = setInterval(() => fetchOSS(false), 10 * 1000);
return () => clearInterval(interval);
}, [fetchOSS]);
const handleCreateOSS = () => {
setIsCreateModalOpen(true);
};
const handleSubmitBallot = (ossId: string, ossAddress: string) => {
setSelectedOSS({ id: ossId, address: ossAddress });
setIsBallotModalOpen(true);
};
const handleBulkBallot = (ossId: string, ossAddress: string) => {
setSelectedOSS({ id: ossId, address: ossAddress });
setIsBulkBallotModalOpen(true);
};
const handleViewRegistry = (ossId: string, ossAddress: string) => {
setSelectedOSS({ id: ossId, address: ossAddress });
setIsRegistryModalOpen(true);
};
const handleEditOSS = (oss: DevOSSSession) => {
setEditingSession(oss);
setIsEditModalOpen(true);
};
return (
<div className="space-y-6 animate-fade-in">
<div className="flex flex-wrap justify-between items-center gap-3 px-1">
<h3 className="font-black text-slate-500 text-[10px] uppercase tracking-[0.2em]">ОСС</h3>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<Filter className="w-4 h-4 text-slate-400" />
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as OSSStatusFilter)}
className="text-[10px] font-bold text-slate-600 bg-white border border-slate-200 rounded-lg py-1.5 px-2.5 focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="">Все</option>
<option value="planned">Запланировано</option>
<option value="active">Активные</option>
<option value="completed">Завершённые</option>
</select>
</div>
<button
onClick={handleCreateOSS}
className="text-[10px] font-black text-primary-600 uppercase bg-primary-50 px-3 py-1.5 rounded-lg border border-primary-100 flex items-center gap-1.5 active:scale-95 transition-all"
>
<Plus className="w-3.5 h-3.5"/> Создать собрание
</button>
</div>
</div>
<div className="space-y-4">
{loading ? (
<div className="text-center py-20 text-slate-400">
<div className="w-10 h-10 border-2 border-slate-200 rounded-full mx-auto mb-2 animate-spin" />
<p className="text-[10px] font-bold uppercase tracking-widest">Загрузка...</p>
</div>
) : ossSessions.length === 0 ? (
<div className="text-center py-20 text-slate-300">
<div className="w-10 h-10 border-2 border-dashed border-slate-200 rounded-full mx-auto mb-2" />
<p className="text-[10px] font-bold uppercase tracking-widest">{statusFilter ? 'Нет ОСС по выбранному фильтру' : 'Нет ОСС'}</p>
</div>
) : (
ossSessions.map(oss => {
const percent = Math.min(100, Math.round((oss.quorumCurrent / oss.quorumTotal) * 100));
const isPassed = percent > 50;
return (
<div key={oss.id} className="bg-white p-6 rounded-[2rem] border border-slate-200 shadow-sm relative overflow-hidden group hover:border-primary-200 transition-all">
{/* Animated Background Pulse for Active */}
{oss.status === 'active' && <div className="absolute top-0 right-0 w-32 h-32 bg-emerald-400/5 rounded-full -mr-16 -mt-16 animate-pulse" />}
<div className="flex justify-between items-start mb-6">
<div className="flex items-center gap-4">
<div className={`p-4 rounded-2xl ${oss.status === 'active' ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-100 text-slate-400'}`}>
<Vote className="w-7 h-7"/>
</div>
<div>
<div className="flex items-center gap-2 mb-0.5">
<h4 className="font-black text-slate-800 text-base">{oss.address}</h4>
<span className={`text-[9px] font-black px-2 py-0.5 rounded uppercase ${
oss.status === 'active' ? 'bg-emerald-100 text-emerald-600' :
oss.status === 'completed' ? 'bg-slate-200 text-slate-600' : 'bg-slate-100 text-slate-500'
}`}>
{oss.status === 'active' ? 'Идет голосование' : oss.status === 'completed' ? 'Завершено' : 'Запланировано'}
</span>
</div>
<p className="text-xs text-slate-500 font-medium">{oss.type === 'annual' ? 'Ежегодное отчетное собрание' : (oss.description?.trim() || 'Внеочередное ОСС')}</p>
{(oss.agendaItems?.length ?? 0) > 0 && (
<p className="text-[10px] text-slate-400 mt-0.5">Пунктов повестки: {oss.agendaItems!.length}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 text-xs font-bold text-slate-400">
<Calendar className="w-4 h-4"/> до {oss.endDate?.slice(0, 10)}
</div>
<button
onClick={() => handleEditOSS(oss)}
className="p-2 text-slate-400 hover:text-primary-600 hover:bg-primary-50 rounded-xl transition-colors"
title="Редактировать ОСС"
>
<Pencil className="w-4 h-4"/>
</button>
</div>
</div>
{/* Quorum Progress */}
<div className="mb-6">
<div className="flex justify-between items-end mb-2">
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Текущий кворум (м²)</span>
<span className={`text-sm font-black ${isPassed ? 'text-emerald-600' : 'text-slate-800'}`}>
{percent}% <span className="text-slate-400 font-bold text-[10px] ml-1">/ 50% + 1</span>
</span>
</div>
<div className="w-full h-4 bg-slate-100 rounded-full overflow-hidden relative shadow-inner">
<div className="absolute top-0 bottom-0 left-1/2 w-0.5 bg-slate-300 z-10" title="Порог кворума"></div>
<div className={`h-full rounded-full transition-all duration-1000 ${isPassed ? 'bg-emerald-500 shadow-[0_0_15px_rgba(16,185,129,0.4)]' : 'bg-amber-400 shadow-[0_0_15px_rgba(251,191,36,0.3)]'}`} style={{ width: `${percent}%` }}></div>
</div>
<div className="flex justify-between mt-2 text-[10px] text-slate-400 font-bold uppercase tracking-tight">
<span>{oss.quorumCurrent.toLocaleString()} м² собрано</span>
<span>Цель: {oss.quorumTotal.toLocaleString()} м²</span>
</div>
</div>
{/* Actions List */}
<div className="flex gap-2 pt-4 border-t border-slate-50 flex-wrap">
<button
onClick={() => handleSubmitBallot(oss.id, oss.address)}
disabled={oss.status === 'completed'}
className="py-3 px-4 bg-slate-900 text-white rounded-2xl text-[10px] font-black uppercase flex items-center justify-center gap-2 shadow-lg active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="w-4 h-4"/> Один бюллетень
</button>
<button
onClick={() => handleBulkBallot(oss.id, oss.address)}
disabled={oss.status === 'completed'}
className="flex-1 py-3 bg-primary-600 text-white rounded-2xl text-[10px] font-black uppercase flex items-center justify-center gap-2 shadow-lg shadow-primary-500/20 active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed min-w-[140px]"
>
<FileText className="w-4 h-4"/> Массовый ввод / CSV
</button>
<button
onClick={() => handleViewRegistry(oss.id, oss.address)}
className="px-4 py-3 bg-slate-100 text-slate-600 rounded-2xl text-[10px] font-black uppercase flex items-center justify-center gap-2 hover:bg-slate-200 transition-colors"
>
<FileText className="w-4 h-4"/> Реестр
</button>
<button
onClick={() => handleEditOSS(oss)}
className="p-3 bg-slate-50 text-slate-400 rounded-2xl hover:text-primary-600 transition-colors"
title="Редактировать ОСС"
>
<Pencil className="w-4 h-4"/>
</button>
</div>
</div>
);
})
)}
</div>
<div className="bg-blue-50 rounded-2xl p-4 border border-blue-100 flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-500 shrink-0 mt-0.5"/>
<p className="text-[11px] text-blue-700 leading-snug font-medium">
С 2024 года бюллетени ОСС должны быть загружены в ГИС ЖКХ в течение 5 рабочих дней после завершения голосования. Контролируйте сроки передачи оригиналов в ГЖИ.
</p>
</div>
{/* Modals */}
<CreateOSSModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={() => fetchOSS()}
/>
<EditOSSModal
isOpen={isEditModalOpen}
onClose={() => { setIsEditModalOpen(false); setEditingSession(null); }}
onSuccess={() => { fetchOSS(); setIsEditModalOpen(false); setEditingSession(null); }}
session={editingSession}
/>
{selectedOSS && (
<>
<AddBallotModal
isOpen={isBallotModalOpen}
onClose={() => {
setIsBallotModalOpen(false);
if (!isRegistryModalOpen && !isBulkBallotModalOpen) {
setSelectedOSS(null);
}
}}
onSuccess={() => {
fetchOSS();
}}
ossId={selectedOSS.id}
ossAddress={selectedOSS.address}
agendaItems={(ossSessions.find(s => s.id === selectedOSS.id) as any)?.agendaItems ?? []}
/>
<BulkBallotModal
isOpen={isBulkBallotModalOpen}
onClose={() => {
setIsBulkBallotModalOpen(false);
if (!isRegistryModalOpen && !isBallotModalOpen) {
setSelectedOSS(null);
}
}}
onSuccess={() => {
fetchOSS();
}}
ossId={selectedOSS.id}
ossAddress={selectedOSS.address}
agendaItems={(ossSessions.find(s => s.id === selectedOSS.id) as any)?.agendaItems ?? []}
/>
<OSSRegistryModal
isOpen={isRegistryModalOpen}
onClose={() => {
setIsRegistryModalOpen(false);
if (!isBallotModalOpen && !isBulkBallotModalOpen) {
setSelectedOSS(null);
}
}}
ossId={selectedOSS.id}
ossAddress={selectedOSS.address}
/>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,339 @@
import React, { useState, useEffect } from 'react';
import { X, Download, CheckCircle2, XCircle, Minus, FileText, User, Home, Calendar } from 'lucide-react';
import { backendApi } from '../../services/apiClient';
interface RegistryItem {
id: number;
apartment: string;
ownerName?: string;
area: number;
ballotSubmitted: boolean;
ballotDate?: string;
voteResult?: 'for' | 'against' | 'abstain';
votesByItem?: Record<string, string>;
notes?: string;
}
interface Props {
isOpen: boolean;
onClose: () => void;
ossId: string;
ossAddress: string;
}
export const OSSRegistryModal: React.FC<Props> = ({ isOpen, onClose, ossId, ossAddress }) => {
const [registry, setRegistry] = useState<RegistryItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen && ossId) {
fetchRegistry();
}
}, [isOpen, ossId]);
const fetchRegistry = async () => {
try {
setLoading(true);
setError(null);
const data = await backendApi.getOSSRegistry(ossId);
setRegistry(Array.isArray(data) ? data : []);
} catch (err: any) {
console.error('Error fetching registry:', err);
setError(err?.message || 'Ошибка при загрузке реестра');
setRegistry([]);
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
// Статистика
const totalEntries = registry.length;
const submittedCount = registry.filter(r => r.ballotSubmitted).length;
const totalArea = registry.reduce((sum, r) => sum + (r.area || 0), 0);
const submittedArea = registry.filter(r => r.ballotSubmitted).reduce((sum, r) => sum + (r.area || 0), 0);
const votesFor = registry.filter(r => r.voteResult === 'for').length;
const votesAgainst = registry.filter(r => r.voteResult === 'against').length;
const votesAbstain = registry.filter(r => r.voteResult === 'abstain').length;
const getVoteIcon = (voteResult?: string) => {
switch (voteResult) {
case 'for':
return <CheckCircle2 className="w-4 h-4 text-emerald-600" />;
case 'against':
return <XCircle className="w-4 h-4 text-red-600" />;
case 'abstain':
return <Minus className="w-4 h-4 text-amber-600" />;
default:
return <div className="w-4 h-4 rounded-full border-2 border-slate-300" />;
}
};
const getVoteLabel = (voteResult?: string) => {
switch (voteResult) {
case 'for':
return 'За';
case 'against':
return 'Против';
case 'abstain':
return 'Воздержался';
default:
return '—';
}
};
const formatVotesByItem = (votesByItem?: Record<string, string>) => {
if (!votesByItem || Object.keys(votesByItem).length === 0) return '—';
const parts = Object.entries(votesByItem)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([idx, v]) => `${Number(idx) + 1}: ${getVoteLabel(v)}`);
return parts.join('; ');
};
const handleExport = () => {
// Экспорт в CSV
const headers = ['Помещение', 'Собственник', 'Площадь (м²)', 'Бюллетень подан', 'Дата подачи', 'Результат голосования', 'Примечания'];
const rows = registry.map(r => [
r.apartment,
r.ownerName || '—',
r.area.toFixed(2),
r.ballotSubmitted ? 'Да' : 'Нет',
r.ballotDate || '—',
getVoteLabel(r.voteResult),
r.notes || '—'
]);
const csvContent = [
headers.join(','),
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
].join('\n');
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `реестр_ОСС_${ossAddress.replace(/[^a-zA-Zа-яА-Я0-9]/g, '_')}_${new Date().toISOString().split('T')[0]}.csv`;
link.click();
};
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-6xl shadow-2xl animate-slide-up max-h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex justify-between items-start rounded-t-2xl">
<div className="flex-1">
<h3 className="text-xl font-black text-slate-800 mb-1">Реестр участников ОСС</h3>
<p className="text-sm text-slate-500 font-medium">{ossAddress}</p>
</div>
<div className="flex gap-2">
<button
onClick={handleExport}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-xl text-sm font-bold hover:bg-slate-200 transition-colors flex items-center gap-2"
title="Экспорт в CSV"
>
<Download className="w-4 h-4" /> Экспорт
</button>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-xl transition-colors"
>
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
</div>
{/* Statistics */}
<div className="px-6 py-4 bg-slate-50 border-b border-slate-200">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white p-3 rounded-xl border border-slate-200">
<p className="text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Всего записей</p>
<p className="text-xl font-black text-slate-800">{totalEntries}</p>
</div>
<div className="bg-white p-3 rounded-xl border border-slate-200">
<p className="text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Бюллетеней подано</p>
<p className="text-xl font-black text-primary-600">{submittedCount} / {totalEntries}</p>
</div>
<div className="bg-white p-3 rounded-xl border border-slate-200">
<p className="text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Площадь собрана</p>
<p className="text-xl font-black text-emerald-600">{submittedArea.toFixed(2)} м²</p>
</div>
<div className="bg-white p-3 rounded-xl border border-slate-200">
<p className="text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Общая площадь</p>
<p className="text-xl font-black text-slate-800">{totalArea.toFixed(2)} м²</p>
</div>
</div>
{/* Vote Statistics */}
{(votesFor > 0 || votesAgainst > 0 || votesAbstain > 0) && (
<div className="mt-4 flex gap-4 flex-wrap">
<div className="flex items-center gap-2 text-sm">
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
<span className="font-bold text-slate-700">За:</span>
<span className="font-black text-emerald-600">{votesFor}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<XCircle className="w-4 h-4 text-red-600" />
<span className="font-bold text-slate-700">Против:</span>
<span className="font-black text-red-600">{votesAgainst}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Minus className="w-4 h-4 text-amber-600" />
<span className="font-bold text-slate-700">Воздержались:</span>
<span className="font-black text-amber-600">{votesAbstain}</span>
</div>
</div>
)}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{loading ? (
<div className="text-center py-20 text-slate-400">
<div className="w-10 h-10 border-2 border-slate-200 rounded-full mx-auto mb-2 animate-spin" />
<p className="text-[10px] font-bold uppercase tracking-widest">Загрузка реестра...</p>
</div>
) : error ? (
<div className="text-center py-20 text-red-400">
<XCircle className="w-12 h-12 mx-auto mb-4" />
<p className="text-sm font-bold">{error}</p>
<button
onClick={fetchRegistry}
className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-xl text-sm font-bold hover:bg-primary-700 transition-colors"
>
Попробовать снова
</button>
</div>
) : registry.length === 0 ? (
<div className="text-center py-20 text-slate-300">
<FileText className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p className="text-sm font-bold uppercase tracking-widest">Реестр пуст</p>
<p className="text-xs text-slate-400 mt-2">Бюллетени еще не были внесены</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b-2 border-slate-200">
<th className="text-left py-3 px-4 text-[10px] font-black text-slate-500 uppercase tracking-wider">
<div className="flex items-center gap-2">
<Home className="w-4 h-4" /> Помещение
</div>
</th>
<th className="text-left py-3 px-4 text-[10px] font-black text-slate-500 uppercase tracking-wider">
<div className="flex items-center gap-2">
<User className="w-4 h-4" /> Собственник
</div>
</th>
<th className="text-right py-3 px-4 text-[10px] font-black text-slate-500 uppercase tracking-wider">
Площадь (м²)
</th>
<th className="text-center py-3 px-4 text-[10px] font-black text-slate-500 uppercase tracking-wider">
Статус
</th>
<th className="text-left py-3 px-4 text-[10px] font-black text-slate-500 uppercase tracking-wider">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4" /> Дата подачи
</div>
</th>
<th className="text-center py-3 px-4 text-[10px] font-black text-slate-500 uppercase tracking-wider">
Голос
</th>
<th className="text-left py-3 px-4 text-[10px] font-black text-slate-500 uppercase tracking-wider">
По пунктам
</th>
<th className="text-left py-3 px-4 text-[10px] font-black text-slate-500 uppercase tracking-wider">
Примечания
</th>
</tr>
</thead>
<tbody>
{registry.map((item, index) => (
<tr
key={item.id}
className={`border-b border-slate-100 hover:bg-slate-50 transition-colors ${
item.ballotSubmitted ? 'bg-emerald-50/30' : ''
}`}
>
<td className="py-3 px-4">
<span className="font-black text-slate-800">{item.apartment}</span>
</td>
<td className="py-3 px-4">
<span className="text-sm text-slate-700">
{item.ownerName || <span className="text-slate-400 italic">Не указано</span>}
</span>
</td>
<td className="py-3 px-4 text-right">
<span className="font-bold text-slate-800">{item.area.toFixed(2)}</span>
</td>
<td className="py-3 px-4 text-center">
{item.ballotSubmitted ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-emerald-100 text-emerald-700 text-[10px] font-black uppercase">
<CheckCircle2 className="w-3.5 h-3.5" /> Подано
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-slate-100 text-slate-500 text-[10px] font-black uppercase">
Не подано
</span>
)}
</td>
<td className="py-3 px-4">
<span className="text-sm text-slate-600">
{item.ballotDate ? new Date(item.ballotDate).toLocaleDateString('ru-RU') : '—'}
</span>
</td>
<td className="py-3 px-4 text-center">
<div className="flex items-center justify-center gap-1.5">
{getVoteIcon(item.voteResult)}
<span className={`text-xs font-bold ${
item.voteResult === 'for' ? 'text-emerald-600' :
item.voteResult === 'against' ? 'text-red-600' :
item.voteResult === 'abstain' ? 'text-amber-600' :
'text-slate-400'
}`}>
{getVoteLabel(item.voteResult)}
</span>
</div>
</td>
<td className="py-3 px-4">
<span className="text-xs text-slate-600 max-w-xs block" title={formatVotesByItem(item.votesByItem)}>
{formatVotesByItem(item.votesByItem)}
</span>
</td>
<td className="py-3 px-4">
<span className="text-xs text-slate-500 max-w-xs truncate block" title={item.notes || ''}>
{item.notes || '—'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Footer */}
<div className="sticky bottom-0 bg-slate-50 border-t border-slate-200 px-6 py-4 rounded-b-2xl flex justify-between items-center">
<div className="text-xs text-slate-500">
<span className="font-bold">Всего записей:</span> {totalEntries} |
<span className="font-bold ml-2">Подано:</span> {submittedCount} |
<span className="font-bold ml-2">Площадь:</span> {submittedArea.toFixed(2)} м² из {totalArea.toFixed(2)} м²
</div>
<button
onClick={onClose}
className="px-6 py-2.5 bg-primary-600 text-white rounded-xl text-sm font-bold hover:bg-primary-700 transition-colors"
>
Закрыть
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,275 @@
import React, { useState, useEffect, useCallback } from 'react';
import { DevPipelineStatus, DevPipelineItem } from '../../types';
import { Building2, Search, Filter, Plus, GripVertical } from 'lucide-react';
import { backendApi } from '../../services/apiClient';
import { readCache, saveCache } from '../../hooks/useCachedFetch';
import { REFRESH_EVENTS } from '../../constants/refreshEvents';
import { AddPipelineObjectModal } from './AddPipelineObjectModal';
import { EditPipelineObjectModal } from './EditPipelineObjectModal';
const STAGES: { id: DevPipelineStatus; label: string; color: string }[] = [
{ id: 'incoming', label: 'Входящие', color: 'bg-slate-500' },
{ id: 'analysis', label: 'Анализ', color: 'bg-sky-500' },
{ id: 'agenda_approval', label: 'Согласование повестки', color: 'bg-blue-500' },
{ id: 'in_person', label: 'Очная часть', color: 'bg-indigo-500' },
{ id: 'absentee', label: 'Заочная часть', color: 'bg-violet-500' },
{ id: 'protocol_formation', label: 'Формирование протокола', color: 'bg-amber-500' },
{ id: 'protocol_to_gzhi', label: 'Отправка протокола в ГЖИ', color: 'bg-orange-500' },
{ id: 'gzhi_order', label: 'Приказ ГЖИ', color: 'bg-rose-500' },
{ id: 'success', label: 'Успех', color: 'bg-emerald-500' },
{ id: 'failure', label: 'Провал', color: 'bg-red-500' },
];
const DRAG_TYPE = 'application/x-pipeline-item';
const CACHE_KEY = 'mkd_dev_pipeline_cache';
/** Событие: перейти на воронку с поиском по адресу (из аудита) */
const PIPELINE_SEARCH_EVENT = 'mkd-pipeline-search-request';
export const PipelineRegistry: React.FC = () => {
const cached = readCache<DevPipelineItem[]>(CACHE_KEY, []);
const [pipeline, setPipeline] = useState<DevPipelineItem[]>(cached);
const [loading, setLoading] = useState(cached.length === 0);
const [search, setSearch] = useState('');
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState<DevPipelineItem | null>(null);
const [draggingId, setDraggingId] = useState<string | null>(null);
const [dragOverStageId, setDragOverStageId] = useState<DevPipelineStatus | null>(null);
const [updatingStatusId, setUpdatingStatusId] = useState<string | null>(null);
const fetchPipeline = useCallback(async (showSpinner = true) => {
try {
if (showSpinner && cached.length === 0 && !search) setLoading(true);
const data = await backendApi.getDevelopmentPipeline({ search: search || undefined });
setPipeline(data);
if (!search) saveCache(CACHE_KEY, data);
} catch (error) {
console.error('Error fetching pipeline:', error);
setPipeline([]);
} finally {
setLoading(false);
}
}, [search]);
useEffect(() => {
const delay = search ? 300 : 0;
const t = setTimeout(() => fetchPipeline(), delay);
return () => clearTimeout(t);
}, [search, fetchPipeline]);
useEffect(() => {
const onRefresh = () => fetchPipeline(false);
window.addEventListener(REFRESH_EVENTS.pipeline, onRefresh);
return () => window.removeEventListener(REFRESH_EVENTS.pipeline, onRefresh);
}, [fetchPipeline]);
useEffect(() => {
const onSearchRequest = (e: Event) => {
const customEvent = e as CustomEvent<{ search?: string }>;
const q = customEvent.detail?.search;
if (typeof q === 'string' && q.trim()) setSearch(q.trim());
};
window.addEventListener(PIPELINE_SEARCH_EVENT, onSearchRequest);
return () => window.removeEventListener(PIPELINE_SEARCH_EVENT, onSearchRequest);
}, []);
useEffect(() => {
const interval = setInterval(() => fetchPipeline(false), 10 * 1000);
return () => clearInterval(interval);
}, [fetchPipeline]);
const updateItemStatus = useCallback(async (itemId: string, newStatus: DevPipelineStatus) => {
try {
setUpdatingStatusId(itemId);
await backendApi.updateDevelopmentPipeline(itemId, { status: newStatus });
window.dispatchEvent(new CustomEvent('mkd-pipeline-changed'));
await fetchPipeline();
} catch (err) {
console.error('Error updating status:', err);
} finally {
setUpdatingStatusId(null);
}
}, [fetchPipeline]);
const handleDragStart = (e: React.DragEvent, item: DevPipelineItem) => {
setDraggingId(item.id);
e.dataTransfer.setData(DRAG_TYPE, JSON.stringify({ id: item.id, status: item.status }));
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setDragImage(e.currentTarget, 0, 0);
};
const handleDragEnd = () => {
setDraggingId(null);
setDragOverStageId(null);
};
const handleDragOver = (e: React.DragEvent, stageId: DevPipelineStatus) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverStageId(stageId);
};
const handleDragLeave = () => {
setDragOverStageId(null);
};
const handleDrop = (e: React.DragEvent, targetStageId: DevPipelineStatus) => {
e.preventDefault();
setDragOverStageId(null);
setDraggingId(null);
const raw = e.dataTransfer.getData(DRAG_TYPE);
if (!raw) return;
try {
const { id, status } = JSON.parse(raw) as { id: string; status: DevPipelineStatus };
if (status === targetStageId) return;
updateItemStatus(id, targetStageId);
} catch {
// ignore
}
};
const handleCardClick = (e: React.MouseEvent, item: DevPipelineItem) => {
if ((e.target as HTMLElement).closest('select') || (e.target as HTMLElement).closest('[data-drag-handle]')) return;
setEditingItem(item);
};
return (
<div className="space-y-6 animate-fade-in">
{/* Toolbar */}
<div className="flex gap-4 items-center">
<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-10 pr-4 py-2.5 rounded-xl border border-slate-200 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 hover:bg-slate-50 shadow-sm"><Filter className="w-5 h-5"/></button>
<button
onClick={() => setIsAddModalOpen(true)}
className="bg-primary-600 text-white p-2.5 rounded-xl shadow-lg shadow-primary-500/30 flex items-center gap-2 px-4 text-xs font-black uppercase tracking-wider active:scale-95 transition-all"
>
<Plus className="w-4 h-4" /> Добавить объект
</button>
</div>
{/* Kanban Horizontal View */}
<div className="overflow-x-auto pb-6 no-scrollbar">
<div className="flex gap-4 min-w-[1000px]">
{STAGES.map(stage => {
const items = pipeline.filter(p => p.status === stage.id);
return (
<div key={stage.id} className="w-72 flex-shrink-0 flex flex-col gap-4">
<div className="flex justify-between items-center px-1">
<h3 className="text-[10px] font-black text-slate-500 uppercase tracking-widest">{stage.label}</h3>
<span className="bg-slate-200 text-slate-600 text-[10px] font-black px-2 py-0.5 rounded-full">{items.length}</span>
</div>
<div
className={`bg-slate-100/50 p-2 rounded-2xl min-h-[400px] border-2 border-dashed transition-colors space-y-3 ${dragOverStageId === stage.id ? 'border-primary-400 bg-primary-50/30' : 'border-slate-200/50'}`}
onDragOver={(e) => handleDragOver(e, stage.id)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, stage.id)}
>
{items.map(item => (
<div
key={item.id}
role="button"
tabIndex={0}
onClick={(e) => handleCardClick(e, item)}
onKeyDown={(e) => e.key === 'Enter' && setEditingItem(item)}
className={`bg-white p-4 rounded-xl border shadow-sm hover:shadow-md hover:border-primary-300 transition-all group cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-500 ${draggingId === item.id ? 'opacity-50 ring-2 ring-primary-400' : 'border-slate-200'}`}
draggable
onDragStart={(e) => handleDragStart(e, item)}
onDragEnd={handleDragEnd}
>
<div className="flex items-start gap-1 mb-2">
<span
data-drag-handle
className="mt-0.5 p-0.5 rounded cursor-grab active:cursor-grabbing text-slate-300 hover:text-slate-500 touch-none"
title="Перетащите в другой этап"
>
<GripVertical className="w-4 h-4" />
</span>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-start">
<span className={`text-[9px] font-black px-1.5 py-0.5 rounded uppercase tracking-tighter ${item.type === 'new' ? 'bg-blue-50 text-blue-600' : 'bg-orange-50 text-orange-600'}`}>
{item.type === 'new' ? 'Новостройка' : 'Вторичка'}
</span>
<span className="text-[10px] font-black text-emerald-600">{item.probability}%</span>
</div>
<h4 className="font-bold text-slate-800 text-sm mb-1 leading-tight group-hover:text-primary-600 transition-colors">{item.address}</h4>
</div>
</div>
<div className="mb-3">
<select
value={item.status}
disabled={!!updatingStatusId}
onChange={(e) => {
e.stopPropagation();
const v = e.target.value as DevPipelineStatus;
if (v !== item.status) updateItemStatus(item.id, v);
}}
onClick={(e) => e.stopPropagation()}
className="w-full text-[10px] font-medium rounded-lg border border-slate-200 bg-slate-50 py-1.5 px-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none cursor-pointer disabled:opacity-60"
>
{STAGES.map(s => (
<option key={s.id} value={s.id}>{s.label}</option>
))}
</select>
</div>
<p className="text-[10px] text-slate-400 font-medium mb-3 flex items-center gap-1">
<Building2 className="w-3 h-3"/> {item.apartments} кв {item.area} м²
</p>
<div className="pt-3 border-t border-slate-50 flex justify-between items-center">
<div className="flex items-center gap-1.5">
<div className="w-6 h-6 rounded-full bg-slate-100 flex items-center justify-center text-[10px] font-black text-slate-500">{item.manager?.[0] ?? '?'}</div>
<span className="text-[10px] font-bold text-slate-500 truncate max-w-[80px]">{item.manager}</span>
</div>
<div className="text-right">
<p className="text-[10px] font-black text-slate-800">{((item.expectedRevenue ?? 0) / 1000).toFixed(0)}k </p>
<p className="text-[8px] text-slate-400 font-bold uppercase">Маржа</p>
</div>
</div>
</div>
))}
{items.length === 0 && !loading && (
<div className="py-20 text-center text-slate-300">
<div className="w-10 h-10 border-2 border-dashed border-slate-200 rounded-full mx-auto mb-2" />
<p className="text-[10px] font-bold uppercase tracking-widest">Пусто</p>
</div>
)}
{loading && items.length === 0 && (
<div className="py-20 text-center text-slate-300">
<div className="w-10 h-10 border-2 border-slate-200 rounded-full mx-auto mb-2 animate-spin" />
<p className="text-[10px] font-bold uppercase tracking-widest">Загрузка...</p>
</div>
)}
</div>
</div>
)
})}
</div>
</div>
<AddPipelineObjectModal
isOpen={isAddModalOpen}
onClose={() => setIsAddModalOpen(false)}
onSuccess={() => { fetchPipeline(); }}
/>
<EditPipelineObjectModal
item={editingItem}
onClose={() => setEditingItem(null)}
onSuccess={() => {
setEditingItem(null);
fetchPipeline();
}}
/>
</div>
);
};

View File

@@ -0,0 +1,231 @@
import React, { useState, useEffect } from 'react';
import { DevAuditData } from '../../types';
import { Download, BarChart3, AlertTriangle, CheckCircle2, TrendingUp, Info, ChevronRight, Briefcase } from 'lucide-react';
import { backendApi } from '../../services/apiClient';
import { AuditCardModal } from './AuditCardModal';
import { AUDIT_INSPECTION_SCHEMA, AUDIT_STATUS_LABELS, calcCategoryAverage, formatCategoryOverall } from './auditInspectionSchema';
import type { InspectionData } from '../../types';
const PIPELINE_SEARCH_EVENT = 'mkd-pipeline-search-request';
interface TechnicalAuditProps {
onNavigate?: (tab: string) => void;
}
export const TechnicalAudit: React.FC<TechnicalAuditProps> = ({ onNavigate }) => {
const [audits, setAudits] = useState<DevAuditData[]>([]);
const [loading, setLoading] = useState(true);
const [selectedAuditId, setSelectedAuditId] = useState<string | null>(null);
const fetchAudits = async () => {
try {
setLoading(true);
const data = await backendApi.getDevelopmentAudits();
setAudits(data);
} catch (error) {
console.error('Error fetching audits:', error);
setAudits([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchAudits();
}, []);
const handleDownloadDefectList = async (e: React.MouseEvent, auditId: string) => {
e.stopPropagation();
try {
const result = await backendApi.getAuditDefectList(auditId);
if (result.url) {
window.open(result.url, '_blank');
} else {
alert('Дефектная ведомость будет сгенерирована на основе данных аудита');
}
} catch (error) {
console.error('Error downloading defect list:', error);
alert('Ошибка при загрузке дефектной ведомости');
}
};
const getStatusBadgeClass = (status: string) => {
if (status === 'completed') return 'bg-emerald-50 text-emerald-600';
if (status === 'in_progress') return 'bg-amber-50 text-amber-600';
return 'bg-slate-100 text-slate-600';
};
/** Среднее по пункту осмотра (15) из inspectionData; null — нет данных */
const getCategoryAverage = (audit: DevAuditData, categoryKey: string): number | null => {
const data = (audit.inspectionData ?? {}) as InspectionData;
const cat = data[categoryKey as keyof InspectionData];
const subItems = cat?.subItems ?? [];
return calcCategoryAverage(subItems);
};
/** Для списка: статус по среднему 15 или null → "—" */
const getCategoryDisplay = (audit: DevAuditData, categoryKey: string): { status: 'good' | 'fair' | 'bad' | null; label: string } => {
const avg = getCategoryAverage(audit, categoryKey);
if (avg == null) return { status: null, label: '—' };
if (avg >= 4) return { status: 'good', label: formatCategoryOverall(avg).split(' — ')[1] ?? 'Отлично' };
if (avg >= 2.5) return { status: 'fair', label: formatCategoryOverall(avg).split(' — ')[1] ?? 'Удовл.' };
return { status: 'bad', label: formatCategoryOverall(avg).split(' — ')[1] ?? 'Плохо' };
};
/** Есть ли в аудите хотя бы одна оценка по осмотру (не пустой/новый аудит) */
const hasInspectionRatings = (audit: DevAuditData): boolean => {
const data = (audit.inspectionData ?? {}) as InspectionData;
return AUDIT_INSPECTION_SCHEMA.some(cat => getCategoryAverage(audit, cat.key) != null);
};
return (
<div className="space-y-6 animate-fade-in">
{loading ? (
<div className="text-center py-20 text-slate-400">
<div className="w-10 h-10 border-2 border-slate-200 rounded-full mx-auto mb-2 animate-spin" />
<p className="text-[10px] font-bold uppercase tracking-widest">Загрузка...</p>
</div>
) : audits.length === 0 ? (
<div className="text-center py-20 text-slate-300">
<div className="w-10 h-10 border-2 border-dashed border-slate-200 rounded-full mx-auto mb-2" />
<p className="text-[10px] font-bold uppercase tracking-widest mb-2">Нет данных об аудитах</p>
<p className="text-xs text-slate-400 max-w-sm mx-auto mb-4">Аудит создаётся автоматически при переходе объекта воронки на этап «Анализ».</p>
{onNavigate && (
<button
type="button"
onClick={() => { onNavigate('pipeline'); }}
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-50 text-primary-600 rounded-xl text-xs font-bold hover:bg-primary-100 transition-colors"
>
<Briefcase className="w-4 h-4"/> Перейти в воронку
</button>
)}
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{audits.map(audit => (
<div
key={audit.id}
role="button"
tabIndex={0}
onClick={() => setSelectedAuditId(audit.id)}
onKeyDown={(e) => e.key === 'Enter' && setSelectedAuditId(audit.id)}
className="bg-white rounded-[2.5rem] border border-slate-200 shadow-sm overflow-hidden flex flex-col group hover:border-primary-400 hover:shadow-md transition-all cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<div className="p-6 pb-0">
<div className="flex justify-between items-start mb-4">
<h4 className="font-black text-slate-800 text-lg leading-tight">{audit.address}</h4>
<span className={`text-[10px] font-black px-2 py-1 rounded uppercase tracking-tighter ${getStatusBadgeClass(audit.status || 'new')}`}>
{AUDIT_STATUS_LABELS[audit.status] || 'Новый'}
</span>
</div>
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="bg-slate-50 p-4 rounded-3xl border border-slate-100">
<p className="text-[9px] font-black text-slate-400 uppercase mb-1">Износ дома</p>
<div className="flex items-center gap-2">
<p className={`text-2xl font-black ${(audit.wearPercent ?? 0) > 50 ? 'text-red-600' : 'text-emerald-600'}`}>
{hasInspectionRatings(audit) && audit.wearPercent != null ? `${audit.wearPercent}%` : '—'}
</p>
{hasInspectionRatings(audit) && (audit.wearPercent ?? 0) > 50 ? <AlertTriangle className="w-4 h-4 text-red-500 animate-pulse"/> : <CheckCircle2 className="w-4 h-4 text-emerald-500"/>}
</div>
</div>
<div className="bg-slate-50 p-4 rounded-3xl border border-slate-100">
<p className="text-[9px] font-black text-slate-400 uppercase mb-1">Расчетная маржа</p>
<div className="flex items-center gap-2">
<p className="text-2xl font-black text-primary-600">
{audit.projectedMargin != null ? `${audit.projectedMargin}%` : '—'}
</p>
<TrendingUp className="w-4 h-4 text-primary-400" />
</div>
</div>
</div>
</div>
<div className="px-6 space-y-2 mb-6">
{AUDIT_INSPECTION_SCHEMA.map(cat => {
const { status, label } = getCategoryDisplay(audit, cat.key);
return (
<React.Fragment key={cat.key}>
<AuditRow label={cat.label} status={status} displayLabel={label} />
</React.Fragment>
);
})}
</div>
<div className="mt-auto bg-slate-900 p-6 text-white">
<div className="flex justify-between items-center mb-4">
<div>
<p className="text-[9px] font-black text-slate-500 uppercase tracking-widest">Прогнозный тариф</p>
<p className="text-xl font-black">{hasInspectionRatings(audit) && audit.calculatedTariff != null ? audit.calculatedTariff : '—'} <span className="text-xs font-normal text-slate-400">/м²</span></p>
</div>
<div className="flex items-center gap-2 flex-wrap">
{onNavigate && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onNavigate('pipeline');
window.dispatchEvent(new CustomEvent(PIPELINE_SEARCH_EVENT, { detail: { search: audit.address || '' } }));
}}
className="bg-white/10 text-white px-3 py-1.5 rounded-xl text-[10px] font-black uppercase flex items-center gap-1.5 hover:bg-white/20 transition-colors"
>
<Briefcase className="w-3.5 h-3.5"/> Воронка
</button>
)}
<button
type="button"
onClick={(e) => handleDownloadDefectList(e, audit.id)}
className="bg-white text-slate-900 px-4 py-2 rounded-xl text-[10px] font-black uppercase flex items-center gap-2 hover:bg-primary-50 transition-colors"
>
<Download className="w-4 h-4"/> Дефектная ведомость
</button>
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-white" />
</div>
</div>
<div className="flex items-center gap-2 text-[9px] text-slate-400 font-medium">
<BarChart3 className="w-3.5 h-3.5 text-primary-400"/> Точка безубыточности: 28.5 /м²
</div>
</div>
</div>
))}
</div>
</>
)}
<AuditCardModal
auditId={selectedAuditId}
onClose={() => setSelectedAuditId(null)}
onSaved={() => { setSelectedAuditId(null); fetchAudits(); }}
/>
{audits.length > 0 && (
<div className="bg-blue-50 rounded-2xl p-4 border border-blue-100 flex items-start gap-3">
<Info className="w-5 h-5 text-blue-500 shrink-0 mt-0.5"/>
<div>
<p className="text-[11px] text-blue-700 leading-snug font-bold mb-1">Автоматическое создание аудитов</p>
<p className="text-[10px] text-blue-600 leading-snug">
Технический аудит создаётся автоматически при попадании объекта воронки на этап «Анализ».
Данные аудита используются для расчёта вероятности успеха и прогнозного тарифа.
</p>
</div>
</div>
)}
</div>
);
};
const AuditRow = ({ label, status, displayLabel }: { label: string; status: 'good' | 'fair' | 'bad' | null; displayLabel?: string }) => (
<div className="flex justify-between items-center p-3 bg-slate-50 rounded-2xl border border-slate-100 text-xs">
<span className="font-bold text-slate-600">{label}</span>
<span className={`text-[9px] font-black px-2 py-0.5 rounded uppercase tracking-tighter ${
status == null ? 'bg-slate-100 text-slate-400' :
status === 'bad' ? 'bg-red-50 text-red-600' :
status === 'good' ? 'bg-emerald-50 text-emerald-600' : 'bg-amber-50 text-amber-600'
}`}>
{status == null ? (displayLabel ?? '—') : (displayLabel ?? (status === 'bad' ? 'Плохое' : status === 'good' ? 'Хорошее' : 'Удовл.'))}
</span>
</div>
);

View File

@@ -0,0 +1,148 @@
/**
* Справочник пунктов и подпунктов осмотра аудита.
* Пункты: Кровля, Фасад, Подъезды, Инфраструктура, Придомовой участок.
*/
import type { InspectionCategoryKey } from '../../types';
export interface InspectionSubItemSchema {
key: string;
label: string;
}
export interface InspectionCategorySchema {
key: InspectionCategoryKey;
label: string;
subItems: InspectionSubItemSchema[];
}
export const AUDIT_INSPECTION_SCHEMA: InspectionCategorySchema[] = [
{
key: 'roof',
label: 'Кровля',
subItems: [
{ key: 'covering', label: 'Покрытие кровли' },
{ key: 'waterproofing', label: 'Гидроизоляция' },
{ key: 'drainage', label: 'Водосточная система' },
{ key: 'fencing', label: 'Ограждение кровли' },
{ key: 'hatches', label: 'Люки и выходы' },
],
},
{
key: 'facade',
label: 'Фасад',
subItems: [
{ key: 'walls', label: 'Состояние стен' },
{ key: 'joints', label: 'Межпанельные швы' },
{ key: 'windows', label: 'Оконные блоки' },
{ key: 'balconies', label: 'Балконы и лоджии' },
{ key: 'basement_floor', label: 'Цокольный этаж' },
],
},
{
key: 'entrances',
label: 'Подъезды',
subItems: [
{ key: 'doors', label: 'Входные двери' },
{ key: 'stairs', label: 'Лестничные марши' },
{ key: 'railings', label: 'Перила и ограждения' },
{ key: 'lighting', label: 'Освещение' },
{ key: 'walls_ceilings', label: 'Стены и потолки' },
{ key: 'elevator', label: 'Лифт (при наличии)' },
],
},
{
key: 'infrastructure',
label: 'Инфраструктура',
subItems: [
{ key: 'heating', label: 'Система отопления' },
{ key: 'water', label: 'ХВС/ГВС' },
{ key: 'sewage', label: 'Канализация' },
{ key: 'electrical', label: 'Электрощитовые' },
{ key: 'basement', label: 'Подвал / ИТП' },
{ key: 'gas', label: 'Газовое оборудование (при наличии)' },
],
},
{
key: 'yard',
label: 'Придомовой участок',
subItems: [
{ key: 'paving', label: 'Благоустройство территории' },
{ key: 'lighting', label: 'Наружное освещение' },
{ key: 'parking', label: 'Парковка' },
{ key: 'playground', label: 'Детская площадка (при наличии)' },
{ key: 'waste', label: 'Площадка для мусора' },
],
},
];
/** Оценки 15 для подпунктов */
export const INSPECTION_SCORE_LABELS: Record<number, string> = {
1: '1 — очень плохо',
2: '2 — плохо',
3: '3 — удовлетворительно',
4: '4 — хорошо',
5: '5 — отлично',
};
export const AUDIT_STATUS_LABELS: Record<string, string> = {
new: 'Новый',
in_progress: 'В работе',
completed: 'Завершён',
};
/** Нормализация оценки для отображения: число 15 или старые good/fair/poor → 5/3/1. */
export function normalizeRatingToScore(rating: unknown): number | null {
if (rating != null && typeof rating === 'number' && rating >= 1 && rating <= 5) return rating;
if (rating === 'good') return 5;
if (rating === 'fair') return 3;
if (rating === 'poor') return 1;
return null;
}
/** Среднее по подпунктам категории: только оценки 15, без noAccess/notPresent. Округление до 1 знака. */
export function calcCategoryAverage(subItems: { rating: number | null; noAccess?: boolean; notPresent?: boolean }[]): number | null {
const valid = subItems.filter(s => {
const score = normalizeRatingToScore(s.rating);
return score != null && !s.noAccess && !s.notPresent;
});
if (valid.length === 0) return null;
const sum = valid.reduce((a, s) => a + (normalizeRatingToScore(s.rating) ?? 0), 0);
return Math.round((sum / valid.length) * 10) / 10;
}
/** Износ % из средних по пунктам: (5 среднее) / 4 × 100. categoryAverages — числа 15 или null. */
export function calcWearPercentFromCategoryAverages(categoryAverages: (number | null)[]): number | null {
const valid = categoryAverages.filter((a): a is number => a != null && a >= 1 && a <= 5);
if (valid.length === 0) return null;
const avg = valid.reduce((s, a) => s + a, 0) / valid.length;
return Math.round(((5 - avg) / 4) * 100);
}
/** Индекс сложности (0100) из средних по пунктам осмотра — та же формула, что и износ: (5 среднее) / 4 × 100. */
export function calcComplexityIndexFromCategoryAverages(categoryAverages: (number | null)[]): number | null {
return calcWearPercentFromCategoryAverages(categoryAverages);
}
/** Прогнозный тариф по износу %, индексу сложности и марже %. Формула как на бэкенде. */
export function calculateTariffFromAudit(params: { wearPercent: number; complexityIndex: number; projectedMargin: number }): number {
const BASE_TARIFF = 28;
const wearPercent = Math.max(0, Math.min(100, Number(params.wearPercent) || 0));
const complexityIndex = Math.max(0, Math.min(100, Number(params.complexityIndex) ?? 50));
const marginPercent = Math.max(0, Math.min(50, Number(params.projectedMargin) || 15));
const wearFactor = 1 + (wearPercent / 100) * 0.5;
const complexityFactor = 1 + (complexityIndex / 100) * 0.2;
const marginFactor = 1 / (1 - marginPercent / 100);
const tariff = BASE_TARIFF * wearFactor * complexityFactor * marginFactor;
return Math.round(tariff * 100) / 100;
}
/** Текст итога по среднему 15 (для отображения) */
export function formatCategoryOverall(avg: number | null): string {
if (avg == null) return '—';
if (avg >= 4.5) return `${avg} — отлично`;
if (avg >= 3.5) return `${avg} — хорошо`;
if (avg >= 2.5) return `${avg} — удовлетворительно`;
if (avg >= 1.5) return `${avg} — плохо`;
return `${avg} — очень плохо`;
}