feat: stop words CRUD + client-side filtering
This commit is contained in:
parent
a764213a77
commit
d0f2a72dda
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
Loading…
Reference in New Issue