feat: suggestions panel for employees + admin review
This commit is contained in:
parent
7e43f9e990
commit
69a2023ec1
|
|
@ -0,0 +1,259 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { useAuth } from "../../context/AuthContext";
|
||||||
|
|
||||||
|
const CATEGORY_OPTIONS = [
|
||||||
|
{ value: "feature", label: "Новая функция", icon: "✨" },
|
||||||
|
{ value: "improvement", label: "Улучшение", icon: "🔧" },
|
||||||
|
{ value: "bug", label: "Проблема", icon: "🐛" },
|
||||||
|
{ value: "other", label: "Другое", icon: "💬" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_MAP = {
|
||||||
|
new: { label: "Новое", color: "#3b82f6" },
|
||||||
|
reviewed: { label: "Рассмотрено", color: "#f59e0b" },
|
||||||
|
accepted: { label: "Принято", color: "#22c55e" },
|
||||||
|
declined: { label: "Отклонено", color: "#ef4444" },
|
||||||
|
implemented: { label: "Реализовано", color: "#8b5cf6" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SuggestionsPanel = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isAdmin = ["admin", "mega_admin"].includes(user?.role);
|
||||||
|
const [suggestions, setSuggestions] = React.useState([]);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [newText, setNewText] = React.useState("");
|
||||||
|
const [newCategory, setNewCategory] = React.useState("feature");
|
||||||
|
const [submitting, setSubmitting] = React.useState(false);
|
||||||
|
const [message, setMessage] = React.useState("");
|
||||||
|
|
||||||
|
const getSupabase = React.useCallback(async () => {
|
||||||
|
const { createClient } = await import("@supabase/supabase-js");
|
||||||
|
return createClient(
|
||||||
|
import.meta.env.VITE_SUPABASE_URL || window.__SUPABASE_URL__,
|
||||||
|
import.meta.env.VITE_SUPABASE_ANON_KEY || window.__SUPABASE_ANON_KEY__
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchSuggestions = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const supabase = await getSupabase();
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("suggestions")
|
||||||
|
.select("*")
|
||||||
|
.order("created_at", { ascending: false });
|
||||||
|
setSuggestions(data || []);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("fetch suggestions error", e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [getSupabase]);
|
||||||
|
|
||||||
|
React.useEffect(() => { fetchSuggestions(); }, [fetchSuggestions]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!newText.trim()) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const supabase = await getSupabase();
|
||||||
|
const { error } = await supabase.from("suggestions").insert({
|
||||||
|
author_id: user?.id,
|
||||||
|
author_name: user?.name || user?.email || "Сотрудник",
|
||||||
|
author_role: user?.role || "unknown",
|
||||||
|
content: newText.trim(),
|
||||||
|
category: newCategory,
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
setNewText("");
|
||||||
|
setMessage("✅ Предложение отправлено!");
|
||||||
|
fetchSuggestions();
|
||||||
|
setTimeout(() => setMessage(""), 3000);
|
||||||
|
} catch (e) {
|
||||||
|
setMessage("❌ Ошибка: " + e.message);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = async (id, newStatus, adminComment) => {
|
||||||
|
try {
|
||||||
|
const supabase = await getSupabase();
|
||||||
|
const updates = { status: newStatus, updated_at: new Date().toISOString() };
|
||||||
|
if (adminComment !== undefined) updates.admin_comment = adminComment;
|
||||||
|
const { error } = await supabase.from("suggestions").update(updates).eq("id", id);
|
||||||
|
if (error) throw error;
|
||||||
|
fetchSuggestions();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("update suggestion error", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Panel className="space-y-4 p-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xl">💡</span>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-[0.16em] text-[var(--color-text)]">
|
||||||
|
Предложить улучшение
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
Есть идея? Опишите — админы рассмотрят.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{CATEGORY_OPTIONS.map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setNewCategory(cat.value)}
|
||||||
|
className={[
|
||||||
|
"rounded-xl border px-3 py-1.5 text-xs font-medium transition",
|
||||||
|
newCategory === cat.value
|
||||||
|
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] !text-[var(--color-text)]"
|
||||||
|
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)]"
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{cat.icon} {cat.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={newText}
|
||||||
|
onChange={(e) => setNewText(e.target.value)}
|
||||||
|
placeholder="Опишите предложение..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] px-4 py-3 text-sm text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none resize-none"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!newText.trim() || submitting}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="rounded-xl bg-[var(--color-accent)] px-5 py-2.5 text-sm font-semibold text-white transition hover:opacity-90 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{submitting ? "Отправка..." : "Отправить"}
|
||||||
|
</button>
|
||||||
|
{message && <span className="text-sm text-[var(--color-text-muted)]">{message}</span>}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel className="space-y-3 p-5">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-[0.16em] text-[var(--color-text)]">
|
||||||
|
Все предложения
|
||||||
|
</h2>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">Загрузка...</p>
|
||||||
|
) : suggestions.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">Пока нет предложений</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{suggestions.map((s) => {
|
||||||
|
const cat = CATEGORY_OPTIONS.find((c) => c.value === s.category) || CATEGORY_OPTIONS[3];
|
||||||
|
const st = STATUS_MAP[s.status] || STATUS_MAP.new;
|
||||||
|
return (
|
||||||
|
<SuggestionCard
|
||||||
|
key={s.id}
|
||||||
|
suggestion={s}
|
||||||
|
category={cat}
|
||||||
|
status={st}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onStatusChange={handleStatusChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SuggestionCard = ({ suggestion: s, category, status, isAdmin, onStatusChange }) => {
|
||||||
|
const [editing, setEditing] = React.useState(false);
|
||||||
|
const [comment, setComment] = React.useState(s.admin_comment || "");
|
||||||
|
|
||||||
|
const formatDate = (d) => {
|
||||||
|
if (!d) return "";
|
||||||
|
const date = new Date(d);
|
||||||
|
return date.toLocaleDateString("ru-RU", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4 space-y-2">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-base">{category.icon}</span>
|
||||||
|
<span className="text-xs font-medium text-[var(--color-text-muted)]">{category.label}</span>
|
||||||
|
<span
|
||||||
|
className="rounded-lg px-2 py-0.5 text-[10px] font-semibold uppercase"
|
||||||
|
style={{ backgroundColor: status.color + "18", color: status.color }}
|
||||||
|
>
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-[var(--color-text-muted)] whitespace-nowrap">{formatDate(s.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--color-text)] whitespace-pre-wrap">{s.content}</p>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||||
|
<span className="font-medium">{s.author_name}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{s.author_role}</span>
|
||||||
|
</div>
|
||||||
|
{s.admin_comment && (
|
||||||
|
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] p-3 text-xs text-[var(--color-text-muted)]">
|
||||||
|
<span className="font-semibold">💬 Админ:</span> {s.admin_comment}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="border-t border-[var(--color-border)] pt-2 mt-2 space-y-2">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{Object.entries(STATUS_MAP).map(([key, val]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStatusChange(s.id, key)}
|
||||||
|
className={[
|
||||||
|
"rounded-lg px-2 py-1 text-[10px] font-semibold uppercase transition",
|
||||||
|
s.status === key ? "text-white" : "opacity-60 hover:opacity-100"
|
||||||
|
].join(" ")}
|
||||||
|
style={{
|
||||||
|
backgroundColor: s.status === key ? val.color : val.color + "22",
|
||||||
|
color: s.status === key ? "#fff" : val.color
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{val.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{editing ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
placeholder="Комментарий админа..."
|
||||||
|
className="flex-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-1.5 text-xs text-[var(--color-text)]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { onStatusChange(s.id, s.status, comment); setEditing(false); }}
|
||||||
|
className="rounded-lg bg-[var(--color-accent)] px-3 py-1.5 text-xs font-semibold text-white"
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
className="text-xs text-[var(--color-accent)] hover:underline"
|
||||||
|
>
|
||||||
|
✏️ Комментарий
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -8,6 +8,7 @@ import UserManagementPanel from "../components/admin/UserManagementPanel";
|
||||||
import ErrorLogPanel from "../components/admin/ErrorLogPanel";
|
import ErrorLogPanel from "../components/admin/ErrorLogPanel";
|
||||||
import { StopWordsPanel } from "../components/admin/StopWordsPanel";
|
import { StopWordsPanel } from "../components/admin/StopWordsPanel";
|
||||||
import { ActionLogPanel } from "../components/admin/ActionLogPanel";
|
import { ActionLogPanel } from "../components/admin/ActionLogPanel";
|
||||||
|
import { SuggestionsPanel } from "../components/admin/SuggestionsPanel";
|
||||||
import { Panel } from "../components/UI/Panel";
|
import { Panel } from "../components/UI/Panel";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useNotifications } from "../hooks/useNotifications";
|
import { useNotifications } from "../hooks/useNotifications";
|
||||||
|
|
@ -23,6 +24,7 @@ const MEGA_ADMIN_NAV = [
|
||||||
{ key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null },
|
{ key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null },
|
||||||
{ key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из клиентской карточки.", badge: null },
|
{ key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из клиентской карточки.", badge: null },
|
||||||
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
|
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
|
||||||
|
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ROLE_SECTION = {
|
const ROLE_SECTION = {
|
||||||
|
|
@ -106,6 +108,7 @@ export const DashboardPage = () => {
|
||||||
{ key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из карточки.", badge: null },
|
{ key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из карточки.", badge: null },
|
||||||
{ key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null },
|
{ key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null },
|
||||||
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
|
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
|
||||||
|
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
|
||||||
]
|
]
|
||||||
: userRole === "logistician"
|
: userRole === "logistician"
|
||||||
? [
|
? [
|
||||||
|
|
@ -113,6 +116,7 @@ export const DashboardPage = () => {
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ key: section.key, label: section.label, description: section.description, badge: String(allOrderGroups.length || orderGroups.length || 0) },
|
{ key: section.key, label: section.label, description: section.description, badge: String(allOrderGroups.length || orderGroups.length || 0) },
|
||||||
|
{ key: "suggestions", label: "Предложения", description: "Предложить улучшение.", badge: null },
|
||||||
];
|
];
|
||||||
|
|
||||||
const activeSectionMeta = navItems.find((n) => n.key === activeSection) || navItems[0];
|
const activeSectionMeta = navItems.find((n) => n.key === activeSection) || navItems[0];
|
||||||
|
|
@ -140,6 +144,7 @@ const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician"
|
||||||
if (activeSection === "stop_words") return <div className="space-y-6 xl:space-y-8"><StopWordsPanel /></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 (activeSection === "errors") return <div className="space-y-6 xl:space-y-8"><ErrorLogPanel /></div>;
|
||||||
if (activeSection === "action_log") return <div className="space-y-6 xl:space-y-8"><ActionLogPanel /></div>;
|
if (activeSection === "action_log") return <div className="space-y-6 xl:space-y-8"><ActionLogPanel /></div>;
|
||||||
|
if (activeSection === "suggestions") return <div className="space-y-6 xl:space-y-8"><SuggestionsPanel /></div>;
|
||||||
|
|
||||||
if (userRole === "driver") {
|
if (userRole === "driver") {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue