const { useEffect, useMemo, useState } = React;
async function api(path, options = {}) {
const res = await fetch(path, {
cache: "no-store",
credentials: "include",
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
...options,
});
if (!res.ok) {
let detail = "Ошибка запроса";
try { detail = (await res.json()).detail || detail; } catch (_) {}
throw new Error(Array.isArray(detail) ? detail.map(x => x.msg).join(", ") : detail);
}
return res.json();
}
function fmtMoney(value) {
return new Intl.NumberFormat("ru-RU", { style: "currency", currency: "RUB", maximumFractionDigits: 2 }).format(Number(value || 0));
}
function fmtDate(value) {
return value ? new Intl.DateTimeFormat("ru-RU", { dateStyle: "short", timeStyle: "short" }).format(new Date(value)) : "";
}
function fmtDateOnly(value) {
if (!value) return "";
const text = String(value);
const match = text.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (match) return `${match[3]}.${match[2]}.${match[1]}`;
return new Intl.DateTimeFormat("ru-RU", { dateStyle: "short" }).format(new Date(value));
}
function prettyJson(value) {
return JSON.stringify(value ?? null, null, 2);
}
function paymentLabel(value) {
return ({ unpaid: "Не оплачен", partially_paid: "Оплачен частично", paid: "Оплачен", canceled: "Отменен", unknown: "Неизвестно" })[value] || value;
}
function shipmentLabel(value) {
return ({ not_shipped: "Не отгружен", partially_shipped: "Отгружен частично", shipped: "Отгружен", unknown: "Неизвестно" })[value] || value;
}
function signatureLabel(value) {
return ({ not_signed: "Не подписан", signed: "Подписан", unknown: "Неизвестно" })[value] || value;
}
function Login({ onLogin }) {
const [login, setLogin] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
async function submit(e) {
e.preventDefault();
setError("");
try { onLogin(await api("/api/auth/login", { method: "POST", body: JSON.stringify({ login, password }) })); }
catch (err) { setError(err.message); }
}
return
;
}
function Shell({ user, setUser }) {
const [path, setPath] = useState(location.pathname === "/" ? "/dashboard" : location.pathname);
useEffect(() => {
const onPop = () => setPath(location.pathname);
addEventListener("popstate", onPop);
return () => removeEventListener("popstate", onPop);
}, []);
function nav(to) { history.pushState(null, "", to); setPath(to); }
async function logout() { await api("/api/auth/logout", { method: "POST" }); setUser(null); }
const page = path.startsWith("/invoices") ? :
path.startsWith("/suppliers") ? :
path.startsWith("/shipping") ? :
path.startsWith("/ai/questions") ? :
path.startsWith("/admin/users") ? :
path.startsWith("/admin/organizations") ? :
path.startsWith("/admin/sync") ? : ;
return
;
}
function Dashboard() {
const [data, setData] = useState(null);
const [error, setError] = useState("");
useEffect(() => { api("/api/dashboard/summary").then(setData).catch(e => setError(e.message)); }, []);
if (error) return {error}
;
if (!data) return Загрузка...
;
return <>
Dashboard
Неоплаченных счетов
{data.total_unpaid_count}
Сумма неоплаченных
{fmtMoney(data.total_unpaid_amount)}
Без номера заказа
{data.without_order_number_count}
Сумма без номера
{fmtMoney(data.without_order_number_amount)}
По организациям
Синхронизация
>;
}
function SimpleTable({ rows, columns, money, footerRows = [] }) {
const moneyColumns = Array.isArray(money) ? money : [money];
function cellValue(value) {
if (typeof value === "boolean") return value ? "Да" : "Нет";
return String(value ?? "");
}
function cells(row) {
return columns.map(c => {moneyColumns.includes(c[0]) ? fmtMoney(row[c[0]]) : cellValue(row[c[0]])} | );
}
return {columns.map(c => | {c[1]} | )}
{rows.map((row, i) => {cells(row)}
)}
{footerRows.length > 0 && {footerRows.map((row, i) => {cells(row)}
)}}
;
}
function Invoices({ user }) {
const [rows, setRows] = useState([]);
const [orgs, setOrgs] = useState([]);
const [selected, setSelected] = useState(null);
const [aiInvoice, setAiInvoice] = useState(null);
const [filters, setFilters] = useState({ organization_id: "", order_number: "" });
const [error, setError] = useState("");
async function load() {
const qs = new URLSearchParams(Object.entries(filters).filter(([,v]) => v));
setRows(await api(`/api/invoices?${qs}`));
}
useEffect(() => { load().catch(e => setError(e.message)); }, [filters]);
useEffect(() => { api("/api/organizations").then(setOrgs).catch(() => setOrgs([])); }, []);
return <>
Счета
{error && {error}
}
| Организация | Номер | Дата | Сумма | Оплата | Ожидаемая оплата | Контрагент | Комментарий 1С | Номер заказа | Б24 | AI | Комментарии | Синхронизация |
{rows.map(r => setSelected(r)}>| {r.organization_name} | {r.number} | {fmtDate(r.date)} | {fmtMoney(r.amount)} | {paymentLabel(r.payment_status)} | {r.expected_payment_date ? fmtDateOnly(r.expected_payment_date) : нет} | {r.counterparty_name} | {r.one_c_comment} | {r.order_number || нет} | {r.bitrix_deal_url ? e.stopPropagation()}>#{r.bitrix_deal_id} : нет} | {user.role === "admin" ? : -} | {r.comments_count} | {fmtDate(r.last_seen_at)} |
)}
{selected && { setSelected(null); load(); }} />}
{aiInvoice && setAiInvoice(null)} />}
>;
}
function Shipping() {
const [data, setData] = useState(null);
const [error, setError] = useState("");
async function load() {
setError("");
try { setData(await api("/api/shipping")); }
catch (e) { setError(e.message); }
}
useEffect(() => { load(); }, []);
const rows = data?.rows || [];
return <>
Отгружено без подписи
{error && {error}
}
{!data ? Загрузка...
: <>
{data.summary.map(item =>
{item.label}
{item.count}
{fmtMoney(item.amount)}
)}
{data.by_organization?.length > 0 && }
| Фирма | Счет | Дата | Сумма | Оплата | Отгрузка | Подпись | Номер отгрузки | Контрагент | Номер заказа | Комментарий 1С | Синхронизация |
{rows.map(row => | {row.organization_name} | {row.number} | {fmtDate(row.date)} | {fmtMoney(row.amount)} | {paymentLabel(row.payment_status)} | {row.shipment_status_label || shipmentLabel(row.shipment_status)} | {row.signature_status_label || signatureLabel(row.signature_status)} | {((row.shipment_numbers || row.realization_numbers || []).join(", ")) || нет} | {row.counterparty_name} | {row.order_number || нет} | {row.one_c_comment} | {fmtDate(row.last_seen_at)} |
)}
>}
>;
}
function matchLabel(value) {
return ({ advance_offset: "Зачет аванса", invoice_number: "Номер счета", comment_invoice_number: "Комментарий + счет", counterparty_amount: "Поставщик + сумма", not_found: "Не найдено" })[value] || value;
}
function Suppliers() {
const [data, setData] = useState(null);
const [error, setError] = useState("");
const [syncing, setSyncing] = useState(false);
const [organizationId, setOrganizationId] = useState("");
async function load() {
setError("");
try { setData(await api("/api/suppliers")); }
catch (e) { setError(e.message); }
}
async function sync() {
setError("");
setSyncing(true);
try { setData(await api("/api/suppliers/sync", { method: "POST" })); }
catch (e) { setError(e.message); }
finally { setSyncing(false); }
}
useEffect(() => { load(); }, []);
const rows = data?.rows || [];
const organizationOptions = useMemo(() => {
return (data?.by_organization || [])
.map(row => {
const source = (data?.by_supplier || []).find(item => item.organization_name === row.organization);
return { id: String(source?.organization_id || ""), name: row.organization };
})
.filter(org => org.id)
.sort((a, b) => a.name.localeCompare(b.name, "ru"));
}, [data]);
const selectedOrganization = organizationOptions.find(org => org.id === organizationId);
const filteredRows = organizationId ? rows.filter(row => String(row.organization_id) === organizationId) : rows;
const filteredAmount = filteredRows.reduce((sum, row) => sum + Number(row.amount || 0), 0);
const bySupplier = useMemo(() => {
const rows = data?.by_supplier || [];
return (organizationId ? rows.filter(row => String(row.organization_id) === organizationId) : rows)
.slice()
.sort((a, b) => Number(b.open_amount || 0) - Number(a.open_amount || 0));
}, [data, organizationId]);
const bySupplierTotals = useMemo(() => {
return bySupplier.reduce((total, row) => ({
supplier: "ИТОГО",
open_count: total.open_count + Number(row.open_count || 0),
open_amount: total.open_amount + Number(row.open_amount || 0),
closed_count: total.closed_count + Number(row.closed_count || 0),
closed_amount: total.closed_amount + Number(row.closed_amount || 0),
total_count: total.total_count + Number(row.total_count || 0),
total_amount: total.total_amount + Number(row.total_amount || 0),
}), {
supplier: "ИТОГО",
open_count: 0,
open_amount: 0,
closed_count: 0,
closed_amount: 0,
total_count: 0,
total_amount: 0,
});
}, [bySupplier]);
const filteredByOrganization = organizationId
? (data?.by_organization || []).filter(row => row.organization === selectedOrganization?.name)
: (data?.by_organization || []);
return <>
Поставщики
{error && {error}
}
{!data ? Загрузка...
: <>
Требуют закрывающих
{filteredRows.length}
{fmtMoney(filteredAmount)}
Год оплат
{data.year}
обновлено {fmtDate(data.generated_at)}
{filteredByOrganization.length > 0 && }
| Фирма | Дата оплаты | Платеж | Сумма | Поставщик | Статус | Закрывающий | Вх. номер | Связь | Назначение |
{filteredRows.map((row, index) => | {row.organization_name} | {fmtDate(row.payment_date)} | {row.payment_number} | {fmtMoney(row.amount)} | {row.supplier_name} | {row.closing_status_label} | {row.closing_number ? `${row.closing_number} от ${fmtDate(row.closing_date)}` : нет} | {row.closing_incoming_number || нет} | {matchLabel(row.match_method)} | {row.purpose} |
)}
{bySupplier.length > 0 && }
{data.diagnostics?.length > 0 && }
>}
>;
}
function InvoiceDrawer({ invoice, close }) {
const [detail, setDetail] = useState(invoice);
const [comments, setComments] = useState([]);
const [orderNumber, setOrderNumber] = useState(invoice.order_number || "");
const [comment, setComment] = useState("");
const [error, setError] = useState("");
const [bitrixDeals, setBitrixDeals] = useState(null);
const [bitrixLoading, setBitrixLoading] = useState(false);
const [bitrixError, setBitrixError] = useState("");
async function load() {
setDetail(await api(`/api/invoices/${invoice.id}`));
setComments(await api(`/api/invoices/${invoice.id}/comments`));
}
useEffect(() => { load().catch(e => setError(e.message)); }, []);
async function saveOrder() {
setError("");
try { setDetail(await api(`/api/invoices/${invoice.id}/order-number`, { method: "PATCH", body: JSON.stringify({ order_number: orderNumber }) })); }
catch (e) { setError(e.message); }
}
async function addComment() {
setError("");
try { await api(`/api/invoices/${invoice.id}/comments`, { method: "POST", body: JSON.stringify({ comment }) }); setComment(""); await load(); }
catch (e) { setError(e.message); }
}
async function findBitrixDeal() {
const normalized = orderNumber.replace(/\D/g, "");
if (!normalized) {
setBitrixError("Укажите номер заказа");
setBitrixDeals(null);
return;
}
setBitrixLoading(true);
setBitrixError("");
try {
setBitrixDeals(await api(`/api/bitrix/deals/by-order/${encodeURIComponent(normalized)}`));
} catch (e) {
setBitrixError(e.message);
setBitrixDeals(null);
} finally {
setBitrixLoading(false);
}
}
return <>
>;
}
function AiAnswerPayload({ value }) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {prettyJson(value)};
}
const evidence = Array.isArray(value.evidence) ? value.evidence : [];
return
{Object.entries(value).filter(([key]) => key !== "evidence").map(([key, val]) =>
{key}
{typeof val === "object" ? prettyJson(val) : String(val ?? "")}
)}
{evidence.length > 0 &&
Источники
{evidence.map((item, index) =>
{item.source_type || "Источник"} {item.source_date || ""}
{item.quote || prettyJson(item)}
)}
}
;
}
function AiAnswersModal({ invoice, close }) {
const [answers, setAnswers] = useState([]);
const [loading, setLoading] = useState(Boolean(invoice.bitrix_deal_id));
const [error, setError] = useState("");
useEffect(() => {
if (!invoice.bitrix_deal_id) return;
api(`/api/invoices/${invoice.id}/ai-answers`)
.then(setAnswers)
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, [invoice.id, invoice.bitrix_deal_id]);
return <>
>;
}
function AiQuestions() {
const empty = {
id: null,
title: "",
question: "",
response_schema: "",
is_active: true,
interval_minutes: 120,
};
const [rows, setRows] = useState([]);
const [form, setForm] = useState(empty);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState(false);
async function load() {
setRows(await api("/api/admin/ai/questions"));
}
useEffect(() => { load().catch(e => setError(e.message)); }, []);
function edit(question) {
setForm({
id: question.id,
title: question.title,
question: question.question,
response_schema: question.response_schema ? prettyJson(question.response_schema) : "",
is_active: question.is_active,
interval_minutes: question.interval_minutes,
});
setError("");
setMessage("");
}
function parseSchema() {
const text = form.response_schema.trim();
if (!text) return null;
try { return JSON.parse(text); }
catch (e) { throw new Error(`JSON-схема не читается: ${e.message}`); }
}
async function save(e) {
e.preventDefault();
setSaving(true);
setError("");
setMessage("");
try {
const payload = {
title: form.title,
question: form.question,
response_schema: parseSchema(),
is_active: form.is_active,
interval_minutes: Number(form.interval_minutes || 120),
};
if (form.id) {
await api(`/api/admin/ai/questions/${form.id}`, { method: "PATCH", body: JSON.stringify(payload) });
setMessage("Вопрос сохранен");
} else {
await api("/api/admin/ai/questions", { method: "POST", body: JSON.stringify(payload) });
setMessage("Вопрос создан");
setForm(empty);
}
await load();
} catch (e) {
setError(e.message);
} finally {
setSaving(false);
}
}
async function runNow() {
setRunning(true);
setError("");
setMessage("");
try {
const result = await api("/api/admin/ai/run", { method: "POST" });
setMessage(prettyJson(result));
} catch (e) {
setError(e.message);
} finally {
setRunning(false);
}
}
return <>
AI вопросы
{error && {error}
}
{message && {message}}
Список вопросов
| Название | Активен | Интервал | Изменен | |
{rows.map(row =>
| {row.title} {row.question} |
{row.is_active ? "Да" : "Нет"} |
{row.interval_minutes} |
{fmtDate(row.updated_at)} |
|
)}
{rows.length === 0 && | Вопросов пока нет |
}
>;
}
function Organizations() {
const empty = { name: "", odata_base_url: "", invoice_entity_set: "Document_СчетНаОплатуПокупателю", one_c_username: "", one_c_password: "", is_active: true, include_in_dashboards: true, sync_interval_minutes: 5, payment_strategy: "metadata_unknown" };
const [rows, setRows] = useState([]);
const [form, setForm] = useState(empty);
const [message, setMessage] = useState("");
async function load() { setRows(await api("/api/admin/organizations")); }
useEffect(() => { load(); }, []);
async function submit(e) { e.preventDefault(); await api("/api/admin/organizations", { method: "POST", body: JSON.stringify(form) }); setForm(empty); load(); }
async function test(id) { setMessage(JSON.stringify(await api(`/api/admin/organizations/${id}/test-connection`, { method: "POST" }), null, 2)); }
async function sync(id) { setMessage(JSON.stringify(await api(`/api/admin/organizations/${id}/sync-now`, { method: "POST" }), null, 2)); load(); }
async function toggleDashboards(org) {
await api(`/api/admin/organizations/${org.id}`, { method: "PATCH", body: JSON.stringify({ include_in_dashboards: !org.include_in_dashboards }) });
await load();
}
return <>Организации
{rows.map(o =>
{o.name}
)}
{message &&
{message}}
>;
}
function Users() {
const [rows, setRows] = useState([]);
const [form, setForm] = useState({ login: "", password: "", role: "user", is_active: true });
async function load() { setRows(await api("/api/admin/users")); }
useEffect(() => { load(); }, []);
async function submit(e) { e.preventDefault(); await api("/api/admin/users", { method: "POST", body: JSON.stringify(form) }); setForm({ login: "", password: "", role: "user", is_active: true }); load(); }
return <>Пользователи
>;
}
function Sync() {
const [result, setResult] = useState("");
async function syncAll() { setResult(JSON.stringify(await api("/api/admin/organizations/sync-all", { method: "POST" }), null, 2)); }
return <>Синхронизация
{result}>;
}
function App() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => { api("/api/auth/me").then(setUser).catch(() => setUser(null)).finally(() => setLoading(false)); }, []);
if (loading) return Загрузка...
;
return user ? : ;
}
ReactDOM.createRoot(document.getElementById("root")).render();