- Config: try ENV_FILE, .env, ../.env for loading; trim trailing slash from BaseURL - Log BASE_URL at server startup for verification - .env.example: document BASE_URL - Tasks, projects, tags, migrations and related API/handlers Made-with: Cursor
272 lines
8.6 KiB
TypeScript
272 lines
8.6 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { format } from "date-fns";
|
|
import type { Task, Project, Tag } from "../types";
|
|
import { api } from "../api";
|
|
import Modal from "./Modal";
|
|
|
|
interface TaskModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onSaved: () => void;
|
|
task?: Task | null;
|
|
projects: Project[];
|
|
tags: Tag[];
|
|
}
|
|
|
|
const STATUS_OPTIONS = ["todo", "in_progress", "done"] as const;
|
|
const PRIORITY_OPTIONS = ["low", "medium", "high", "critical"] as const;
|
|
|
|
export default function TaskModal({
|
|
open,
|
|
onClose,
|
|
onSaved,
|
|
task,
|
|
projects,
|
|
tags,
|
|
}: TaskModalProps) {
|
|
const isEdit = !!task;
|
|
const [title, setTitle] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [status, setStatus] = useState<Task["status"]>("todo");
|
|
const [priority, setPriority] = useState<Task["priority"]>("medium");
|
|
const [dueDate, setDueDate] = useState("");
|
|
const [projectId, setProjectId] = useState("");
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [showDelete, setShowDelete] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setError("");
|
|
setShowDelete(false);
|
|
if (task) {
|
|
setTitle(task.title);
|
|
setDescription(task.description || "");
|
|
setStatus(task.status);
|
|
setPriority(task.priority);
|
|
setDueDate(task.due_date ? format(new Date(task.due_date), "yyyy-MM-dd") : "");
|
|
setProjectId(task.project_id || "");
|
|
} else {
|
|
setTitle("");
|
|
setDescription("");
|
|
setStatus("todo");
|
|
setPriority("medium");
|
|
setDueDate("");
|
|
setProjectId("");
|
|
}
|
|
}, [open, task]);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!title.trim()) return;
|
|
setSaving(true);
|
|
setError("");
|
|
try {
|
|
if (isEdit) {
|
|
await api.updateTask(task!.id, {
|
|
title: title.trim(),
|
|
description: description || undefined,
|
|
status,
|
|
priority,
|
|
due_date: dueDate || undefined,
|
|
project_id: projectId || undefined,
|
|
});
|
|
} else {
|
|
await api.createTask({
|
|
title: title.trim(),
|
|
description: description || undefined,
|
|
status,
|
|
priority,
|
|
due_date: dueDate || undefined,
|
|
project_id: projectId || undefined,
|
|
});
|
|
}
|
|
onSaved();
|
|
onClose();
|
|
} catch (err: unknown) {
|
|
setError((err as { error?: string })?.error || "Failed to save task");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!task) return;
|
|
setSaving(true);
|
|
try {
|
|
await api.deleteTask(task.id);
|
|
onSaved();
|
|
onClose();
|
|
} catch {
|
|
setError("Failed to delete task");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
open={open}
|
|
onClose={onClose}
|
|
title={isEdit ? "Edit Task" : "New Task"}
|
|
wide
|
|
>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
{error && (
|
|
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Title
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
|
placeholder="Task title"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Description
|
|
</label>
|
|
<textarea
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
rows={3}
|
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
|
placeholder="Markdown supported"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Status
|
|
</label>
|
|
<select
|
|
value={status}
|
|
onChange={(e) => setStatus(e.target.value as Task["status"])}
|
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
|
>
|
|
{STATUS_OPTIONS.map((s) => (
|
|
<option key={s} value={s}>
|
|
{s.replace("_", " ")}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Priority
|
|
</label>
|
|
<select
|
|
value={priority}
|
|
onChange={(e) => setPriority(e.target.value as Task["priority"])}
|
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
|
>
|
|
{PRIORITY_OPTIONS.map((p) => (
|
|
<option key={p} value={p}>
|
|
{p}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Due Date
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={dueDate}
|
|
onChange={(e) => setDueDate(e.target.value)}
|
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Project
|
|
</label>
|
|
<select
|
|
value={projectId}
|
|
onChange={(e) => setProjectId(e.target.value)}
|
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
|
>
|
|
<option value="">None</option>
|
|
{projects.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-between pt-4">
|
|
<div>
|
|
{isEdit && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowDelete(true)}
|
|
className="text-red-600 dark:text-red-400 hover:underline text-sm"
|
|
>
|
|
Delete task
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={saving || !title.trim()}
|
|
className="px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
|
>
|
|
{saving ? "Saving..." : isEdit ? "Save" : "Create"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{showDelete && (
|
|
<div className="mt-4 p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
|
<p className="text-red-700 dark:text-red-300 text-sm mb-2">
|
|
Delete this task? This cannot be undone.
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowDelete(false)}
|
|
className="px-3 py-1 rounded bg-gray-200 dark:bg-gray-600 text-sm"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleDelete}
|
|
disabled={saving}
|
|
className="px-3 py-1 rounded bg-red-600 text-white text-sm hover:bg-red-700 disabled:opacity-50"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|