feat(web): add projects page with CRUD operations
Some checks failed
ci/woodpecker/push/web Pipeline failed
Some checks failed
ci/woodpecker/push/web Pipeline failed
Wire up /projects route with a card-based project list, create dialog, and delete confirmation. Adds projects API client with full CRUD using the existing apiGet/apiPost/apiPatch/apiDelete helpers. - Projects API client with typed DTOs matching backend schema - Card grid layout (2-col md+, 1-col mobile) with hover states - Create project dialog with name/description fields - Delete confirmation dialog with danger styling - Loading (MosaicSpinner), empty, and error states - Status badges for PLANNING/ACTIVE/PAUSED/COMPLETED/ARCHIVED - All design tokens from globals.css, no hardcoded colors Refs #468
This commit is contained in:
809
apps/web/src/app/(authenticated)/projects/page.tsx
Normal file
809
apps/web/src/app/(authenticated)/projects/page.tsx
Normal file
@@ -0,0 +1,809 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { ReactElement, SyntheticEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { fetchProjects, createProject, deleteProject, ProjectStatus } from "@/lib/api/projects";
|
||||
import type { Project, CreateProjectDto } from "@/lib/api/projects";
|
||||
import { useWorkspaceId } from "@/lib/hooks";
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Status badge helpers
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
interface StatusStyle {
|
||||
label: string;
|
||||
bg: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function getStatusStyle(status: ProjectStatus): StatusStyle {
|
||||
switch (status) {
|
||||
case ProjectStatus.PLANNING:
|
||||
return { label: "Planning", bg: "rgba(47,128,255,0.15)", color: "var(--primary)" };
|
||||
case ProjectStatus.ACTIVE:
|
||||
return { label: "Active", bg: "rgba(20,184,166,0.15)", color: "var(--success)" };
|
||||
case ProjectStatus.PAUSED:
|
||||
return { label: "Paused", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" };
|
||||
case ProjectStatus.COMPLETED:
|
||||
return { label: "Completed", bg: "rgba(139,92,246,0.15)", color: "var(--purple)" };
|
||||
case ProjectStatus.ARCHIVED:
|
||||
return { label: "Archived", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
||||
default:
|
||||
return { label: String(status), bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
ProjectCard
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
interface ProjectCardProps {
|
||||
project: Project;
|
||||
onDelete: (id: string) => void;
|
||||
onClick: (id: string) => void;
|
||||
}
|
||||
|
||||
function ProjectCard({ project, onDelete, onClick }: ProjectCardProps): ReactElement {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const status = getStatusStyle(project.status);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
onClick(project.id);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick(project.id);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHovered(false);
|
||||
}}
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: `1px solid ${hovered ? "var(--primary)" : "var(--border)"}`,
|
||||
borderRadius: "var(--r-lg)",
|
||||
padding: 20,
|
||||
cursor: "pointer",
|
||||
transition: "border-color 0.2s var(--ease)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* Header row: name + delete button */}
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h3
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--text)",
|
||||
fontSize: "1rem",
|
||||
margin: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{project.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
aria-label={`Delete project ${project.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(project.id);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: 4,
|
||||
borderRadius: "var(--r-sm)",
|
||||
color: "var(--muted)",
|
||||
transition: "color 0.15s, background 0.15s",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
marginLeft: 8,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = "var(--danger)";
|
||||
e.currentTarget.style.background = "rgba(229,72,77,0.1)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = "var(--muted)";
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{project.description ? (
|
||||
<p
|
||||
style={{
|
||||
color: "var(--muted)",
|
||||
fontSize: "0.85rem",
|
||||
margin: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{project.description}
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ color: "var(--muted)", fontSize: "0.85rem", margin: 0, fontStyle: "italic" }}>
|
||||
No description
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Footer: status + timestamps */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginTop: "auto",
|
||||
}}
|
||||
>
|
||||
{/* Status badge */}
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "2px 10px",
|
||||
borderRadius: "var(--r)",
|
||||
background: status.bg,
|
||||
color: status.color,
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
|
||||
{/* Timestamps */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--muted)",
|
||||
fontFamily: "var(--mono)",
|
||||
}}
|
||||
>
|
||||
{formatTimestamp(project.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Create Project Dialog
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
interface CreateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: CreateProjectDto) => Promise<void>;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
function CreateProjectDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}: CreateDialogProps): ReactElement {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
function resetForm(): void {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setFormError(null);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SyntheticEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
setFormError("Project name is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload: CreateProjectDto = { name: trimmedName };
|
||||
const trimmedDesc = description.trim();
|
||||
if (trimmedDesc) {
|
||||
payload.description = trimmedDesc;
|
||||
}
|
||||
await onSubmit(payload);
|
||||
resetForm();
|
||||
} catch (err: unknown) {
|
||||
setFormError(err instanceof Error ? err.message : "Failed to create project.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) resetForm();
|
||||
onOpenChange(isOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<div
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
border: "1px solid var(--border)",
|
||||
padding: 24,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<span style={{ color: "var(--text)" }}>New Project</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span style={{ color: "var(--muted)" }}>
|
||||
Give your project a name and optional description.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
void handleSubmit(e);
|
||||
}}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
{/* Name */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label
|
||||
htmlFor="project-name"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: 6,
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
color: "var(--text-2)",
|
||||
}}
|
||||
>
|
||||
Name <span style={{ color: "var(--danger)" }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="project-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
}}
|
||||
placeholder="e.g. Website Redesign"
|
||||
maxLength={255}
|
||||
autoFocus
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r)",
|
||||
color: "var(--text)",
|
||||
fontSize: "0.9rem",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label
|
||||
htmlFor="project-description"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: 6,
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
color: "var(--text-2)",
|
||||
}}
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="project-description"
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value);
|
||||
}}
|
||||
placeholder="A brief summary of this project..."
|
||||
rows={3}
|
||||
maxLength={10000}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r)",
|
||||
color: "var(--text)",
|
||||
fontSize: "0.9rem",
|
||||
outline: "none",
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Form error */}
|
||||
{formError !== null && (
|
||||
<p style={{ color: "var(--danger)", fontSize: "0.85rem", margin: "0 0 12px" }}>
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: "transparent",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r)",
|
||||
color: "var(--text-2)",
|
||||
fontSize: "0.85rem",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !name.trim()}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: "var(--primary)",
|
||||
border: "none",
|
||||
borderRadius: "var(--r)",
|
||||
color: "#fff",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
cursor: isSubmitting || !name.trim() ? "not-allowed" : "pointer",
|
||||
opacity: isSubmitting || !name.trim() ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? "Creating..." : "Create Project"}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Delete Confirmation Dialog
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
interface DeleteDialogProps {
|
||||
open: boolean;
|
||||
projectName: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
function DeleteConfirmDialog({
|
||||
open,
|
||||
projectName,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
isDeleting,
|
||||
}: DeleteDialogProps): ReactElement {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) onCancel();
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<div
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
border: "1px solid var(--border)",
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<span style={{ color: "var(--text)" }}>Delete Project</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span style={{ color: "var(--muted)" }}>
|
||||
{"This will permanently delete "}
|
||||
<strong style={{ color: "var(--text)" }}>{projectName}</strong>
|
||||
{". This action cannot be undone."}
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isDeleting}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: "transparent",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r)",
|
||||
color: "var(--text-2)",
|
||||
fontSize: "0.85rem",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isDeleting}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: "var(--danger)",
|
||||
border: "none",
|
||||
borderRadius: "var(--r)",
|
||||
color: "#fff",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
cursor: isDeleting ? "not-allowed" : "pointer",
|
||||
opacity: isDeleting ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Projects Page
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
export default function ProjectsPage(): ReactElement {
|
||||
const router = useRouter();
|
||||
const workspaceId = useWorkspaceId();
|
||||
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Create dialog state
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
// Delete dialog state
|
||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const loadProjects = useCallback(async (wsId: string | null): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await fetchProjects(wsId ?? undefined);
|
||||
setProjects(data);
|
||||
} catch (err: unknown) {
|
||||
console.error("[Projects] Failed to fetch projects:", err);
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Something went wrong loading projects. You could try again when ready."
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const wsId = workspaceId;
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await fetchProjects(wsId);
|
||||
if (!cancelled) {
|
||||
setProjects(data);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error("[Projects] Failed to fetch projects:", err);
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Something went wrong loading projects. You could try again when ready."
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
|
||||
return (): void => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workspaceId]);
|
||||
|
||||
function handleRetry(): void {
|
||||
void loadProjects(workspaceId);
|
||||
}
|
||||
|
||||
async function handleCreate(data: CreateProjectDto): Promise<void> {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await createProject(data, workspaceId ?? undefined);
|
||||
setCreateOpen(false);
|
||||
void loadProjects(workspaceId);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteRequest(projectId: string): void {
|
||||
const target = projects.find((p) => p.id === projectId);
|
||||
if (target) {
|
||||
setDeleteTarget(target);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteConfirm(): Promise<void> {
|
||||
if (!deleteTarget) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteProject(deleteTarget.id, workspaceId ?? undefined);
|
||||
setDeleteTarget(null);
|
||||
void loadProjects(workspaceId);
|
||||
} catch (err: unknown) {
|
||||
console.error("[Projects] Failed to delete project:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to delete project.");
|
||||
setDeleteTarget(null);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCardClick(projectId: string): void {
|
||||
router.push(`/projects/${projectId}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8" style={{ maxWidth: 960 }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 32,
|
||||
flexWrap: "wrap",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "1.875rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--text)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Projects
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.9rem",
|
||||
color: "var(--muted)",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
Organize and track your work across different initiatives
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 16px",
|
||||
background: "var(--primary)",
|
||||
border: "none",
|
||||
borderRadius: "var(--r)",
|
||||
color: "#fff",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Plus size={16} />
|
||||
New Project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<MosaicSpinner label="Loading projects..." />
|
||||
</div>
|
||||
) : error !== null ? (
|
||||
/* Error */
|
||||
<div
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
padding: 32,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<p style={{ color: "var(--danger)", margin: "0 0 16px" }}>{error}</p>
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: "var(--danger)",
|
||||
border: "none",
|
||||
borderRadius: "var(--r)",
|
||||
color: "#fff",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
/* Empty */
|
||||
<div
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
padding: 48,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<p style={{ color: "var(--muted)", margin: "0 0 16px", fontSize: "0.9rem" }}>
|
||||
No projects yet. Create your first project to get started.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 16px",
|
||||
background: "var(--primary)",
|
||||
border: "none",
|
||||
borderRadius: "var(--r)",
|
||||
color: "#fff",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Create Project
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Projects grid */
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{projects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
onDelete={handleDeleteRequest}
|
||||
onClick={handleCardClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Dialog */}
|
||||
<CreateProjectDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onSubmit={handleCreate}
|
||||
isSubmitting={isCreating}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<DeleteConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
projectName={deleteTarget?.name ?? ""}
|
||||
onConfirm={() => {
|
||||
void handleDeleteConfirm();
|
||||
}}
|
||||
onCancel={() => {
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -14,3 +14,4 @@ export * from "./teams";
|
||||
export * from "./personalities";
|
||||
export * from "./telemetry";
|
||||
export * from "./dashboard";
|
||||
export * from "./projects";
|
||||
|
||||
104
apps/web/src/lib/api/projects.ts
Normal file
104
apps/web/src/lib/api/projects.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Projects API Client
|
||||
* Handles project-related API requests
|
||||
*/
|
||||
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "./client";
|
||||
|
||||
/**
|
||||
* Project status enum (matches backend ProjectStatus)
|
||||
*/
|
||||
export enum ProjectStatus {
|
||||
PLANNING = "PLANNING",
|
||||
ACTIVE = "ACTIVE",
|
||||
PAUSED = "PAUSED",
|
||||
COMPLETED = "COMPLETED",
|
||||
ARCHIVED = "ARCHIVED",
|
||||
}
|
||||
|
||||
/**
|
||||
* Project response interface (matches Prisma Project model)
|
||||
*/
|
||||
export interface Project {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: ProjectStatus;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
creatorId: string;
|
||||
domainId: string | null;
|
||||
color: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for creating a new project
|
||||
*/
|
||||
export interface CreateProjectDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
status?: ProjectStatus;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
color?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for updating an existing project
|
||||
*/
|
||||
export interface UpdateProjectDto {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
status?: ProjectStatus;
|
||||
startDate?: string | null;
|
||||
endDate?: string | null;
|
||||
color?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all projects for a workspace
|
||||
*/
|
||||
export async function fetchProjects(workspaceId?: string): Promise<Project[]> {
|
||||
return apiGet<Project[]>("/api/projects", workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single project by ID
|
||||
*/
|
||||
export async function fetchProject(id: string, workspaceId?: string): Promise<Project> {
|
||||
return apiGet<Project>(`/api/projects/${id}`, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new project
|
||||
*/
|
||||
export async function createProject(
|
||||
data: CreateProjectDto,
|
||||
workspaceId?: string
|
||||
): Promise<Project> {
|
||||
return apiPost<Project>("/api/projects", data, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing project
|
||||
*/
|
||||
export async function updateProject(
|
||||
id: string,
|
||||
data: UpdateProjectDto,
|
||||
workspaceId?: string
|
||||
): Promise<Project> {
|
||||
return apiPatch<Project>(`/api/projects/${id}`, data, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a project
|
||||
*/
|
||||
export async function deleteProject(id: string, workspaceId?: string): Promise<void> {
|
||||
await apiDelete<Record<string, never>>(`/api/projects/${id}`, workspaceId);
|
||||
}
|
||||
Reference in New Issue
Block a user