feat(admin): /admin/jobs/settings page — inline-edit Celery job config
Add GET+PUT table for all job_types (scrape_kn, nspd_geo, objective_etl, objective_sync). Per-row drafts, collapsible extra_config JSON editor, toast notifications. Tab added to admin layout. Hand-rolled types until codegen picks up new endpoints. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2fee7739e8
commit
7e1540f84b
3 changed files with 3548 additions and 0 deletions
612
frontend/src/app/admin/jobs/settings/page.tsx
Normal file
612
frontend/src/app/admin/jobs/settings/page.tsx
Normal file
|
|
@ -0,0 +1,612 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
|
// Hand-rolled types — replace with codegen once backend job_settings endpoints
|
||||||
|
// appear in /openapi.json (run `npm run codegen` then delete this block).
|
||||||
|
interface JobSettingRead {
|
||||||
|
job_type: string;
|
||||||
|
enabled: boolean;
|
||||||
|
queue_name: string;
|
||||||
|
cron_schedule: string | null;
|
||||||
|
rate_ms: number | null;
|
||||||
|
max_retries: number;
|
||||||
|
max_concurrency: number;
|
||||||
|
extra_config: Record<string, unknown>;
|
||||||
|
updated_at: string;
|
||||||
|
updated_by: string | null;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JobSettingUpdate {
|
||||||
|
enabled?: boolean | null;
|
||||||
|
queue_name?: string | null;
|
||||||
|
cron_schedule?: string | null;
|
||||||
|
rate_ms?: number | null;
|
||||||
|
max_retries?: number | null;
|
||||||
|
max_concurrency?: number | null;
|
||||||
|
extra_config?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-row draft state (mirrors JobSettingRead fields that are editable)
|
||||||
|
interface RowDraft {
|
||||||
|
enabled: boolean;
|
||||||
|
cron_schedule: string;
|
||||||
|
rate_ms: string; // kept as string for controlled input, parsed on save
|
||||||
|
max_retries: string;
|
||||||
|
max_concurrency: string;
|
||||||
|
extra_config_text: string; // raw textarea JSON
|
||||||
|
extra_config_error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toast state type
|
||||||
|
interface Toast {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
kind: "success" | "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRowDraft(s: JobSettingRead): RowDraft {
|
||||||
|
return {
|
||||||
|
enabled: s.enabled,
|
||||||
|
cron_schedule: s.cron_schedule ?? "",
|
||||||
|
rate_ms: s.rate_ms != null ? String(s.rate_ms) : "",
|
||||||
|
max_retries: String(s.max_retries),
|
||||||
|
max_concurrency: String(s.max_concurrency),
|
||||||
|
extra_config_text: JSON.stringify(s.extra_config, null, 2),
|
||||||
|
extra_config_error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIso(s: string): string {
|
||||||
|
return new Date(s).toLocaleString("ru-RU");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JobSettingsPage() {
|
||||||
|
const [token, setToken] = useState<string>(() =>
|
||||||
|
typeof window === "undefined"
|
||||||
|
? ""
|
||||||
|
: (localStorage.getItem("admin_token") ?? ""),
|
||||||
|
);
|
||||||
|
// Per-row drafts keyed by job_type
|
||||||
|
const [drafts, setDrafts] = useState<Record<string, RowDraft>>({});
|
||||||
|
// Which rows have the extra_config textarea expanded
|
||||||
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
const toastSeqRef = useRef(0);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const saveToken = (v: string) => {
|
||||||
|
setToken(v);
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.setItem("admin_token", v);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addToast = useCallback((message: string, kind: "success" | "error") => {
|
||||||
|
const id = ++toastSeqRef.current;
|
||||||
|
setToasts((prev: Toast[]) => [...prev, { id, message, kind }]);
|
||||||
|
window.setTimeout(
|
||||||
|
() =>
|
||||||
|
setToasts((prev: Toast[]) => prev.filter((t: Toast) => t.id !== id)),
|
||||||
|
4000,
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const settings = useQuery({
|
||||||
|
queryKey: ["job-settings", token],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<JobSettingRead[]>("/api/v1/admin/jobs/settings", {
|
||||||
|
headers: { "X-Admin-Token": token },
|
||||||
|
}),
|
||||||
|
enabled: !!token,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialise drafts when settings data first arrives (or new job_types appear).
|
||||||
|
// Using useEffect for syncing external data → local draft state, per codebase pattern.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settings.data) return;
|
||||||
|
setDrafts((prev: Record<string, RowDraft>) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
let changed = false;
|
||||||
|
for (const s of settings.data) {
|
||||||
|
if (!(s.job_type in next)) {
|
||||||
|
next[s.job_type] = toRowDraft(s);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, [settings.data]);
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
job_type,
|
||||||
|
update,
|
||||||
|
}: {
|
||||||
|
job_type: string;
|
||||||
|
update: JobSettingUpdate;
|
||||||
|
}) =>
|
||||||
|
apiFetch<JobSettingRead>(`/api/v1/admin/jobs/settings/${job_type}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "X-Admin-Token": token },
|
||||||
|
body: JSON.stringify(update),
|
||||||
|
}),
|
||||||
|
onSuccess: (data: JobSettingRead) => {
|
||||||
|
setDrafts((prev: Record<string, RowDraft>) => ({
|
||||||
|
...prev,
|
||||||
|
[data.job_type]: toRowDraft(data),
|
||||||
|
}));
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["job-settings"] });
|
||||||
|
addToast(`${data.job_type}: сохранено`, "success");
|
||||||
|
},
|
||||||
|
onError: (
|
||||||
|
err: unknown,
|
||||||
|
variables: { job_type: string; update: JobSettingUpdate },
|
||||||
|
) => {
|
||||||
|
addToast(
|
||||||
|
`${variables.job_type}: ошибка — ${(err as Error).message}`,
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchDraft = (job_type: string, patch: Partial<RowDraft>) => {
|
||||||
|
setDrafts((prev: Record<string, RowDraft>) => ({
|
||||||
|
...prev,
|
||||||
|
[job_type]: { ...(prev[job_type] ?? {}), ...patch } as RowDraft,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = (job_type: string) => {
|
||||||
|
const draft = drafts[job_type];
|
||||||
|
if (!draft) return;
|
||||||
|
|
||||||
|
// Parse extra_config JSON only when the textarea is expanded
|
||||||
|
let extra_config: Record<string, unknown> | undefined;
|
||||||
|
if (expanded[job_type]) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(draft.extra_config_text) as unknown;
|
||||||
|
if (
|
||||||
|
typeof parsed !== "object" ||
|
||||||
|
parsed === null ||
|
||||||
|
Array.isArray(parsed)
|
||||||
|
) {
|
||||||
|
patchDraft(job_type, {
|
||||||
|
extra_config_error: "Должен быть JSON-объект (не массив и не null)",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
extra_config = parsed as Record<string, unknown>;
|
||||||
|
patchDraft(job_type, { extra_config_error: null });
|
||||||
|
} catch (e) {
|
||||||
|
patchDraft(job_type, {
|
||||||
|
extra_config_error: `Невалидный JSON: ${(e as Error).message}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const update: JobSettingUpdate = {
|
||||||
|
enabled: draft.enabled,
|
||||||
|
cron_schedule: draft.cron_schedule.trim() || null,
|
||||||
|
rate_ms: draft.rate_ms !== "" ? Number(draft.rate_ms) : null,
|
||||||
|
max_retries: Number(draft.max_retries),
|
||||||
|
max_concurrency: Number(draft.max_concurrency),
|
||||||
|
};
|
||||||
|
if (extra_config !== undefined) {
|
||||||
|
update.extra_config = extra_config;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMutation.mutate({ job_type, update });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPendingFor = (job_type: string) =>
|
||||||
|
updateMutation.isPending &&
|
||||||
|
(
|
||||||
|
updateMutation.variables as
|
||||||
|
| { job_type: string; update: JobSettingUpdate }
|
||||||
|
| undefined
|
||||||
|
)?.job_type === job_type;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Toast notifications */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 20,
|
||||||
|
right: 20,
|
||||||
|
zIndex: 9999,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{toasts.map((t: Toast) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
style={{
|
||||||
|
padding: "10px 16px",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
background: t.kind === "success" ? "#f0fdf4" : "#fef2f2",
|
||||||
|
border: `1px solid ${t.kind === "success" ? "#bbf7d0" : "#fecaca"}`,
|
||||||
|
color: t.kind === "success" ? "#065f46" : "#991b1b",
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,.08)",
|
||||||
|
maxWidth: 360,
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.kind === "success" ? "✓ " : "✕ "}
|
||||||
|
{t.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style={{ marginTop: 0, fontSize: 18 }}>Job Settings</h2>
|
||||||
|
<p style={{ color: "#5b6066", fontSize: 13, marginTop: 4 }}>
|
||||||
|
Централизованные настройки Celery jobs. Изменения применяются с
|
||||||
|
следующего beat-цикла. Manual trigger остаётся в{" "}
|
||||||
|
<a
|
||||||
|
href="/admin/scrape"
|
||||||
|
style={{ color: "#1d4ed8", textDecoration: "none" }}
|
||||||
|
>
|
||||||
|
/admin/scrape
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Token */}
|
||||||
|
<section style={cardStyle}>
|
||||||
|
<label style={{ display: "block" }}>
|
||||||
|
<span style={labelStyle}>Admin Token (X-Admin-Token)</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
saveToken(e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="SCRAPE_ADMIN_TOKEN — сохранится в localStorage"
|
||||||
|
style={{ ...inputStyle, maxWidth: 400 }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<section style={{ ...cardStyle, marginTop: 16 }}>
|
||||||
|
{!token ? (
|
||||||
|
<p style={{ color: "#5b6066", fontSize: 13 }}>
|
||||||
|
Введите Admin Token выше чтобы загрузить настройки.
|
||||||
|
</p>
|
||||||
|
) : settings.isLoading ? (
|
||||||
|
<p style={{ color: "#5b6066" }}>Загрузка…</p>
|
||||||
|
) : settings.isError ? (
|
||||||
|
<p style={{ color: "#b3261e" }}>Ошибка: {String(settings.error)}</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ overflowX: "auto" }}>
|
||||||
|
<table
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: "#f6f7f9" }}>
|
||||||
|
{[
|
||||||
|
"job_type",
|
||||||
|
"enabled",
|
||||||
|
"queue_name",
|
||||||
|
"cron_schedule",
|
||||||
|
"rate_ms",
|
||||||
|
"max_retries",
|
||||||
|
"max_concurrency",
|
||||||
|
"extra_config",
|
||||||
|
"updated_at",
|
||||||
|
"",
|
||||||
|
].map((h) => (
|
||||||
|
<th key={h} style={th}>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(settings.data ?? []).map((row: JobSettingRead) => {
|
||||||
|
const draft = drafts[row.job_type];
|
||||||
|
if (!draft) return null;
|
||||||
|
const isExpanded = expanded[row.job_type] ?? false;
|
||||||
|
const saving = isPendingFor(row.job_type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={row.job_type}
|
||||||
|
style={{
|
||||||
|
borderBottom: "1px solid #eef0f3",
|
||||||
|
verticalAlign: "top",
|
||||||
|
opacity: saving ? 0.6 : 1,
|
||||||
|
transition: "opacity .15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* job_type — read-only */}
|
||||||
|
<td style={{ ...td, fontWeight: 600 }}>
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
fontFamily: "ui-monospace,SFMono-Regular,monospace",
|
||||||
|
background: "#f3f4f6",
|
||||||
|
padding: "2px 6px",
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.job_type}
|
||||||
|
</code>
|
||||||
|
{row.description && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#6b7280",
|
||||||
|
marginTop: 4,
|
||||||
|
maxWidth: 160,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* enabled — checkbox */}
|
||||||
|
<td style={{ ...td, textAlign: "center" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.enabled}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
patchDraft(row.job_type, {
|
||||||
|
enabled: e.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={{ width: 16, height: 16, cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* queue_name — read-only (infra config) */}
|
||||||
|
<td style={{ ...td, color: "#6b7280" }}>
|
||||||
|
<code style={{ fontSize: 12 }}>{row.queue_name}</code>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* cron_schedule */}
|
||||||
|
<td style={td}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draft.cron_schedule}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
patchDraft(row.job_type, {
|
||||||
|
cron_schedule: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="* * * * *"
|
||||||
|
style={{
|
||||||
|
...compactInput,
|
||||||
|
fontFamily: "ui-monospace,SFMono-Regular,monospace",
|
||||||
|
width: 130,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* rate_ms */}
|
||||||
|
<td style={td}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={draft.rate_ms}
|
||||||
|
min={0}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
patchDraft(row.job_type, {
|
||||||
|
rate_ms: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="—"
|
||||||
|
style={{ ...compactInput, width: 80 }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* max_retries */}
|
||||||
|
<td style={td}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={draft.max_retries}
|
||||||
|
min={0}
|
||||||
|
max={10}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
patchDraft(row.job_type, {
|
||||||
|
max_retries: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={{ ...compactInput, width: 60 }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* max_concurrency */}
|
||||||
|
<td style={td}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={draft.max_concurrency}
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
patchDraft(row.job_type, {
|
||||||
|
max_concurrency: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={{ ...compactInput, width: 60 }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* extra_config — collapsible textarea */}
|
||||||
|
<td style={td}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setExpanded((prev: Record<string, boolean>) => ({
|
||||||
|
...prev,
|
||||||
|
[row.job_type]: !isExpanded,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
style={toggleBtn}
|
||||||
|
>
|
||||||
|
{isExpanded ? "▼ скрыть" : "▶ JSON"}
|
||||||
|
</button>
|
||||||
|
{isExpanded && (
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
<textarea
|
||||||
|
value={draft.extra_config_text}
|
||||||
|
onChange={(
|
||||||
|
e: React.ChangeEvent<HTMLTextAreaElement>,
|
||||||
|
) => {
|
||||||
|
patchDraft(row.job_type, {
|
||||||
|
extra_config_text: e.target.value,
|
||||||
|
extra_config_error: null,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
rows={6}
|
||||||
|
style={{
|
||||||
|
width: 280,
|
||||||
|
fontFamily:
|
||||||
|
"ui-monospace,SFMono-Regular,monospace",
|
||||||
|
fontSize: 11,
|
||||||
|
padding: "6px 8px",
|
||||||
|
border: draft.extra_config_error
|
||||||
|
? "1px solid #fca5a5"
|
||||||
|
: "1px solid #d1d5db",
|
||||||
|
borderRadius: 6,
|
||||||
|
resize: "vertical",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
{draft.extra_config_error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: "#b3261e",
|
||||||
|
fontSize: 11,
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{draft.extra_config_error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* updated_at — read-only */}
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
...td,
|
||||||
|
color: "#6b7280",
|
||||||
|
fontSize: 11,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatIso(row.updated_at)}
|
||||||
|
{row.updated_by && (
|
||||||
|
<div style={{ color: "#9ca3af" }}>
|
||||||
|
{row.updated_by}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Save button */}
|
||||||
|
<td style={{ ...td, whiteSpace: "nowrap" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSave(row.job_type)}
|
||||||
|
disabled={saving || !token}
|
||||||
|
style={
|
||||||
|
saving ? { ...saveBtn, opacity: 0.6 } : saveBtn
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{saving ? "…" : "Сохранить"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Styles ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const cardStyle = {
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #e6e8ec",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle = {
|
||||||
|
display: "block",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#5b6066",
|
||||||
|
marginBottom: 4,
|
||||||
|
textTransform: "uppercase" as const,
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
padding: "8px 10px",
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 14,
|
||||||
|
width: "100%",
|
||||||
|
boxSizing: "border-box" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const compactInput = {
|
||||||
|
padding: "6px 8px",
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
boxSizing: "border-box" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const th = {
|
||||||
|
padding: "8px 10px",
|
||||||
|
textAlign: "left" as const,
|
||||||
|
fontWeight: 600 as const,
|
||||||
|
borderBottom: "1px solid #e6e8ec",
|
||||||
|
whiteSpace: "nowrap" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const td = { padding: "8px 10px" };
|
||||||
|
|
||||||
|
const saveBtn = {
|
||||||
|
padding: "6px 14px",
|
||||||
|
background: "#1d4ed8",
|
||||||
|
color: "#fff",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600 as const,
|
||||||
|
cursor: "pointer",
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleBtn = {
|
||||||
|
padding: "3px 8px",
|
||||||
|
background: "#f3f4f6",
|
||||||
|
color: "#374151",
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 11,
|
||||||
|
cursor: "pointer",
|
||||||
|
whiteSpace: "nowrap" as const,
|
||||||
|
};
|
||||||
|
|
@ -9,6 +9,7 @@ const TABS = [
|
||||||
{ href: "/admin/scrape/geo", label: "🗺 NSPD geo (rosreestr2coord)" },
|
{ href: "/admin/scrape/geo", label: "🗺 NSPD geo (rosreestr2coord)" },
|
||||||
{ href: "/admin/scrape/objective", label: "ETL Objective" },
|
{ href: "/admin/scrape/objective", label: "ETL Objective" },
|
||||||
{ href: "/admin/leads", label: "Лиды PRINZIP" },
|
{ href: "/admin/leads", label: "Лиды PRINZIP" },
|
||||||
|
{ href: "/admin/jobs/settings", label: "⚙️ Job Settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminLayout({
|
export default function AdminLayout({
|
||||||
|
|
|
||||||
2935
frontend/src/lib/api-types.ts
Normal file
2935
frontend/src/lib/api-types.ts
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue