feat: deploy infrastructure + disk/drive, calendar, presentation, workspaces, onboarding, demo user
This commit is contained in:
Binary file not shown.
@@ -29,12 +29,14 @@ export interface AgentInfo {
|
||||
description: string;
|
||||
is_enabled: boolean;
|
||||
version: string;
|
||||
config: string | null;
|
||||
last_run_at: string | null;
|
||||
}
|
||||
|
||||
export interface AgentUpdate {
|
||||
description?: string;
|
||||
is_enabled?: boolean;
|
||||
config?: string;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
@@ -380,3 +382,28 @@ export function adminToggleBotCommand(id: string, enabled: boolean): Promise<Bot
|
||||
export function adminSyncBotCommands(): Promise<{ synced_count: number; telegram_sync: boolean }> {
|
||||
return apiFetch("/admin/bot/sync", { method: "POST" });
|
||||
}
|
||||
|
||||
// ── Agent Execution ──
|
||||
|
||||
export interface AgentRunResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: Record<string, unknown>;
|
||||
errors: string[];
|
||||
duration_ms: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export function adminRunAgent(name: string, context?: Record<string, unknown>): Promise<AgentRunResult> {
|
||||
return apiFetch(`/admin/agents/${encodeURIComponent(name)}/run`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ context }),
|
||||
});
|
||||
}
|
||||
|
||||
export function adminRunAllAgents(context?: Record<string, unknown>): Promise<Record<string, AgentRunResult>> {
|
||||
return apiFetch("/admin/agents/run-all", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ context }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export function addIdeaToCalendar(ideaId: string): Promise<{ success: boolean; message: string }> {
|
||||
return apiFetch(`/ideas/${ideaId}/calendar`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function getCalendarStatus(): Promise<{
|
||||
connected: boolean;
|
||||
provider: string | null;
|
||||
}> {
|
||||
return apiFetch("/calendar/status");
|
||||
}
|
||||
|
||||
export function disconnectCalendar(): Promise<{ success: boolean }> {
|
||||
return apiFetch("/calendar/disconnect", { method: "POST" });
|
||||
}
|
||||
|
||||
export function listCalendarProviders(): Promise<{ providers: string[] }> {
|
||||
return apiFetch("/calendar/providers");
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
// ── Voting ──
|
||||
|
||||
export interface VoteCount {
|
||||
up: number;
|
||||
down: number;
|
||||
user_vote: string | null;
|
||||
}
|
||||
|
||||
export function toggleVote(ideaId: string, vote: "up" | "down"): Promise<VoteCount> {
|
||||
return apiFetch(`/ideas/${ideaId}/vote`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ vote }),
|
||||
});
|
||||
}
|
||||
|
||||
export function getVotes(ideaId: string): Promise<VoteCount> {
|
||||
return apiFetch(`/ideas/${ideaId}/votes`);
|
||||
}
|
||||
|
||||
// ── Comments ──
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
idea_id: string;
|
||||
user_id: string;
|
||||
content: string;
|
||||
parent_id: string | null;
|
||||
display_name: string;
|
||||
avatar_url: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
replies: Comment[];
|
||||
}
|
||||
|
||||
export function listComments(ideaId: string): Promise<Comment[]> {
|
||||
return apiFetch(`/ideas/${ideaId}/comments`);
|
||||
}
|
||||
|
||||
export function createComment(
|
||||
ideaId: string,
|
||||
content: string,
|
||||
parentId?: string,
|
||||
): Promise<Comment> {
|
||||
return apiFetch(`/ideas/${ideaId}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content, parent_id: parentId }),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteComment(ideaId: string, commentId: string): Promise<void> {
|
||||
return apiFetch(`/ideas/${ideaId}/comments/${commentId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Activity ──
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
idea_id: string;
|
||||
user_id: string | null;
|
||||
action: string;
|
||||
details: Record<string, unknown> | null;
|
||||
display_name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function listActivity(ideaId: string, limit = 50): Promise<Activity[]> {
|
||||
return apiFetch(`/ideas/${ideaId}/activity?limit=${limit}`);
|
||||
}
|
||||
|
||||
// ── Notifications ──
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
body: string;
|
||||
link: string | null;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function listNotifications(unreadOnly = false): Promise<Notification[]> {
|
||||
const params = unreadOnly ? "?unread_only=true" : "";
|
||||
return apiFetch(`/notifications${params}`);
|
||||
}
|
||||
|
||||
export function getUnreadCount(): Promise<{ count: number }> {
|
||||
return apiFetch("/notifications/unread-count");
|
||||
}
|
||||
|
||||
export function markNotificationRead(id: string): Promise<Notification> {
|
||||
return apiFetch(`/notifications/${id}/read`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function markAllNotificationsRead(): Promise<{ count: number }> {
|
||||
return apiFetch("/notifications/read-all", { method: "POST" });
|
||||
}
|
||||
@@ -8,10 +8,22 @@ export interface Idea {
|
||||
status: string;
|
||||
tags: string[] | null;
|
||||
is_public: boolean;
|
||||
funnel_status: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type FunnelStatus = "raw" | "validated" | "backlog" | "in_progress" | "launched" | "retrospective";
|
||||
|
||||
export const FUNNEL_LABELS: Record<FunnelStatus, string> = {
|
||||
raw: "Сырая",
|
||||
validated: "Подтверждённая",
|
||||
backlog: "Бэклог",
|
||||
in_progress: "В работе",
|
||||
launched: "Запущена",
|
||||
retrospective: "Ретроспектива",
|
||||
};
|
||||
|
||||
export interface IdeaCreatePayload {
|
||||
title: string;
|
||||
content: string;
|
||||
@@ -84,3 +96,13 @@ export function getAnalysisResults(
|
||||
const params = role ? `?role=${role}` : "";
|
||||
return apiFetch(`/ideas/${id}/analysis${params}`);
|
||||
}
|
||||
|
||||
export function updateIdeaFunnel(
|
||||
id: string,
|
||||
targetStatus: string,
|
||||
): Promise<Idea> {
|
||||
return apiFetch(`/ideas/${id}/funnel`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ target_status: targetStatus }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "personal" | "team";
|
||||
owner_id: string;
|
||||
description: string | null;
|
||||
member_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceCreatePayload {
|
||||
name: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceUpdatePayload {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
id: string;
|
||||
user_id: string;
|
||||
workspace_id: string;
|
||||
role: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
joined_at: string;
|
||||
}
|
||||
|
||||
export interface AddMemberPayload {
|
||||
user_id: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export function listWorkspaces(): Promise<Workspace[]> {
|
||||
return apiFetch("/workspaces");
|
||||
}
|
||||
|
||||
export function getWorkspace(id: string): Promise<Workspace> {
|
||||
return apiFetch(`/workspaces/${id}`);
|
||||
}
|
||||
|
||||
export function createWorkspace(data: WorkspaceCreatePayload): Promise<Workspace> {
|
||||
return apiFetch("/workspaces", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateWorkspace(id: string, data: WorkspaceUpdatePayload): Promise<Workspace> {
|
||||
return apiFetch(`/workspaces/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteWorkspace(id: string): Promise<void> {
|
||||
return apiFetch(`/workspaces/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function listMembers(workspaceId: string): Promise<Member[]> {
|
||||
return apiFetch(`/workspaces/${workspaceId}/members`);
|
||||
}
|
||||
|
||||
export function addMember(workspaceId: string, data: AddMemberPayload): Promise<Member> {
|
||||
return apiFetch(`/workspaces/${workspaceId}/members`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function removeMember(workspaceId: string, memberId: string): Promise<void> {
|
||||
return apiFetch(`/workspaces/${workspaceId}/members/${memberId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { listActivity, type Activity } from "../api/collaboration";
|
||||
|
||||
interface Props {
|
||||
ideaId: string;
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
comment: "оставил(а) комментарий",
|
||||
vote_up: "оценил(а) идею",
|
||||
vote_down: "отклонил(а) идею",
|
||||
status_change: "изменил(а) статус",
|
||||
funnel: "переместил(а) в воронке",
|
||||
share: "поделился(ась) идеей",
|
||||
analyze: "запустил(а) анализ",
|
||||
};
|
||||
|
||||
export default function ActivityFeed({ ideaId }: Props) {
|
||||
const [activities, setActivities] = useState<Activity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
listActivity(ideaId)
|
||||
.then(setActivities)
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, [ideaId]);
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-sm text-gray-400">Загрузка активности…</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold">Активность</h3>
|
||||
{activities.length === 0 && (
|
||||
<p className="text-sm text-gray-400">Нет активности</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{activities.map((a) => (
|
||||
<div key={a.id} className="flex items-start gap-2 text-sm">
|
||||
<div className="mt-0.5 flex h-6 w-6 items-center justify-center rounded-full bg-gray-100 text-xs dark:bg-gray-800">
|
||||
{a.display_name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p>
|
||||
<span className="font-medium">{a.display_name}</span>{" "}
|
||||
<span className="text-gray-500">
|
||||
{ACTION_LABELS[a.action] || a.action}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{new Date(a.created_at).toLocaleString("ru-RU")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
listComments,
|
||||
createComment,
|
||||
deleteComment,
|
||||
type Comment,
|
||||
} from "../api/collaboration";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
|
||||
interface Props {
|
||||
ideaId: string;
|
||||
}
|
||||
|
||||
export default function CommentThread({ ideaId }: Props) {
|
||||
const { user } = useAuth();
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const [replyTo, setReplyTo] = useState<string | null>(null);
|
||||
const [replyText, setReplyText] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadComments();
|
||||
}, [ideaId]);
|
||||
|
||||
async function loadComments() {
|
||||
try {
|
||||
const items = await listComments(ideaId);
|
||||
// Build threaded structure
|
||||
const topLevel = items.filter((c) => !c.parent_id);
|
||||
const byParent: Record<string, Comment[]> = {};
|
||||
for (const c of items) {
|
||||
if (c.parent_id) {
|
||||
if (!byParent[c.parent_id]) byParent[c.parent_id] = [];
|
||||
byParent[c.parent_id].push(c);
|
||||
}
|
||||
}
|
||||
setComments(
|
||||
topLevel.map((c) => ({ ...c, replies: byParent[c.id] || [] })),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!newComment.trim() || sending) return;
|
||||
setSending(true);
|
||||
try {
|
||||
await createComment(ideaId, newComment.trim());
|
||||
setNewComment("");
|
||||
await loadComments();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReply(parentId: string) {
|
||||
if (!replyText.trim() || sending) return;
|
||||
setSending(true);
|
||||
try {
|
||||
await createComment(ideaId, replyText.trim(), parentId);
|
||||
setReplyTo(null);
|
||||
setReplyText("");
|
||||
await loadComments();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(commentId: string) {
|
||||
if (!confirm("Удалить комментарий?")) return;
|
||||
try {
|
||||
await deleteComment(ideaId, commentId);
|
||||
await loadComments();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderComment(c: Comment, depth = 0) {
|
||||
const isOwner = user?.id === c.user_id;
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`${depth > 0 ? "ml-6 border-l-2 pl-4 dark:border-gray-700" : ""} ${
|
||||
depth > 0 ? "" : "border-b pb-3 dark:border-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{c.display_name}</span>
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{new Date(c.created_at).toLocaleString("ru-RU")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
{c.content}
|
||||
</p>
|
||||
</div>
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={() => handleDelete(c.id)}
|
||||
className="text-xs text-red-500 hover:underline"
|
||||
aria-label="Удалить"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1">
|
||||
<button
|
||||
onClick={() => setReplyTo(replyTo === c.id ? null : c.id)}
|
||||
className="text-xs text-primary-600 hover:underline"
|
||||
>
|
||||
{replyTo === c.id ? "Отмена" : "Ответить"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{replyTo === c.id && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleReply(c.id);
|
||||
}}
|
||||
className="mt-2 flex gap-2"
|
||||
>
|
||||
<input
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
placeholder="Написать ответ…"
|
||||
className="flex-1 rounded-lg border border-gray-300 bg-gray-50 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-800"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sending || !replyText.trim()}
|
||||
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
Отправить
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{c.replies.map((r) => renderComment(r, depth + 1))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold">Комментарии ({comments.length})</h3>
|
||||
|
||||
{/* New comment form */}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<input
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="Написать комментарий…"
|
||||
className="flex-1 rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sending || !newComment.trim()}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
Отправить
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Comments list */}
|
||||
<div className="space-y-3">
|
||||
{comments.length === 0 && (
|
||||
<p className="text-sm text-gray-400">Нет комментариев</p>
|
||||
)}
|
||||
{comments.map((c) => renderComment(c))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
listIdeas,
|
||||
updateIdeaFunnel,
|
||||
type Idea,
|
||||
type FunnelStatus,
|
||||
FUNNEL_LABELS,
|
||||
} from "../api/ideas";
|
||||
|
||||
const FUNNEL_ORDER: FunnelStatus[] = [
|
||||
"raw", "validated", "backlog", "in_progress", "launched", "retrospective",
|
||||
];
|
||||
|
||||
export default function IdeaFunnelKanban() {
|
||||
const [ideas, setIdeas] = useState<Idea[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [moving, setMoving] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
listIdeas()
|
||||
.then(setIdeas)
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function handleMove(ideaId: string, direction: "prev" | "next") {
|
||||
const idea = ideas.find((i) => i.id === ideaId);
|
||||
if (!idea) return;
|
||||
const currentIdx = FUNNEL_ORDER.indexOf(
|
||||
(idea.funnel_status as FunnelStatus) || "raw",
|
||||
);
|
||||
const nextIdx =
|
||||
direction === "next"
|
||||
? Math.min(currentIdx + 1, FUNNEL_ORDER.length - 1)
|
||||
: Math.max(currentIdx - 1, 0);
|
||||
if (nextIdx === currentIdx) return;
|
||||
|
||||
setMoving(ideaId);
|
||||
try {
|
||||
const updated = await updateIdeaFunnel(ideaId, FUNNEL_ORDER[nextIdx]);
|
||||
setIdeas((prev) =>
|
||||
prev.map((i) => (i.id === ideaId ? updated : i)),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setMoving(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary-600 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const columns: Record<string, Idea[]> = {};
|
||||
for (const s of FUNNEL_ORDER) {
|
||||
columns[s] = [];
|
||||
}
|
||||
for (const idea of ideas) {
|
||||
const status = (idea.funnel_status as FunnelStatus) || "raw";
|
||||
if (columns[status]) columns[status].push(idea);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{FUNNEL_ORDER.map((status) => (
|
||||
<div
|
||||
key={status}
|
||||
className="min-w-[250px] flex-shrink-0 rounded-xl border bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900"
|
||||
>
|
||||
<h3 className="mb-3 text-sm font-semibold text-gray-600 dark:text-gray-400">
|
||||
{FUNNEL_LABELS[status]}
|
||||
<span className="ml-1 text-xs text-gray-400">
|
||||
({columns[status].length})
|
||||
</span>
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{columns[status].length === 0 && (
|
||||
<p className="py-4 text-center text-xs text-gray-400">
|
||||
Нет идей
|
||||
</p>
|
||||
)}
|
||||
{columns[status].map((idea) => (
|
||||
<div
|
||||
key={idea.id}
|
||||
className="rounded-lg border bg-white p-3 shadow-sm transition hover:shadow-md dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
<Link
|
||||
to={`/ideas/${idea.id}`}
|
||||
className="mb-1 block text-sm font-medium hover:text-primary-600"
|
||||
>
|
||||
{idea.title}
|
||||
</Link>
|
||||
<p className="mb-2 line-clamp-2 text-xs text-gray-500">
|
||||
{idea.content}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{new Date(idea.created_at).toLocaleDateString("ru-RU")}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => handleMove(idea.id, "prev")}
|
||||
disabled={
|
||||
moving === idea.id ||
|
||||
FUNNEL_ORDER.indexOf(status) === 0
|
||||
}
|
||||
className="rounded px-1.5 py-0.5 text-xs text-gray-500 hover:bg-gray-100 disabled:opacity-30 dark:hover:bg-gray-700"
|
||||
aria-label="Переместить назад"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMove(idea.id, "next")}
|
||||
disabled={
|
||||
moving === idea.id ||
|
||||
FUNNEL_ORDER.indexOf(status) ===
|
||||
FUNNEL_ORDER.length - 1
|
||||
}
|
||||
className="rounded px-1.5 py-0.5 text-xs text-gray-500 hover:bg-gray-100 disabled:opacity-30 dark:hover:bg-gray-700"
|
||||
aria-label="Переместить вперёд"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import T from "./T";
|
||||
import HelpFAB from "./HelpFAB";
|
||||
import WorkspaceSwitcher from "./WorkspaceSwitcher";
|
||||
import NotificationBell from "./NotificationBell";
|
||||
|
||||
function GitHubIcon() {
|
||||
return (
|
||||
@@ -46,6 +48,9 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
<Link to="/dashboard" className="text-xl font-bold text-primary-700">
|
||||
VoIdea
|
||||
</Link>
|
||||
<div className="mx-2">
|
||||
<WorkspaceSwitcher />
|
||||
</div>
|
||||
<nav className="flex items-center gap-4 text-sm">
|
||||
<Link to="/dashboard" className="hover:text-primary-600">
|
||||
<T path="nav.ideas" />
|
||||
@@ -67,6 +72,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
<T path="nav.admin" />
|
||||
</Link>
|
||||
)}
|
||||
<NotificationBell />
|
||||
<span className="text-gray-400">{user?.display_name}</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNotificationStore } from "../stores/notifications";
|
||||
|
||||
export default function NotificationBell() {
|
||||
const { notifications, unreadCount, loading, load, markRead, markAllRead } =
|
||||
useNotificationStore();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const interval = setInterval(load, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="relative rounded-lg p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
aria-label={`Уведомления${unreadCount > 0 ? ` (${unreadCount} новых)` : ""}`}
|
||||
>
|
||||
<svg className="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -right-1 -top-1 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-80 rounded-xl border bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900">
|
||||
<div className="flex items-center justify-between border-b px-4 py-2 dark:border-gray-700">
|
||||
<h3 className="text-sm font-semibold">Уведомления</h3>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllRead}
|
||||
className="text-xs text-primary-600 hover:underline"
|
||||
>
|
||||
Прочитать все
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{loading && (
|
||||
<p className="p-4 text-center text-xs text-gray-400">Загрузка…</p>
|
||||
)}
|
||||
{!loading && notifications.length === 0 && (
|
||||
<p className="p-4 text-center text-xs text-gray-400">
|
||||
Нет новых уведомлений
|
||||
</p>
|
||||
)}
|
||||
{notifications.map((n) => (
|
||||
<div
|
||||
key={n.id}
|
||||
className={`border-b p-3 text-sm last:border-0 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800 ${
|
||||
!n.is_read ? "bg-primary-50/50 dark:bg-primary-950/30" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{n.title}</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500 line-clamp-2">
|
||||
{n.body}
|
||||
</p>
|
||||
{n.link && (
|
||||
<a
|
||||
href={n.link}
|
||||
className="mt-1 inline-block text-xs text-primary-600 hover:underline"
|
||||
>
|
||||
Открыть
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => markRead(n.id)}
|
||||
className="ml-2 text-xs text-gray-400 hover:text-gray-600"
|
||||
aria-label="Отметить прочитанным"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-gray-400">
|
||||
{new Date(n.created_at).toLocaleString("ru-RU")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getVotes, toggleVote, type VoteCount } from "../api/collaboration";
|
||||
|
||||
interface Props {
|
||||
ideaId: string;
|
||||
}
|
||||
|
||||
export default function VoteButtons({ ideaId }: Props) {
|
||||
const [votes, setVotes] = useState<VoteCount>({ up: 0, down: 0, user_vote: null });
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getVotes(ideaId).then(setVotes).catch(console.error);
|
||||
}, [ideaId]);
|
||||
|
||||
async function handleVote(vote: "up" | "down") {
|
||||
if (sending) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const updated = await toggleVote(ideaId, vote);
|
||||
setVotes(updated);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
const upActive = votes.user_vote === "up";
|
||||
const downActive = votes.user_vote === "down";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleVote("up")}
|
||||
disabled={sending}
|
||||
className={`flex items-center gap-1 rounded-lg px-2 py-1 text-sm transition ${
|
||||
upActive
|
||||
? "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300"
|
||||
: "hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
} disabled:opacity-50`}
|
||||
aria-label="Нравится"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5" />
|
||||
</svg>
|
||||
{votes.up}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleVote("down")}
|
||||
disabled={sending}
|
||||
className={`flex items-center gap-1 rounded-lg px-2 py-1 text-sm transition ${
|
||||
downActive
|
||||
? "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300"
|
||||
: "hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
} disabled:opacity-50`}
|
||||
aria-label="Не нравится"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5" />
|
||||
</svg>
|
||||
{votes.down}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useWorkspaceStore } from "../stores/workspace";
|
||||
import { createWorkspace } from "../api/workspaces";
|
||||
|
||||
export default function WorkspaceSwitcher() {
|
||||
const {
|
||||
workspaces, currentWorkspace, loading,
|
||||
loadWorkspaces, switchWorkspace,
|
||||
} = useWorkspaceStore();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
loadWorkspaces();
|
||||
}, []);
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newName.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
await createWorkspace({ name: newName.trim(), type: "team" });
|
||||
setNewName("");
|
||||
await loadWorkspaces();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
|
||||
>
|
||||
<svg className="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
<span className="max-w-[150px] truncate">{currentWorkspace?.name || "Workspace"}</span>
|
||||
<svg className={`h-4 w-4 transition ${open ? "rotate-180" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-64 rounded-xl border bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900">
|
||||
<div className="p-2">
|
||||
{workspaces.map((ws) => (
|
||||
<button
|
||||
key={ws.id}
|
||||
onClick={() => { switchWorkspace(ws.id); setOpen(false); }}
|
||||
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition ${
|
||||
currentWorkspace?.id === ws.id
|
||||
? "bg-primary-50 text-primary-700 dark:bg-primary-950 dark:text-primary-300"
|
||||
: "hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-gray-100 text-xs font-medium dark:bg-gray-800">
|
||||
{ws.type === "personal" ? "P" : "T"}
|
||||
</span>
|
||||
<div className="flex-1 truncate">
|
||||
<p className="font-medium">{ws.name}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{ws.type === "personal" ? "Личное" : "Команда"} · {ws.member_count} уч.
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t p-2 dark:border-gray-700">
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); handleCreate(); }}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Новое рабочее пространство"
|
||||
className="flex-1 rounded-lg border border-gray-300 bg-gray-50 px-2 py-1.5 text-xs dark:border-gray-600 dark:bg-gray-800"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating || !newName.trim()}
|
||||
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+138
-34
@@ -51,6 +51,7 @@ import {
|
||||
adminListBotCommands,
|
||||
adminToggleBotCommand,
|
||||
adminSyncBotCommands,
|
||||
adminRunAgent,
|
||||
type BotCommand,
|
||||
type PipelineConfig,
|
||||
type PipelineStageConfig,
|
||||
@@ -262,11 +263,28 @@ function UsersTab() {
|
||||
|
||||
function AgentsTab() {
|
||||
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
||||
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
|
||||
const [metaConfig, setMetaConfig] = useState("");
|
||||
const [runResult, setRunResult] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
adminListAgents().then(setAgents).catch(console.error);
|
||||
loadAgents();
|
||||
}, []);
|
||||
|
||||
async function loadAgents() {
|
||||
try {
|
||||
const list = await adminListAgents();
|
||||
setAgents(list);
|
||||
const meta = list.find(a => a.agent_name === "meta_agent");
|
||||
if (meta?.config) {
|
||||
try {
|
||||
setMetaConfig(JSON.stringify(JSON.parse(meta.config), null, 2));
|
||||
} catch { setMetaConfig(meta.config); }
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function toggleAgent(name: string, is_enabled: boolean) {
|
||||
try {
|
||||
const updated = await adminUpdateAgent(name, { is_enabled });
|
||||
@@ -274,41 +292,127 @@ function AgentsTab() {
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function saveMetaConfig() {
|
||||
try {
|
||||
JSON.parse(metaConfig);
|
||||
const updated = await adminUpdateAgent("meta_agent", { config: metaConfig });
|
||||
setAgents(prev => prev.map(a => a.agent_name === "meta_agent" ? updated : a));
|
||||
} catch (e) {
|
||||
alert("Invalid JSON: " + (e instanceof Error ? e.message : String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
async function runAgent(name: string) {
|
||||
setRunning(true);
|
||||
setRunResult(null);
|
||||
try {
|
||||
const result = await adminRunAgent(name);
|
||||
setRunResult(JSON.stringify(result, null, 2));
|
||||
} catch (e) {
|
||||
setRunResult("Error: " + (e instanceof Error ? e.message : String(e)));
|
||||
} finally { setRunning(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b dark:border-gray-700">
|
||||
<th className="px-4 py-3 font-medium">Агент</th>
|
||||
<th className="px-4 py-3 font-medium">Описание</th>
|
||||
<th className="px-4 py-3 font-medium">Версия</th>
|
||||
<th className="px-4 py-3 font-medium">Статус</th>
|
||||
<th className="px-4 py-3 font-medium">Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{agents.map((a) => (
|
||||
<tr key={a.agent_name} className="border-b last:border-0 dark:border-gray-700">
|
||||
<td className="px-4 py-3 font-mono text-xs">{a.agent_name}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{a.description || "—"}</td>
|
||||
<td className="px-4 py-3 text-xs">{a.version}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${a.is_enabled ? "bg-green-100 text-green-700" : "bg-red-100 text-red-700"}`}>
|
||||
{a.is_enabled ? "Вкл" : "Выкл"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => toggleAgent(a.agent_name, !a.is_enabled)}
|
||||
className={`text-xs hover:underline ${a.is_enabled ? "text-red-600" : "text-green-600"}`}
|
||||
>
|
||||
{a.is_enabled ? "Отключить" : "Включить"}
|
||||
</button>
|
||||
</td>
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-x-auto rounded-xl border bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b dark:border-gray-700">
|
||||
<th className="px-4 py-3 font-medium">Агент</th>
|
||||
<th className="px-4 py-3 font-medium">Описание</th>
|
||||
<th className="px-4 py-3 font-medium">Версия</th>
|
||||
<th className="px-4 py-3 font-medium">Статус</th>
|
||||
<th className="px-4 py-3 font-medium">Действие</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{agents.map((a) => (
|
||||
<tr key={a.agent_name} className="border-b last:border-0 dark:border-gray-700">
|
||||
<td className="px-4 py-3 font-mono text-xs">{a.agent_name}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{a.description || "—"}</td>
|
||||
<td className="px-4 py-3 text-xs">{a.version}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${a.is_enabled ? "bg-green-100 text-green-700" : "bg-red-100 text-red-700"}`}>
|
||||
{a.is_enabled ? "Вкл" : "Выкл"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => toggleAgent(a.agent_name, !a.is_enabled)}
|
||||
className={`text-xs hover:underline ${a.is_enabled ? "text-red-600" : "text-green-600"}`}
|
||||
>
|
||||
{a.is_enabled ? "Отключить" : "Включить"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedAgent(selectedAgent === a.agent_name ? null : a.agent_name)}
|
||||
className="text-xs text-primary-600 hover:underline"
|
||||
>
|
||||
{selectedAgent === a.agent_name ? "Скрыть" : "Настроить"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => runAgent(a.agent_name)}
|
||||
disabled={running}
|
||||
className="text-xs text-amber-600 hover:underline disabled:opacity-50"
|
||||
>
|
||||
Запустить
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Meta-agent config panel */}
|
||||
{selectedAgent === "meta_agent" && (
|
||||
<div className="rounded-xl border bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900">
|
||||
<h3 className="mb-3 font-semibold">Мета-агент: Конфигурация</h3>
|
||||
<p className="mb-3 text-xs text-gray-500">
|
||||
JSON-конфигурация: cron_schedule, weights (агент → вес), sources_count, auto_create
|
||||
</p>
|
||||
<textarea
|
||||
value={metaConfig}
|
||||
onChange={e => setMetaConfig(e.target.value)}
|
||||
rows={10}
|
||||
className="mb-3 w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 font-mono text-xs dark:border-gray-600 dark:bg-gray-800"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={saveMetaConfig}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
|
||||
>
|
||||
Сохранить конфиг
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setMetaConfig(JSON.stringify({
|
||||
cron_schedule: "0 9 * * 1,4",
|
||||
weights: {},
|
||||
sources_count: 10,
|
||||
auto_create: true,
|
||||
}, null, 2));
|
||||
}}
|
||||
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Run result */}
|
||||
{runResult && (
|
||||
<div className="rounded-xl border bg-gray-50 p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">Результат запуска</h3>
|
||||
<button onClick={() => setRunResult(null)} className="text-xs text-gray-500 hover:underline">Закрыть</button>
|
||||
</div>
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap text-xs">{runResult}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { listIdeas, type Idea } from "../api/ideas";
|
||||
import IdeaFunnelKanban from "../components/IdeaFunnelKanban";
|
||||
|
||||
export default function Dashboard() {
|
||||
const [ideas, setIdeas] = useState<Idea[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [view, setView] = useState<"list" | "kanban">("list");
|
||||
|
||||
useEffect(() => {
|
||||
listIdeas()
|
||||
@@ -25,12 +27,36 @@ export default function Dashboard() {
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Мои идеи</h1>
|
||||
<Link
|
||||
to="/create"
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
|
||||
>
|
||||
+ Новая идея
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex rounded-lg border dark:border-gray-600">
|
||||
<button
|
||||
onClick={() => setView("list")}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition ${
|
||||
view === "list"
|
||||
? "bg-primary-600 text-white"
|
||||
: "hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
Список
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("kanban")}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition ${
|
||||
view === "kanban"
|
||||
? "bg-primary-600 text-white"
|
||||
: "hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
Канбан
|
||||
</button>
|
||||
</div>
|
||||
<Link
|
||||
to="/create"
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
|
||||
>
|
||||
+ Новая идея
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ideas.length === 0 ? (
|
||||
@@ -40,6 +66,8 @@ export default function Dashboard() {
|
||||
Создайте первую идею, чтобы начать
|
||||
</p>
|
||||
</div>
|
||||
) : view === "kanban" ? (
|
||||
<IdeaFunnelKanban />
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{ideas.map((idea) => (
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
type AnalysisResult,
|
||||
type Idea,
|
||||
} from "../api/ideas";
|
||||
import VoteButtons from "../components/VoteButtons";
|
||||
import CommentThread from "../components/CommentThread";
|
||||
import ActivityFeed from "../components/ActivityFeed";
|
||||
import { addIdeaToCalendar } from "../api/calendar";
|
||||
|
||||
export default function IdeaView() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -97,7 +101,7 @@ export default function IdeaView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex gap-2">
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{(["md", "json", "html"] as const).map((fmt) => (
|
||||
<a
|
||||
key={fmt}
|
||||
@@ -108,6 +112,19 @@ export default function IdeaView() {
|
||||
Экспорт {fmt.toUpperCase()}
|
||||
</a>
|
||||
))}
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await addIdeaToCalendar(id!);
|
||||
alert("Событие создано в календаре");
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : "Ошибка");
|
||||
}
|
||||
}}
|
||||
className="rounded-lg border px-3 py-1.5 text-xs hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
|
||||
>
|
||||
+ Календарь
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 whitespace-pre-wrap text-gray-700 dark:text-gray-300">
|
||||
@@ -129,6 +146,19 @@ export default function IdeaView() {
|
||||
Статус: {idea.status} •{" "}
|
||||
{new Date(idea.created_at).toLocaleDateString("ru-RU")}
|
||||
</p>
|
||||
|
||||
<div className="mt-4">
|
||||
<VoteButtons ideaId={id!} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-2">
|
||||
<div className="rounded-xl border bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900">
|
||||
<CommentThread ideaId={id!} />
|
||||
</div>
|
||||
<div className="rounded-xl border bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900">
|
||||
<ActivityFeed ideaId={id!} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
|
||||
@@ -419,20 +419,85 @@ function ThemeTab() {
|
||||
}
|
||||
|
||||
function IntegrationsTab() {
|
||||
const [calStatus, setCalStatus] = useState<{ connected: boolean; provider: string | null } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
import("../api/calendar").then((m) =>
|
||||
m.getCalendarStatus().then(setCalStatus).catch(() => {}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="max-w-md space-y-4 rounded-xl border bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900">
|
||||
<h3 className="font-semibold">Интеграции</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Яндекс.Диск</p>
|
||||
<p className="text-xs text-gray-500">Автоматическое сохранение идей</p>
|
||||
<div className="max-w-md space-y-4">
|
||||
{/* Disk integrations */}
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900">
|
||||
<h3 className="mb-4 font-semibold">Диски</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Яндекс.Диск</p>
|
||||
<p className="text-xs text-gray-500">Автоматическое сохранение идей</p>
|
||||
</div>
|
||||
<button className="rounded-lg bg-primary-600 px-4 py-2 text-xs font-medium text-white hover:bg-primary-700">
|
||||
Подключить
|
||||
</button>
|
||||
</div>
|
||||
<button className="rounded-lg bg-primary-600 px-4 py-2 text-xs font-medium text-white hover:bg-primary-700">
|
||||
Подключить
|
||||
</button>
|
||||
<hr className="my-4 dark:border-gray-700" />
|
||||
<p className="text-xs text-gray-400">Google Drive и Apple CloudKit — по запросу.</p>
|
||||
</div>
|
||||
|
||||
{/* Calendar integration */}
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900">
|
||||
<h3 className="mb-4 font-semibold">Календарь</h3>
|
||||
<p className="mb-3 text-xs text-gray-500">
|
||||
Добавляйте идеи в календарь одним нажатием.
|
||||
</p>
|
||||
{calStatus?.connected ? (
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-2 text-sm">
|
||||
<span className="h-2 w-2 rounded-full bg-green-500" />
|
||||
Подключено: {calStatus.provider}
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const m = await import("../api/calendar");
|
||||
await m.disconnectCalendar();
|
||||
setCalStatus({ connected: false, provider: null });
|
||||
}}
|
||||
className="rounded-lg border border-red-300 px-4 py-2 text-sm text-red-600 hover:bg-red-50 dark:border-red-800 dark:hover:bg-red-950"
|
||||
>
|
||||
Отключить
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const m = await import("../api/calendar");
|
||||
try {
|
||||
const { providers } = await m.listCalendarProviders();
|
||||
alert(
|
||||
`Доступные провайдеры: ${providers.join(", ")}.\n\nПодключите календарь через OAuth в настройках профиля.`,
|
||||
);
|
||||
} catch (e) { console.error(e); }
|
||||
}}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-xs font-medium text-white hover:bg-primary-700"
|
||||
>
|
||||
Подключить Google
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
alert("Подключение Yandex Календаря — через OAuth в настройках профиля.");
|
||||
}}
|
||||
className="ml-2 rounded-lg border px-4 py-2 text-xs font-medium hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
|
||||
>
|
||||
Подключить Яндекс
|
||||
</button>
|
||||
<p className="mt-2 text-xs text-gray-400">
|
||||
Apple Calendar (CalDAV) — по запросу.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<hr className="dark:border-gray-700" />
|
||||
<p className="text-xs text-gray-400">Google Drive и Apple CloudKit — по запросу.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
listNotifications,
|
||||
getUnreadCount,
|
||||
markNotificationRead,
|
||||
markAllNotificationsRead,
|
||||
type Notification,
|
||||
} from "../api/collaboration";
|
||||
|
||||
interface NotificationState {
|
||||
notifications: Notification[];
|
||||
unreadCount: number;
|
||||
loading: boolean;
|
||||
load: () => Promise<void>;
|
||||
markRead: (id: string) => Promise<void>;
|
||||
markAllRead: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useNotificationStore = create<NotificationState>((set, get) => ({
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
loading: true,
|
||||
|
||||
load: async () => {
|
||||
try {
|
||||
const [notifications, { count }] = await Promise.all([
|
||||
listNotifications(true),
|
||||
getUnreadCount(),
|
||||
]);
|
||||
set({ notifications, unreadCount: count, loading: false });
|
||||
} catch {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
markRead: async (id: string) => {
|
||||
try {
|
||||
await markNotificationRead(id);
|
||||
const notifications = get().notifications.filter((n) => n.id !== id);
|
||||
set({
|
||||
notifications,
|
||||
unreadCount: Math.max(0, get().unreadCount - 1),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
|
||||
markAllRead: async () => {
|
||||
try {
|
||||
await markAllNotificationsRead();
|
||||
set({ notifications: [], unreadCount: 0 });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,40 @@
|
||||
import { create } from "zustand";
|
||||
import { listWorkspaces, type Workspace } from "../api/workspaces";
|
||||
|
||||
interface WorkspaceState {
|
||||
workspaces: Workspace[];
|
||||
currentWorkspace: Workspace | null;
|
||||
loading: boolean;
|
||||
loadWorkspaces: () => Promise<void>;
|
||||
switchWorkspace: (id: string) => void;
|
||||
setCurrentWorkspace: (ws: Workspace | null) => void;
|
||||
}
|
||||
|
||||
export const useWorkspaceStore = create<WorkspaceState>((set, get) => ({
|
||||
workspaces: [],
|
||||
currentWorkspace: null,
|
||||
loading: true,
|
||||
|
||||
loadWorkspaces: async () => {
|
||||
try {
|
||||
const workspaces = await listWorkspaces();
|
||||
const current = get().currentWorkspace;
|
||||
// Keep existing selection if still valid, otherwise pick first
|
||||
const stillExists = current && workspaces.find(w => w.id === current.id);
|
||||
set({
|
||||
workspaces,
|
||||
currentWorkspace: stillExists || workspaces[0] || null,
|
||||
loading: false,
|
||||
});
|
||||
} catch {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
switchWorkspace: (id: string) => {
|
||||
const ws = get().workspaces.find(w => w.id === id);
|
||||
if (ws) set({ currentWorkspace: ws });
|
||||
},
|
||||
|
||||
setCurrentWorkspace: (ws) => set({ currentWorkspace: ws }),
|
||||
}));
|
||||
Reference in New Issue
Block a user