docs/web-client-vite/src/components/CreateTaskModal.tsx

245 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from "react";
import type { Task, Member, Label } from "@/lib/api";
import { createTask, getMembers, getLabels } from "@/lib/api";
const STATUSES = [
{ key: "backlog", label: "Backlog", color: "#737373" },
{ key: "todo", label: "TODO", color: "#3b82f6" },
{ key: "in_progress", label: "In Progress", color: "#f59e0b" },
{ key: "in_review", label: "Review", color: "#a855f7" },
{ key: "done", label: "Done", color: "#22c55e" },
];
const PRIORITIES = [
{ key: "low", label: "Low", color: "#737373" },
{ key: "medium", label: "Medium", color: "#3b82f6" },
{ key: "high", label: "High", color: "#f59e0b" },
{ key: "critical", label: "Critical", color: "#ef4444" },
];
const TYPES = [
{ key: "task", label: "Задача" },
{ key: "bug", label: "Баг" },
{ key: "feature", label: "Фича" },
];
interface Props {
projectId: string;
initialStatus: string;
parentId?: string;
parentKey?: string;
onClose: () => void;
onCreated: (task: Task) => void;
}
export default function CreateTaskModal({ projectId, initialStatus, parentId, parentKey, onClose, onCreated }: Props) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState(initialStatus || "backlog");
const [priority, setPriority] = useState("medium");
const [type, setType] = useState("task");
const [assigneeId, setAssigneeId] = useState("");
const [reviewerId, setReviewerId] = useState("");
const [selectedLabels, setSelectedLabels] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [members, setMembers] = useState<Member[]>([]);
const [labels, setLabels] = useState<Label[]>([]);
useEffect(() => {
getMembers().then(setMembers).catch(() => {});
getLabels().then(setLabels).catch(() => {});
}, []);
const handleSubmit = async () => {
if (!title.trim()) return;
setSaving(true);
setError("");
try {
const task = await createTask(projectId, {
title: title.trim(),
description: description.trim() || undefined,
status,
priority,
type,
parent_id: parentId,
assignee_id: assigneeId || undefined,
reviewer_id: reviewerId || undefined,
labels: selectedLabels,
});
onCreated(task);
onClose();
} catch (e: any) {
setError(e.message || "Ошибка");
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" onClick={onClose}>
<div
className="bg-[var(--card)] border border-[var(--border)] rounded-xl w-full max-w-lg p-6 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-bold mb-4">
{parentKey ? `Подзадача для ${parentKey}` : "Новая задача"}
</h3>
<div className="space-y-4">
{/* Title */}
<div>
<label className="text-xs text-[var(--muted)] mb-1 block">Название</label>
<input
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
placeholder="Название задачи..."
className="w-full bg-[var(--bg)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--accent)]"
/>
</div>
{/* Description */}
<div>
<label className="text-xs text-[var(--muted)] mb-1 block">Описание</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Описание (опционально)..."
rows={3}
className="w-full bg-[var(--bg)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--accent)] resize-y"
/>
</div>
{/* Type */}
<div>
<label className="text-xs text-[var(--muted)] mb-1 block">Тип</label>
<div className="flex gap-1">
{TYPES.map((t) => (
<button
key={t.key}
onClick={() => setType(t.key)}
className={`px-2 py-1 rounded text-xs transition-colors
${type === t.key ? "bg-[var(--accent)] text-white" : "bg-white/5 text-[var(--muted)] hover:bg-white/10"}`}
>
{t.label}
</button>
))}
</div>
</div>
{/* Status + Priority row */}
<div className="flex gap-4">
<div className="flex-1">
<label className="text-xs text-[var(--muted)] mb-1 block">Статус</label>
<div className="flex flex-wrap gap-1">
{STATUSES.map((s) => (
<button
key={s.key}
onClick={() => setStatus(s.key)}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors
${status === s.key ? "bg-white/10 text-[var(--fg)]" : "text-[var(--muted)] hover:bg-white/5"}`}
>
<span className="w-2 h-2 rounded-full" style={{ background: s.color }} />
{s.label}
</button>
))}
</div>
</div>
</div>
<div>
<label className="text-xs text-[var(--muted)] mb-1 block">Приоритет</label>
<div className="flex gap-1">
{PRIORITIES.map((p) => (
<button
key={p.key}
onClick={() => setPriority(p.key)}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors
${priority === p.key ? "bg-white/10 text-[var(--fg)]" : "text-[var(--muted)] hover:bg-white/5"}`}
>
<span className="w-2 h-2 rounded-full" style={{ background: p.color }} />
{p.label}
</button>
))}
</div>
</div>
{/* Assignee + Reviewer */}
<div className="flex gap-4">
<div className="flex-1">
<label className="text-xs text-[var(--muted)] mb-1 block">Исполнитель</label>
<select
value={assigneeId}
onChange={(e) => setAssigneeId(e.target.value)}
className="w-full px-2 py-1.5 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-sm outline-none"
>
<option value="">Не назначен</option>
{members.map((m) => (
<option key={m.id} value={m.id}>{m.name} ({m.slug})</option>
))}
</select>
</div>
<div className="flex-1">
<label className="text-xs text-[var(--muted)] mb-1 block">Ревьюер</label>
<select
value={reviewerId}
onChange={(e) => setReviewerId(e.target.value)}
className="w-full px-2 py-1.5 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-sm outline-none"
>
<option value="">Не назначен</option>
{members.map((m) => (
<option key={m.id} value={m.id}>{m.name} ({m.slug})</option>
))}
</select>
</div>
</div>
{/* Labels */}
{labels.length > 0 && (
<div>
<label className="text-xs text-[var(--muted)] mb-1 block">Лейблы</label>
<div className="flex flex-wrap gap-1">
{labels.map((l) => (
<button
key={l.id}
onClick={() => {
setSelectedLabels(prev =>
prev.includes(l.name) ? prev.filter(n => n !== l.name) : [...prev, l.name]
);
}}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors border ${
selectedLabels.includes(l.name)
? "border-[var(--accent)] bg-[var(--accent)]/20"
: "border-[var(--border)] bg-white/5 text-[var(--muted)] hover:bg-white/10"
}`}
>
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: l.color }} />
{l.name}
</button>
))}
</div>
</div>
)}
{error && <div className="text-xs text-red-400">{error}</div>}
<div className="flex gap-2 justify-end pt-2">
<button onClick={onClose} className="px-4 py-2 text-sm text-[var(--muted)]">Отмена</button>
<button
onClick={handleSubmit}
disabled={!title.trim() || saving}
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm disabled:opacity-50"
>
{saving ? "..." : "Создать"}
</button>
</div>
</div>
</div>
</div>
);
}