Initial commit MKD fixes
This commit is contained in:
212
components/development/AddBallotModal.tsx
Executable file
212
components/development/AddBallotModal.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
307
components/development/AddPipelineObjectModal.tsx
Executable file
307
components/development/AddPipelineObjectModal.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
293
components/development/AuditCardModal.tsx
Executable file
293
components/development/AuditCardModal.tsx
Executable 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;
|
||||
};
|
||||
|
||||
/** Итог по пункту = среднее арифметическое подпунктов (только 1–5, без 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>
|
||||
|
||||
{/* Износ дома — только из данных пунктов осмотра (средние 1–5 → %), без ручного ввода */}
|
||||
<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">Из средних по пунктам осмотра (1–5)</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">Индекс сложности дома (0–100)</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">Из средних по пунктам осмотра (1–5)</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="Среднее арифметическое по подпунктам (только оценки 1–5)">
|
||||
Среднее: {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="">Оценка 1–5</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>
|
||||
);
|
||||
};
|
||||
289
components/development/BulkBallotModal.tsx
Executable file
289
components/development/BulkBallotModal.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
442
components/development/CreateOSSModal.tsx
Executable file
442
components/development/CreateOSSModal.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
232
components/development/DevSummary.tsx
Executable file
232
components/development/DevSummary.tsx
Executable 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>
|
||||
);
|
||||
|
||||
227
components/development/EditOSSModal.tsx
Executable file
227
components/development/EditOSSModal.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
288
components/development/EditPipelineObjectModal.tsx
Executable file
288
components/development/EditPipelineObjectModal.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
376
components/development/MarketingActivityModal.tsx
Executable file
376
components/development/MarketingActivityModal.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
225
components/development/MarketingCampaigns.tsx
Executable file
225
components/development/MarketingCampaigns.tsx
Executable 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>
|
||||
);
|
||||
289
components/development/OSSMaster.tsx
Executable file
289
components/development/OSSMaster.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
339
components/development/OSSRegistryModal.tsx
Executable file
339
components/development/OSSRegistryModal.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
275
components/development/PipelineRegistry.tsx
Executable file
275
components/development/PipelineRegistry.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
231
components/development/TechnicalAudit.tsx
Executable file
231
components/development/TechnicalAudit.tsx
Executable 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';
|
||||
};
|
||||
|
||||
/** Среднее по пункту осмотра (1–5) из 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);
|
||||
};
|
||||
|
||||
/** Для списка: статус по среднему 1–5 или 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>
|
||||
);
|
||||
|
||||
148
components/development/auditInspectionSchema.ts
Executable file
148
components/development/auditInspectionSchema.ts
Executable 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: 'Площадка для мусора' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Оценки 1–5 для подпунктов */
|
||||
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: 'Завершён',
|
||||
};
|
||||
|
||||
/** Нормализация оценки для отображения: число 1–5 или старые 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;
|
||||
}
|
||||
|
||||
/** Среднее по подпунктам категории: только оценки 1–5, без 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 — числа 1–5 или 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);
|
||||
}
|
||||
|
||||
/** Индекс сложности (0–100) из средних по пунктам осмотра — та же формула, что и износ: (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;
|
||||
}
|
||||
|
||||
/** Текст итога по среднему 1–5 (для отображения) */
|
||||
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} — очень плохо`;
|
||||
}
|
||||
Reference in New Issue
Block a user