413 lines
11 KiB
TypeScript
413 lines
11 KiB
TypeScript
import type { Route } from "./+types/projectPage";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ArrowLeft, Plus, SquarePen } from "lucide-react";
|
|
import { Link, useRevalidator } from "react-router";
|
|
import { db } from "@lib/db";
|
|
import { projects, columns, tasks, type Task, type Column } from "@lib/db/schema";
|
|
import { eq, asc, desc } from "drizzle-orm";
|
|
import Layout from "@/components/layout";
|
|
import { TaskDialog } from "@/components/task/TaskDialog";
|
|
import { ColumnDialog } from "@/components/column/ColumnDialog";
|
|
import { ProjectDialog } from "@/components/project/ProjectDialog";
|
|
import { useEffect, useState } from "react";
|
|
import { projectPageAction } from "./projectPageAction";
|
|
import { getCurrentUser } from "@lib/auth-utils";
|
|
import { canUserEditProject, canUserViewProject } from "@lib/auth";
|
|
|
|
export function meta({ loaderData }: Route.MetaArgs) {
|
|
return [
|
|
{ title: `${loaderData.project.name} - FramSpor` },
|
|
{ name: "description", content: `Manage tasks for ${loaderData.project.name}` }
|
|
];
|
|
}
|
|
|
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
|
const user = await getCurrentUser(request);
|
|
|
|
const projectId = params.id;
|
|
|
|
// Check if user can view this project
|
|
const canView = await canUserViewProject(user?.id || "", projectId);
|
|
if (!canView) {
|
|
throw new Response("You do not have permission to view this project", { status: 403 });
|
|
}
|
|
|
|
// Fetch the project
|
|
const projectResult = await db
|
|
.select()
|
|
.from(projects)
|
|
.where(eq(projects.id, projectId))
|
|
.limit(1);
|
|
|
|
if (projectResult.length === 0) {
|
|
throw new Response("Project not found", { status: 404 });
|
|
}
|
|
|
|
const project = projectResult[0];
|
|
|
|
// Check if user can edit this project
|
|
const canEdit = await canUserEditProject(user?.id || "", projectId);
|
|
|
|
// Fetch columns for this project
|
|
const projectColumns = await db
|
|
.select()
|
|
.from(columns)
|
|
.where(eq(columns.projectId, projectId))
|
|
.orderBy(asc(columns.position));
|
|
|
|
// Fetch tasks for each column
|
|
const columnsWithTasks = await Promise.all(
|
|
projectColumns.map(async (column) => {
|
|
const columnTasks = await db
|
|
.select()
|
|
.from(tasks)
|
|
.where(eq(tasks.columnId, column.id))
|
|
.orderBy(desc(tasks.priority), asc(tasks.dueDate));
|
|
|
|
return {
|
|
...column,
|
|
tasks: columnTasks.sort((a, b) => {
|
|
if (a.dueDate === null && b.dueDate === null) return 0;
|
|
if (a.dueDate === null) return 1;
|
|
if (b.dueDate === null) return -1;
|
|
return a.dueDate.getTime() - b.dueDate.getTime();
|
|
})
|
|
};
|
|
})
|
|
);
|
|
|
|
return {
|
|
project,
|
|
columns: columnsWithTasks,
|
|
user,
|
|
canEdit
|
|
};
|
|
}
|
|
|
|
interface ColumnWithTasks extends Column {
|
|
tasks: Task[];
|
|
}
|
|
|
|
export const action = projectPageAction;
|
|
|
|
export default function ProjectBoard({ loaderData }: Route.ComponentProps) {
|
|
const { project, columns: initialColumns, user, canEdit } = loaderData;
|
|
const [columns, setColumns] = useState(initialColumns);
|
|
const [isTaskDialogOpen, setIsTaskDialogOpen] = useState(false);
|
|
const [isColumnDialogOpen, setIsColumnDialogOpen] = useState(false);
|
|
const [isProjectDialogOpen, setIsProjectDialogOpen] = useState(false);
|
|
const [selectedColumnId, setSelectedColumnId] = useState<string | null>(null);
|
|
const [editingTask, setEditingTask] = useState<any>(null);
|
|
const [editingColumn, setEditingColumn] = useState<any>(null);
|
|
const revalidator = useRevalidator();
|
|
|
|
useEffect(() => {
|
|
setColumns(loaderData.columns);
|
|
}, [loaderData, loaderData.columns, loaderData.project]);
|
|
|
|
const handleAddTask = (columnId?: string) => {
|
|
setSelectedColumnId(columnId || null);
|
|
setEditingTask(null);
|
|
setIsTaskDialogOpen(true);
|
|
};
|
|
|
|
const handleEditTask = (task: Task) => {
|
|
setEditingTask(task);
|
|
setSelectedColumnId(null);
|
|
setIsTaskDialogOpen(true);
|
|
};
|
|
|
|
const handleAddColumn = () => {
|
|
setEditingColumn(null);
|
|
setIsColumnDialogOpen(true);
|
|
};
|
|
|
|
const handleEditColumn = (column: ColumnWithTasks) => {
|
|
setEditingColumn(column);
|
|
setIsColumnDialogOpen(true);
|
|
};
|
|
|
|
const handleEditProject = () => {
|
|
setIsProjectDialogOpen(true);
|
|
};
|
|
|
|
const handleColumnSubmit = async (data: { name: string }) => {
|
|
const formData = new FormData();
|
|
|
|
if (editingColumn) {
|
|
formData.append("intent", "updateColumn");
|
|
formData.append("columnId", editingColumn.id);
|
|
} else {
|
|
formData.append("intent", "createColumn");
|
|
}
|
|
|
|
formData.append("name", data.name);
|
|
|
|
const response = await fetch(`/project/${project.id}`, {
|
|
method: "POST",
|
|
body: formData
|
|
});
|
|
|
|
revalidator.revalidate();
|
|
};
|
|
|
|
const handleDeleteColumn = async (columnId: string) => {
|
|
const formData = new FormData();
|
|
formData.append("intent", "deleteColumn");
|
|
formData.append("columnId", columnId);
|
|
|
|
const response = await fetch(`/project/${project.id}`, {
|
|
method: "POST",
|
|
body: formData
|
|
});
|
|
|
|
if (response.ok) {
|
|
// Refresh the data
|
|
revalidator.revalidate();
|
|
}
|
|
};
|
|
|
|
const handleProjectSubmit = async (data: {
|
|
name: string;
|
|
description: string;
|
|
isPublic: boolean;
|
|
}) => {
|
|
const formData = new FormData();
|
|
formData.append("intent", "updateProject");
|
|
formData.append("name", data.name);
|
|
formData.append("description", data.description);
|
|
formData.append("isPublic", data.isPublic ? "true" : "false");
|
|
|
|
const response = await fetch(`/project/${project.id}`, {
|
|
method: "POST",
|
|
body: formData
|
|
});
|
|
|
|
if (response.ok) {
|
|
// Refresh the data
|
|
revalidator.revalidate();
|
|
}
|
|
};
|
|
|
|
const handleDeleteProject = async () => {
|
|
const formData = new FormData();
|
|
formData.append("intent", "deleteProject");
|
|
|
|
const response = await fetch(`/project/${project.id}`, {
|
|
method: "POST",
|
|
body: formData
|
|
});
|
|
|
|
if (response.ok) {
|
|
// Redirect to home page
|
|
window.location.href = "/";
|
|
}
|
|
};
|
|
|
|
const handleTaskSubmit = async (data: {
|
|
title: string;
|
|
description: string;
|
|
columnId: string;
|
|
priority: "low" | "medium" | "high";
|
|
dueDate?: Date;
|
|
}) => {
|
|
const formData = new FormData();
|
|
|
|
if (editingTask) {
|
|
formData.append("intent", "updateTask");
|
|
formData.append("taskId", editingTask.id);
|
|
} else {
|
|
formData.append("intent", "createTask");
|
|
}
|
|
|
|
formData.append("title", data.title);
|
|
formData.append("description", data.description);
|
|
formData.append("columnId", selectedColumnId || data.columnId);
|
|
formData.append("priority", data.priority);
|
|
if (data.dueDate) {
|
|
formData.append("dueDate", data.dueDate.toISOString());
|
|
}
|
|
|
|
const response = await fetch(`/project/${project.id}`, {
|
|
method: "POST",
|
|
body: formData
|
|
});
|
|
|
|
revalidator.revalidate();
|
|
};
|
|
|
|
const handleTaskDelete = async () => {
|
|
const formData = new FormData();
|
|
formData.append("intent", "deleteTask");
|
|
formData.append("taskId", editingTask.id);
|
|
|
|
const response = await fetch(`/project/${project.id}`, {
|
|
method: "POST",
|
|
body: formData
|
|
});
|
|
|
|
revalidator.revalidate();
|
|
};
|
|
|
|
return (
|
|
<Layout>
|
|
<TaskDialog
|
|
open={isTaskDialogOpen}
|
|
onOpenChange={setIsTaskDialogOpen}
|
|
projectId={project.id}
|
|
columns={columns}
|
|
onSubmit={handleTaskSubmit}
|
|
onDelete={handleTaskDelete}
|
|
initialData={
|
|
editingTask || (selectedColumnId ? { columnId: selectedColumnId } : undefined)
|
|
}
|
|
isEditing={!!editingTask}
|
|
canEdit={canEdit}
|
|
/>
|
|
<ColumnDialog
|
|
open={isColumnDialogOpen}
|
|
onOpenChange={setIsColumnDialogOpen}
|
|
onSubmit={handleColumnSubmit}
|
|
onDelete={editingColumn ? () => handleDeleteColumn(editingColumn.id) : undefined}
|
|
initialData={editingColumn}
|
|
isEditing={!!editingColumn}
|
|
columns={columns.length}
|
|
/>
|
|
<ProjectDialog
|
|
open={isProjectDialogOpen}
|
|
onOpenChange={setIsProjectDialogOpen}
|
|
onSubmit={handleProjectSubmit}
|
|
onDelete={handleDeleteProject}
|
|
initialData={{
|
|
name: project.name,
|
|
description: project.description || ""
|
|
}}
|
|
isEditing={true}
|
|
/>
|
|
{/* Header */}
|
|
<div className="max-md:flex-col max-md:gap-4 flex justify-between mb-8">
|
|
<div className="flex items-start gap-4">
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight">{project.name}</h1>
|
|
<p className="text-muted-foreground mt-2">
|
|
{project.description || "No description."}
|
|
{!canEdit && <span className="ml-2 text-orange-600">(View Only)</span>}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="max-sm:flex-col flex gap-2">
|
|
{user && (
|
|
<Button variant="outline" asChild>
|
|
<Link to="/">
|
|
<ArrowLeft className="size-4.5 mr-1" />
|
|
Back to Projects
|
|
</Link>
|
|
</Button>
|
|
)}
|
|
{canEdit && (
|
|
<>
|
|
<Link to={`/project/${project.id}/settings`}>
|
|
<Button variant="outline" onClick={handleEditProject}>
|
|
<SquarePen className="size-4 mr-1" />
|
|
Edit Project
|
|
</Button>
|
|
</Link>
|
|
|
|
<Button onClick={() => handleAddTask()}>
|
|
<Plus className="size-4.5 mr-1" />
|
|
Add Task
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Kanban Board */}
|
|
<div className="max-lg:flex-col flex gap-4 overflow-x-auto pb-6">
|
|
{columns.map((column) => (
|
|
<div
|
|
key={column.id}
|
|
className="min-w-80 lg:w-100 xl:w-100 2xl:w-110 flex-shrink-0"
|
|
>
|
|
<div className="border rounded-lg p-4 px-5 bg-card">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="font-semibold text-lg">{column.name}</h3>
|
|
{canEdit && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handleEditColumn(column)}
|
|
>
|
|
<SquarePen className="w-4 h-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="space-y-3">
|
|
{column.tasks.map((task) => (
|
|
<div
|
|
key={task.id}
|
|
className="border rounded-md py-4 px-4.5 bg-background gap-3
|
|
hover:shadow-sm transition-shadow cursor-pointer flex flex-col"
|
|
onClick={() => handleEditTask(task)}
|
|
>
|
|
<h4 className="font-medium text-sm">{task.title}</h4>
|
|
{task.description && (
|
|
<p className="text-xs text-muted-foreground line-clamp-3 overflow-ellipsis">
|
|
<pre>{task.description}</pre>
|
|
</p>
|
|
)}
|
|
<div className="flex items-center justify-between text-xs">
|
|
{task.priority && (
|
|
<span
|
|
className={`px-2 py-1 text-xs ${
|
|
task.priority === "high"
|
|
? "bg-red-100 text-red-800"
|
|
: task.priority === "medium"
|
|
? "bg-yellow-100 text-yellow-800"
|
|
: "bg-blue-200 text-blue-800"
|
|
}`}
|
|
>
|
|
{task.priority}
|
|
</span>
|
|
)}
|
|
{task.dueDate && (
|
|
<span className="text-muted-foreground">
|
|
{new Date(task.dueDate).toLocaleDateString()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{canEdit && (
|
|
<Button
|
|
variant="ghost"
|
|
className="w-full justify-start text-muted-foreground"
|
|
onClick={() => handleAddTask(column.id)}
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Add Task
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{canEdit && (
|
|
<div className="w-80 flex-shrink-0">
|
|
<div className="border-dashed border-2 rounded-lg p-6 flex items-center justify-center h-32">
|
|
<Button
|
|
variant="ghost"
|
|
className="text-muted-foreground"
|
|
onClick={handleAddColumn}
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Add Column
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|