feat: suggestions voting + logistician access
- Add suggestion_votes table (DB migration applied) - Vote/unvote with optimistic UI update - Sort suggestions by vote_count desc, then created_at desc - Add 'Предложения' tab to logistician role - Vote button shows count, highlighted when voted
This commit is contained in:
parent
112147cb0a
commit
efe98c09d4
|
|
@ -27,6 +27,7 @@ export const SuggestionsPanel = () => {
|
||||||
const [newCategory, setNewCategory] = React.useState("feature");
|
const [newCategory, setNewCategory] = React.useState("feature");
|
||||||
const [submitting, setSubmitting] = React.useState(false);
|
const [submitting, setSubmitting] = React.useState(false);
|
||||||
const [message, setMessage] = React.useState("");
|
const [message, setMessage] = React.useState("");
|
||||||
|
const [myVotes, setMyVotes] = React.useState(new Set());
|
||||||
|
|
||||||
const getSupabase = React.useCallback(async () => {
|
const getSupabase = React.useCallback(async () => {
|
||||||
const { createClient } = await import("@supabase/supabase-js");
|
const { createClient } = await import("@supabase/supabase-js");
|
||||||
|
|
@ -42,14 +43,23 @@ export const SuggestionsPanel = () => {
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from("suggestions")
|
.from("suggestions")
|
||||||
.select("*")
|
.select("*")
|
||||||
|
.order("vote_count", { ascending: false })
|
||||||
.order("created_at", { ascending: false });
|
.order("created_at", { ascending: false });
|
||||||
|
|
||||||
|
// Fetch my votes
|
||||||
|
const { data: votesData } = await supabase
|
||||||
|
.from("suggestion_votes")
|
||||||
|
.select("suggestion_id")
|
||||||
|
.eq("user_id", user?.id);
|
||||||
|
setMyVotes(new Set((votesData || []).map((v) => v.suggestion_id)));
|
||||||
|
|
||||||
setSuggestions(data || []);
|
setSuggestions(data || []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("fetch suggestions error", e);
|
console.error("fetch suggestions error", e);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [getSupabase]);
|
}, [getSupabase, user?.id]);
|
||||||
|
|
||||||
React.useEffect(() => { fetchSuggestions(); }, [fetchSuggestions]);
|
React.useEffect(() => { fetchSuggestions(); }, [fetchSuggestions]);
|
||||||
|
|
||||||
|
|
@ -90,6 +100,40 @@ export const SuggestionsPanel = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVote = async (suggestionId) => {
|
||||||
|
const hasVoted = myVotes.has(suggestionId);
|
||||||
|
try {
|
||||||
|
const supabase = await getSupabase();
|
||||||
|
if (hasVoted) {
|
||||||
|
await supabase
|
||||||
|
.from("suggestion_votes")
|
||||||
|
.delete()
|
||||||
|
.eq("suggestion_id", suggestionId)
|
||||||
|
.eq("user_id", user?.id);
|
||||||
|
setMyVotes((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(suggestionId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await supabase
|
||||||
|
.from("suggestion_votes")
|
||||||
|
.insert({ suggestion_id: suggestionId, user_id: user?.id });
|
||||||
|
setMyVotes((prev) => new Set(prev).add(suggestionId));
|
||||||
|
}
|
||||||
|
fetchSuggestions();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vote error", e);
|
||||||
|
// Revert optimistic update
|
||||||
|
setMyVotes((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (hasVoted) next.add(suggestionId);
|
||||||
|
else next.delete(suggestionId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Panel className="space-y-4 p-5">
|
<Panel className="space-y-4 p-5">
|
||||||
|
|
@ -100,7 +144,7 @@ export const SuggestionsPanel = () => {
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
Есть идея? Опишите — админы рассмотрят.
|
Есть идея? Опишите — другие смогут проголосовать, админы рассмотрят.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{CATEGORY_OPTIONS.map((cat) => (
|
{CATEGORY_OPTIONS.map((cat) => (
|
||||||
|
|
@ -140,9 +184,14 @@ export const SuggestionsPanel = () => {
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Panel className="space-y-3 p-5">
|
<Panel className="space-y-3 p-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-[0.16em] text-[var(--color-text)]">
|
<h2 className="text-sm font-semibold uppercase tracking-[0.16em] text-[var(--color-text)]">
|
||||||
Все предложения
|
Все предложения
|
||||||
</h2>
|
</h2>
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
↑ Сортировка: по голосам
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">Загрузка...</p>
|
<p className="text-sm text-[var(--color-text-muted)]">Загрузка...</p>
|
||||||
) : suggestions.length === 0 ? (
|
) : suggestions.length === 0 ? (
|
||||||
|
|
@ -152,6 +201,7 @@ export const SuggestionsPanel = () => {
|
||||||
{suggestions.map((s) => {
|
{suggestions.map((s) => {
|
||||||
const cat = CATEGORY_OPTIONS.find((c) => c.value === s.category) || CATEGORY_OPTIONS[3];
|
const cat = CATEGORY_OPTIONS.find((c) => c.value === s.category) || CATEGORY_OPTIONS[3];
|
||||||
const st = STATUS_MAP[s.status] || STATUS_MAP.new;
|
const st = STATUS_MAP[s.status] || STATUS_MAP.new;
|
||||||
|
const hasVoted = myVotes.has(s.id);
|
||||||
return (
|
return (
|
||||||
<SuggestionCard
|
<SuggestionCard
|
||||||
key={s.id}
|
key={s.id}
|
||||||
|
|
@ -160,6 +210,8 @@ export const SuggestionsPanel = () => {
|
||||||
status={st}
|
status={st}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
onStatusChange={handleStatusChange}
|
onStatusChange={handleStatusChange}
|
||||||
|
hasVoted={hasVoted}
|
||||||
|
onVote={handleVote}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -170,7 +222,7 @@ export const SuggestionsPanel = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SuggestionCard = ({ suggestion: s, category, status, isAdmin, onStatusChange }) => {
|
const SuggestionCard = ({ suggestion: s, category, status, isAdmin, onStatusChange, hasVoted, onVote }) => {
|
||||||
const [editing, setEditing] = React.useState(false);
|
const [editing, setEditing] = React.useState(false);
|
||||||
const [comment, setComment] = React.useState(s.admin_comment || "");
|
const [comment, setComment] = React.useState(s.admin_comment || "");
|
||||||
|
|
||||||
|
|
@ -196,11 +248,28 @@ const SuggestionCard = ({ suggestion: s, category, status, isAdmin, onStatusChan
|
||||||
<span className="text-[10px] text-[var(--color-text-muted)] whitespace-nowrap">{formatDate(s.created_at)}</span>
|
<span className="text-[10px] text-[var(--color-text-muted)] whitespace-nowrap">{formatDate(s.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-[var(--color-text)] whitespace-pre-wrap">{s.content}</p>
|
<p className="text-sm text-[var(--color-text)] whitespace-pre-wrap">{s.content}</p>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||||
<span className="font-medium">{s.author_name}</span>
|
<span className="font-medium">{s.author_name}</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>{s.author_role}</span>
|
<span>{s.author_role}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Vote button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onVote(s.id)}
|
||||||
|
className={[
|
||||||
|
"flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-semibold transition",
|
||||||
|
hasVoted
|
||||||
|
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
|
||||||
|
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
|
||||||
|
].join(" ")}
|
||||||
|
title={hasVoted ? "Нажмите, чтобы отменить голос" : "Голосовать за предложение"}
|
||||||
|
>
|
||||||
|
<span className="text-sm">{hasVoted ? "👍" : "⬆️"}</span>
|
||||||
|
<span>{s.vote_count || 0}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{s.admin_comment && (
|
{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)]">
|
<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}
|
<span className="font-semibold">💬 Админ:</span> {s.admin_comment}
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,7 @@ export const DashboardPage = () => {
|
||||||
: userRole === "logistician"
|
: userRole === "logistician"
|
||||||
? [
|
? [
|
||||||
{ key: "logistics", label: "Логистика", description: "Группы доставки по готовности к уведомлению.", badge: String(allOrderGroups.length || orderGroups.length || 0) },
|
{ key: "logistics", label: "Логистика", description: "Группы доставки по готовности к уведомлению.", badge: String(allOrderGroups.length || orderGroups.length || 0) },
|
||||||
|
{ key: "suggestions", label: "Предложения", description: "Предложить улучшение.", badge: null },
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ key: section.key, label: section.label, description: section.description, badge: String(userRole === "driver" ? driverOrderCount : (allOrderGroups.length || orderGroups.length || 0)) },
|
{ key: section.key, label: section.label, description: section.description, badge: String(userRole === "driver" ? driverOrderCount : (allOrderGroups.length || orderGroups.length || 0)) },
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue