diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index ec89f62..c905a0b 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -5,7 +5,14 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { createProject, listProjects, type ProjectStatus } from "@/lib/projects"; +import { + createProject, + getAvailableProjectSkills, + listProjects, + type ProjectSortField, + type ProjectSortOrder, + type ProjectStatus, +} from "@/lib/projects"; // ─── Validation schemas ──────────────────────────────────────────────────── @@ -34,11 +41,19 @@ const CreateProjectSchema = z.object({ .optional(), }); +const PROJECT_SORT_FIELDS = ["newest", "budget", "deadline"] as const; +const PROJECT_SORT_ORDERS = ["asc", "desc"] as const; + const ListProjectsSchema = z.object({ - clientId: z.string().uuid("clientId must be a valid UUID").optional(), - status: z.enum(PROJECT_STATUS).optional(), - limit: z.coerce.number().int().min(1).max(100).optional(), - offset: z.coerce.number().int().min(0).optional(), + clientId: z.string().uuid("clientId must be a valid UUID").optional(), + status: z.enum(PROJECT_STATUS).optional(), + minBudget: z.coerce.number().min(0).optional(), + maxBudget: z.coerce.number().min(0).optional(), + skills: z.array(z.string().min(1).max(100)).optional(), + sort: z.enum(PROJECT_SORT_FIELDS).optional(), + order: z.enum(PROJECT_SORT_ORDERS).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + offset: z.coerce.number().int().min(0).optional(), }); // ─── POST /api/projects ──────────────────────────────────────────────────── @@ -80,10 +95,15 @@ export async function GET(req: NextRequest) { const { searchParams } = req.nextUrl; const parsed = ListProjectsSchema.safeParse({ - clientId: searchParams.get("clientId") ?? undefined, - status: searchParams.get("status") ?? undefined, - limit: searchParams.get("limit") ?? undefined, - offset: searchParams.get("offset") ?? undefined, + clientId: searchParams.get("clientId") ?? undefined, + status: searchParams.get("status") ?? undefined, + minBudget: searchParams.get("minBudget") ?? undefined, + maxBudget: searchParams.get("maxBudget") ?? undefined, + skills: searchParams.getAll("skills"), + sort: searchParams.get("sort") ?? undefined, + order: searchParams.get("order") ?? undefined, + limit: searchParams.get("limit") ?? undefined, + offset: searchParams.get("offset") ?? undefined, }); if (!parsed.success) { @@ -94,13 +114,19 @@ export async function GET(req: NextRequest) { } try { - const projects = await listProjects({ - clientId: parsed.data.clientId, - status: parsed.data.status as ProjectStatus | undefined, - limit: parsed.data.limit, - offset: parsed.data.offset, + const result = await listProjects({ + clientId: parsed.data.clientId, + status: parsed.data.status as ProjectStatus | undefined, + minBudget: parsed.data.minBudget, + maxBudget: parsed.data.maxBudget, + skills: parsed.data.skills, + sort: parsed.data.sort as ProjectSortField | undefined, + order: parsed.data.order as ProjectSortOrder | undefined, + limit: parsed.data.limit, + offset: parsed.data.offset, }); - return NextResponse.json(projects); + const skills = await getAvailableProjectSkills(); + return NextResponse.json({ ...result, skills }); } catch (err) { console.error("[GET /api/projects]", err); return NextResponse.json( diff --git a/app/dashboard/projects/page.tsx b/app/dashboard/projects/page.tsx index 1704b38..3308ca9 100644 --- a/app/dashboard/projects/page.tsx +++ b/app/dashboard/projects/page.tsx @@ -1,11 +1,13 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; -import { Search, Filter, ChevronRight, Loader2 } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Search, Filter, ChevronRight, Loader2, SlidersHorizontal, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, @@ -25,6 +27,12 @@ interface Project { deadline: string; } +interface ProjectResponse { + projects: Project[]; + skills: string[]; + totalItems: number; +} + const statusConfig = { pending: { color: "bg-muted", text: "Pending", textColor: "text-muted-foreground" }, "in-progress": { color: "bg-secondary/20", text: "In Progress", textColor: "text-secondary" }, @@ -49,180 +57,416 @@ function getAuthHeaders(): Record { return token ? { Authorization: `Bearer ${token}` } : {}; } +function LoadingRows() { + return ( +
+ {Array.from({ length: 5 }, (_, index) => ( +
+
+
+
+
+
+ ))} +
+ ); +} + export default function ProjectsPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [projects, setProjects] = useState([]); + const [skills, setSkills] = useState([]); + const [totalItems, setTotalItems] = useState(0); const [loading, setLoading] = useState(true); - const [searchTerm, setSearchTerm] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); + const [drawerOpen, setDrawerOpen] = useState(false); const [now] = useState(() => Date.now()); + // Filter state (initialised from URL query params so filters persist). + const [searchTerm, setSearchTerm] = useState(searchParams.get("q") ?? ""); + const [statusFilter, setStatusFilter] = useState(searchParams.get("status") ?? "all"); + const [minBudget, setMinBudget] = useState(searchParams.get("minBudget") ?? ""); + const [maxBudget, setMaxBudget] = useState(searchParams.get("maxBudget") ?? ""); + const [selectedSkills, setSelectedSkills] = useState( + searchParams.getAll("skills"), + ); + const [sort, setSort] = useState(searchParams.get("sort") ?? "newest"); + const [order, setOrder] = useState(searchParams.get("order") ?? "desc"); + + const hasActiveFilters = + searchTerm.trim() !== "" || + statusFilter !== "all" || + minBudget !== "" || + maxBudget !== "" || + selectedSkills.length > 0; + + // Build the query string used for both the API call and the URL. + const queryString = useMemo(() => { + const params = new URLSearchParams(); + if (searchTerm.trim()) params.set("q", searchTerm.trim()); + if (statusFilter !== "all") params.set("status", statusFilter); + if (minBudget !== "") params.set("minBudget", minBudget); + if (maxBudget !== "") params.set("maxBudget", maxBudget); + selectedSkills.forEach((skill) => params.append("skills", skill)); + if (sort !== "newest") params.set("sort", sort); + if (order !== "desc") params.set("order", order); + return params.toString(); + }, [searchTerm, statusFilter, minBudget, maxBudget, selectedSkills, sort, order]); + + // Keep the URL in sync with the applied filters (shareable / refreshable). useEffect(() => { - (async () => { - try { - const res = await fetch("/api/projects", { - headers: getAuthHeaders(), - credentials: "include", - }); - if (!res.ok) return; - const data = await res.json(); - const mapped: Project[] = (data.projects ?? []).map((p: { - id: string; title: string; status: string; - budget_max: string | null; deadline: string | null; - milestones_count: number; completed_milestones: number; - }) => ({ - id: p.id, - title: p.title, - status: mapStatus(p.status), - budget: parseFloat(p.budget_max ?? "0"), - progress: - p.milestones_count > 0 - ? Math.round((p.completed_milestones / p.milestones_count) * 100) - : 0, - milestonesCount: p.milestones_count, - completedMilestones: p.completed_milestones, - deadline: p.deadline - ? new Date(p.deadline).toISOString().split("T")[0] - : new Date(Date.now() + 30 * 86400000).toISOString().split("T")[0], - })); - setProjects(mapped); - } finally { - setLoading(false); - } - })(); - }, []); + const params = new URLSearchParams(queryString); + const qs = params.toString(); + router.replace(qs ? `/dashboard/projects?${qs}` : "/dashboard/projects", { + scroll: false, + }); + }, [queryString, router]); + + const loadProjects = useCallback(async () => { + setLoading(true); + try { + const res = await fetch(`/api/projects?${queryString}`, { + headers: getAuthHeaders(), + credentials: "include", + cache: "no-store", + }); + if (!res.ok) return; + const data = (await res.json()) as ProjectResponse; + const mapped: Project[] = (data.projects ?? []).map((p: { + id: string; title: string; status: string; + budgetUsdc: number; milestoneCount: number; createdAt: string; + }) => ({ + id: p.id, + title: p.title, + status: mapStatus(p.status), + budget: Number(p.budgetUsdc ?? 0), + progress: 0, + milestonesCount: Number(p.milestoneCount ?? 0), + completedMilestones: 0, + deadline: p.createdAt + ? new Date(p.createdAt).toISOString().split("T")[0] + : new Date(Date.now() + 30 * 86400000).toISOString().split("T")[0], + })); + setProjects(mapped); + setSkills(data.skills ?? []); + setTotalItems(data.totalItems ?? mapped.length); + } finally { + setLoading(false); + } + }, [queryString]); + + // Debounced fetch — prevents excessive API calls during rapid input changes. + useEffect(() => { + const timeout = window.setTimeout(() => { + void loadProjects(); + }, 300); + return () => window.clearTimeout(timeout); + }, [loadProjects]); + + function toggleSkill(skill: string) { + setSelectedSkills((current) => + current.includes(skill) ? current.filter((item) => item !== skill) : [...current, skill], + ); + } + + function clearFilters() { + setSearchTerm(""); + setStatusFilter("all"); + setMinBudget(""); + setMaxBudget(""); + setSelectedSkills([]); + setSort("newest"); + setOrder("desc"); + } const filtered = useMemo( () => projects.filter((p) => { const matchSearch = p.title.toLowerCase().includes(searchTerm.toLowerCase()); const matchStatus = statusFilter === "all" || p.status === statusFilter; - return matchSearch && matchStatus; + const matchMin = minBudget === "" || p.budget >= parseFloat(minBudget); + const matchMax = maxBudget === "" || p.budget <= parseFloat(maxBudget); + return matchSearch && matchStatus && matchMin && matchMax; }), - [projects, searchTerm, statusFilter] + [projects, searchTerm, statusFilter, minBudget, maxBudget] ); - return ( -
-
-
-

