245 lines
9.3 KiB
TypeScript
245 lines
9.3 KiB
TypeScript
|
||
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>
|
||
);
|
||
}
|