feat: stop words CRUD + client-side filtering

This commit is contained in:
root 2026-05-27 14:28:52 +00:00
parent a764213a77
commit d0f2a72dda
3 changed files with 150 additions and 3 deletions

View File

@ -0,0 +1,126 @@
import React from "react";
import { Panel } from "../UI/Panel";
import { Button } from "../UI/Button";
import { supabase } from "../../supabaseClient";
export const StopWordsPanel = () => {
const [words, setWords] = React.useState([]);
const [newWord, setNewWord] = React.useState("");
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState("");
const [deletingId, setDeletingId] = React.useState(null);
const loadWords = React.useCallback(async () => {
setIsLoading(true);
setError("");
const { data, error: fetchError } = await supabase
.from("stop_words")
.select("id, word, created_at")
.order("word", { ascending: true });
if (fetchError) {
setError(fetchError.message);
} else {
setWords(data || []);
}
setIsLoading(false);
}, []);
React.useEffect(() => { loadWords(); }, [loadWords]);
const handleAdd = async () => {
const trimmed = newWord.trim().toLowerCase();
if (!trimmed) return;
if (words.some((w) => w.word === trimmed)) {
setError("Такое слово уже есть");
return;
}
setError("");
const { error: insertError } = await supabase
.from("stop_words")
.insert({ word: trimmed });
if (insertError) {
setError(insertError.message);
return;
}
setNewWord("");
await loadWords();
};
const handleDelete = async (id) => {
setDeletingId(id);
const { error: deleteError } = await supabase
.from("stop_words")
.delete()
.eq("id", id);
if (deleteError) {
setError(deleteError.message);
} else {
await loadWords();
}
setDeletingId(null);
};
const handleKeyDown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAdd();
}
};
return (
<Panel className="space-y-5 p-6">
<div>
<h2 className="text-lg font-semibold">Стоп-слова</h2>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
Позиции с этими словами не показываются клиентам в карточке доставки.
Добавляйте слова-маркеры: «сверление», «обмер» и т.д.
</p>
</div>
<div className="flex gap-3">
<input
type="text"
value={newWord}
onChange={(e) => setNewWord(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Новое стоп-слово"
className="flex-1 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-2.5 text-sm !text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
maxLength={100}
/>
<Button onClick={handleAdd} disabled={!newWord.trim()}>
Добавить
</Button>
</div>
{error && (
<p className="text-sm text-[var(--color-warning)]">{error}</p>
)}
{isLoading ? (
<p className="text-sm text-[var(--color-text-muted)]">Загрузка...</p>
) : !words.length ? (
<p className="text-sm text-[var(--color-text-muted)]">Стоп-слов пока нет. Добавьте первое.</p>
) : (
<div className="flex flex-wrap gap-2">
{words.map((w) => (
<span
key={w.id}
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-strong)] px-3 py-1.5 text-sm !text-[var(--color-text)]"
>
{w.word}
<button
type="button"
disabled={deletingId === w.id}
onClick={() => handleDelete(w.id)}
className="ml-0.5 flex h-4 w-4 items-center justify-center rounded-full text-[var(--color-text-muted)] transition hover:bg-[var(--color-accent-soft)] hover:!text-[var(--color-danger)] disabled:opacity-40"
aria-label={`Удалить ${w.word}`}
>
×
</button>
</span>
))}
</div>
)}
</Panel>
);
};

View File

@ -60,14 +60,26 @@ const flattenOrderProducts = (rawItems) => {
return products;
};
const matchesStopWord = (name, stopWords) => {
if (!stopWords || !stopWords.length) return false;
const lower = name.toLowerCase();
return stopWords.some((sw) => lower.includes(sw));
};
export const OrderCompositionPanel = ({ invitation = {} }) => {
const stopWords = invitation.stopWords || [];
const rawItems = invitation.orderItems || invitation.items || [];
const products = flattenOrderProducts(rawItems);
const allProducts = flattenOrderProducts(rawItems);
const products = stopWords.length
? allProducts.filter((p) => !matchesStopWord(p.name, stopWords))
: allProducts;
const filteredCount = allProducts.length - products.length;
const reference = getInvitationReferenceLabel(invitation);
const [isExpanded, setIsExpanded] = React.useState(false);
if (products.length === 0) return null;
if (products.length === 0 && filteredCount === 0) return null;
return (
<Panel className="space-y-3 border shadow-soft p-5 sm:p-6">
@ -106,6 +118,11 @@ export const OrderCompositionPanel = ({ invitation = {} }) => {
) : null}
</div>
))}
{products.length === 0 && filteredCount > 0 && (
<p className="text-sm text-[var(--color-text-muted)]">
Все позиции исключены из отображения.
</p>
)}
</div>
)}
</Panel>

View File

@ -6,6 +6,7 @@ import { OrdersTable } from "../components/orders/OrdersTable";
import { AdminDashboard } from "../components/admin/AdminDashboard";
import UserManagementPanel from "../components/admin/UserManagementPanel";
import ErrorLogPanel from "../components/admin/ErrorLogPanel";
import { StopWordsPanel } from "../components/admin/StopWordsPanel";
import { Panel } from "../components/UI/Panel";
import { ProductGuidePanel } from "../components/dashboard/ProductGuidePanel";
import { useAuth } from "../context/AuthContext";
@ -20,6 +21,7 @@ const MEGA_ADMIN_NAV = [
{ key: "orders", label: "Группы", description: "Реестр групп доставки.", badge: null },
{ key: "users", label: "Пользователи", description: "Управление пользователями и ролями.", badge: null },
{ key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null },
{ key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из клиентской карточки.", badge: null },
];
const ROLE_SECTION = {
@ -91,6 +93,7 @@ export const DashboardPage = () => {
{ key: "analytics", label: "Аналитика", description: "Статистика доставки.", badge: null },
{ key: "orders", label: "Группы", description: "Реестр групп доставки.", badge: String(allOrderGroups.length || orderGroups.length || 0) },
{ key: "users", label: "Пользователи", description: "Управление пользователями.", badge: null },
{ key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из карточки.", badge: null },
]
: userRole === "logistician"
? [
@ -113,6 +116,7 @@ export const DashboardPage = () => {
if (activeSection === "guide") return <ProductGuidePanel />;
if (activeSection === "analytics") return <div className="space-y-6 xl:space-y-8"><AdminDashboard /></div>;
if (activeSection === "users") return <div className="space-y-6 xl:space-y-8"><UserManagementPanel /></div>;
if (activeSection === "stop_words") return <div className="space-y-6 xl:space-y-8"><StopWordsPanel /></div>;
if (activeSection === "errors") return <div className="space-y-6 xl:space-y-8"><ErrorLogPanel /></div>;
if (userRole === "driver") {