All Projects

-

View and manage all your projects

+ const filterPanel = ( +
+
+
+ Filters +
+ {hasActiveFilters && ( + + )} +
+ + + +
+ Status + +
+ +
+ Budget range (USDC) +
+ setMinBudget(e.target.value)} + /> + + setMaxBudget(e.target.value)} + />
+
-
-
- - setSearchTerm(e.target.value)} - className="pl-10 border-border/40" - /> +
+ Required skills + {skills.length === 0 ? ( +

No skills available yet.

+ ) : ( +
+ {skills.map((skill) => ( + + ))}
- + + + + + Newest first + Budget + Deadline + + + {sort !== "newest" && ( + -
+ )} +
+
+ ); - {loading ? ( -
- - Loading projects… + return ( +
+
+
+
+

All Projects

+

View and manage all your projects

- ) : ( -
-
- - - - - - - - - - - - - - {filtered.map((project) => { - const config = statusConfig[project.status]; - const daysLeft = Math.ceil( - (new Date(project.deadline).getTime() - now) / (1000 * 60 * 60 * 24) - ); - const isOverdue = daysLeft < 0; - return ( - - - - - - - - - - ); - })} - -
ProjectStatusProgressBudgetMilestonesDeadlineAction
{project.title} - - {config.text} - - -
-
-
-
- - {project.progress}% - -
-
- ${project.budget.toLocaleString()} - - {project.completedMilestones}/{project.milestonesCount} - -

- {isOverdue ? `${Math.abs(daysLeft)}d ago` : `${daysLeft}d left`} -

-
- - - -
+ +
+ +
+ {/* Desktop filter sidebar */} + + + {/* Mobile filter drawer */} + {drawerOpen && ( +
+
setDrawerOpen(false)} + /> +
+
+

Filters

+ +
+ {filterPanel} + +
+
+ )} + +
+
+
+

Showing

+

+ {totalItems} project{totalItems === 1 ? "" : "s"} found +

+
+
+ + {sort !== "newest" && ( + + )} +
-
- )} - {!loading && filtered.length === 0 && ( -
-

- {projects.length === 0 - ? "No projects yet." - : "No projects match your filters."} -

- {projects.length > 0 && ( - + {loading ? ( + + ) : filtered.length === 0 ? ( +
+

+ {totalItems === 0 + ? "No projects yet." + : "No projects match your filters."} +

+ {totalItems > 0 && ( + + )} +
+ ) : ( +
+
+ + + + + + + + + + + + + + {filtered.map((project) => { + const config = statusConfig[project.status]; + const daysLeft = Math.ceil( + (new Date(project.deadline).getTime() - now) / (1000 * 60 * 60 * 24) + ); + const isOverdue = daysLeft < 0; + return ( + + + + + + + + + + ); + })} + +
ProjectStatusProgressBudgetMilestonesDeadlineAction
{project.title} + + {config.text} + + +
+
+
+
+ + {project.progress}% + +
+
+ ${project.budget.toLocaleString()} + + {project.completedMilestones}/{project.milestonesCount} + +

+ {isOverdue ? `${Math.abs(daysLeft)}d ago` : `${daysLeft}d left`} +

+
+ + + +
+
+
)}
- )} +
); diff --git a/lib/projects.ts b/lib/projects.ts index dcd4b94..95582d3 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -43,13 +43,26 @@ export interface UpdateProjectInput { milestoneCount?: number; } +export type ProjectSortField = "newest" | "budget" | "deadline"; +export type ProjectSortOrder = "asc" | "desc"; + export interface ListProjectsFilter { clientId?: string; status?: ProjectStatus; + minBudget?: number; + maxBudget?: number; + skills?: string[]; + sort?: ProjectSortField; + order?: ProjectSortOrder; limit?: number; offset?: number; } +export interface ListProjectsResult { + projects: Project[]; + totalItems: number; +} + // ─── Row → domain mapper ─────────────────────────────────────────────────── function rowToProject(row: Record): Project { @@ -93,53 +106,84 @@ export async function createProject(input: CreateProjectInput): Promise } /** - * Returns a paginated list of projects, optionally filtered by clientId - * and/or status. Ordered by created_at descending (newest first). + * Returns a paginated list of projects, optionally filtered by clientId, + * status, budget range and/or required skills. Results can be sorted by + * newest first (default), budget or deadline, in ascending or descending + * order. Returns both the rows and the total number of matching projects. */ -export async function listProjects(filter: ListProjectsFilter = {}): Promise { +export async function listProjects( + filter: ListProjectsFilter = {}, +): Promise { const limit = Math.min(filter.limit ?? 20, 100); // hard cap at 100 const offset = filter.offset ?? 0; - // Build WHERE clauses dynamically. Neon's tagged-template approach requires - // all placeholders to appear in the literal at build time, so we branch - // into four possible queries rather than building a string. - let rows: Record[]; - - if (filter.clientId && filter.status) { - rows = await sql` - SELECT * FROM projects - WHERE client_id = ${filter.clientId} - AND status = ${filter.status} - ORDER BY created_at DESC - LIMIT ${limit} - OFFSET ${offset} - ` as Record[]; - } else if (filter.clientId) { - rows = await sql` - SELECT * FROM projects - WHERE client_id = ${filter.clientId} - ORDER BY created_at DESC - LIMIT ${limit} - OFFSET ${offset} - ` as Record[]; - } else if (filter.status) { - rows = await sql` - SELECT * FROM projects - WHERE status = ${filter.status} - ORDER BY created_at DESC - LIMIT ${limit} - OFFSET ${offset} - ` as Record[]; + const skills = (filter.skills ?? []) + .map((s) => s.trim()) + .filter((s) => s.length > 0); + + // Build the ORDER BY clause from a controlled set of column names. + const sortField = filter.sort ?? "newest"; + const order = filter.order ?? (sortField === "newest" ? "desc" : "asc"); + const orderSql = order === "asc" ? sql`ASC` : sql`DESC`; + const orderBy = + sortField === "budget" + ? sql`COALESCE(budget_max, budget_min, 0) ${orderSql}, created_at DESC` + : sortField === "deadline" + ? sql`deadline ${orderSql} NULLS LAST, created_at DESC` + : sql`created_at DESC`; + + // Build the WHERE clause. Neon's tagged-template helper requires all + // placeholders to appear in the literal at build time, so we use the + // `0 = 0` / `1 = 1` pattern to conditionally include filters. + const where = sql` + (${filter.clientId ? sql`client_id = ${filter.clientId}` : sql`TRUE`}) + AND (${filter.status ? sql`status = ${filter.status}` : sql`TRUE`}) + AND (${filter.minBudget !== undefined ? sql`COALESCE(budget_max, budget_min, 0) >= ${filter.minBudget}` : sql`TRUE`}) + AND (${filter.maxBudget !== undefined ? sql`COALESCE(budget_min, budget_max, 0) <= ${filter.maxBudget}` : sql`TRUE`}) + AND (${skills.length > 0 ? sql`tags && ${skills}` : sql`TRUE`}) + `; + + const rows = (await sql` + SELECT *, COUNT(*) OVER() AS total_count + FROM projects + WHERE ${where} + ORDER BY ${orderBy} + LIMIT ${limit} + OFFSET ${offset} + `) as Array & { total_count: string | number }>; + + let totalItems: number; + if (rows.length > 0) { + const raw = rows[0].total_count; + totalItems = typeof raw === "number" ? raw : parseInt(String(raw), 10) || 0; } else { - rows = await sql` - SELECT * FROM projects - ORDER BY created_at DESC - LIMIT ${limit} - OFFSET ${offset} - ` as Record[]; + const countRows = (await sql` + SELECT COUNT(*) AS count FROM projects WHERE ${where} + `) as Array<{ count: string | number }>; + const raw = countRows[0]?.count ?? 0; + totalItems = typeof raw === "number" ? raw : parseInt(String(raw), 10) || 0; } - return rows.map(rowToProject); + const projects = rows.map(({ total_count: _ignored, ...rest }) => + rowToProject(rest), + ); + + return { projects, totalItems }; +} + +/** + * Returns the distinct skills currently used by any project, sorted + * alphabetically. Used to populate the skill filter options. + */ +export async function getAvailableProjectSkills(): Promise { + const rows = (await sql` + SELECT DISTINCT skill + FROM projects p, unnest(COALESCE(p.tags, ARRAY[]::text[])) AS skill + ORDER BY skill ASC + `) as Array<{ skill: string }>; + return rows + .map((row) => row.skill) + .filter((s): s is string => typeof s === "string" && s.length > 0); } /**