From 374f104edbce89d5fd0e1c78f56fe66b02664157 Mon Sep 17 00:00:00 2001 From: samuelelijah585 Date: Tue, 1 Sep 2026 10:45:46 +0100 Subject: [PATCH 1/4] fix: [Feature]: Advanced Project Filtering & Sorting (#181) --- app/dashboard/projects/page.tsx | 259 ++++++++++++++++++++++++++++---- 1 file changed, 227 insertions(+), 32 deletions(-) diff --git a/app/dashboard/projects/page.tsx b/app/dashboard/projects/page.tsx index 1704b38..d719a95 100644 --- a/app/dashboard/projects/page.tsx +++ b/app/dashboard/projects/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; -import { Search, Filter, ChevronRight, Loader2 } from "lucide-react"; +import { Search, Filter, ChevronRight, Loader2, RotateCcw } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -14,15 +14,19 @@ import { SelectValue, } from "@/components/ui/select"; +type ProjectStatus = "pending" | "in-progress" | "pending-approval" | "completed"; + interface Project { id: string; title: string; - status: "pending" | "in-progress" | "pending-approval" | "completed"; + status: ProjectStatus; budget: number; progress: number; milestonesCount: number; completedMilestones: number; deadline: string; + createdAt: string; + skills: string[]; } const statusConfig = { @@ -53,9 +57,51 @@ export default function ProjectsPage() { const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); + const [minBudget, setMinBudget] = useState(""); + const [maxBudget, setMaxBudget] = useState(""); + const [selectedSkills, setSelectedSkills] = useState([]); + const [sortBy, setSortBy] = useState("newest"); + const [filtersOpen, setFiltersOpen] = useState(false); + const [hydrated, setHydrated] = useState(false); const [now] = useState(() => Date.now()); + useEffect(() => { + if (typeof window === "undefined") return; + const params = new URLSearchParams(window.location.search); + const q = params.get("q") ?? ""; + setSearchTerm(q); + setDebouncedSearchTerm(q); + if (params.get("status")) setStatusFilter(params.get("status")!); + if (params.get("minBudget")) setMinBudget(params.get("minBudget")!); + if (params.get("maxBudget")) setMaxBudget(params.get("maxBudget")!); + const skillParams = (params.get("skills") ?? "").split(",").filter(Boolean); + if (skillParams.length) setSelectedSkills(skillParams); + if (params.get("sort")) setSortBy(params.get("sort")!); + setHydrated(true); + }, []); + + useEffect(() => { + const handler = setTimeout(() => setDebouncedSearchTerm(searchTerm), 300); + return () => clearTimeout(handler); + }, [searchTerm]); + + useEffect(() => { + if (!hydrated) return; + if (typeof window === "undefined") return; + const params = new URLSearchParams(); + if (debouncedSearchTerm) params.set("q", debouncedSearchTerm); + if (statusFilter !== "all") params.set("status", statusFilter); + if (minBudget) params.set("minBudget", minBudget); + if (maxBudget) params.set("maxBudget", maxBudget); + if (selectedSkills.length) params.set("skills", selectedSkills.join(",")); + if (sortBy !== "newest") params.set("sort", sortBy); + const qs = params.toString(); + const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname; + window.history.replaceState(null, "", url); + }, [hydrated, debouncedSearchTerm, statusFilter, minBudget, maxBudget, selectedSkills, sortBy]); + useEffect(() => { (async () => { try { @@ -69,6 +115,8 @@ export default function ProjectsPage() { id: string; title: string; status: string; budget_max: string | null; deadline: string | null; milestones_count: number; completed_milestones: number; + created_at?: string | null; skills?: string[] | null; + required_skills?: string[] | null; }) => ({ id: p.id, title: p.title, @@ -83,6 +131,12 @@ export default function ProjectsPage() { deadline: p.deadline ? new Date(p.deadline).toISOString().split("T")[0] : new Date(Date.now() + 30 * 86400000).toISOString().split("T")[0], + createdAt: p.created_at ?? "", + skills: Array.isArray(p.skills) + ? p.skills + : Array.isArray(p.required_skills) + ? p.required_skills! + : [], })); setProjects(mapped); } finally { @@ -91,14 +145,60 @@ export default function ProjectsPage() { })(); }, []); + const allSkills = useMemo( + () => Array.from(new Set(projects.flatMap((p) => p.skills))).sort(), + [projects] + ); + + const activeFilterCount = + (statusFilter !== "all" ? 1 : 0) + + (minBudget ? 1 : 0) + + (maxBudget ? 1 : 0) + + selectedSkills.length; + + const clearAllFilters = () => { + setSearchTerm(""); + setDebouncedSearchTerm(""); + setStatusFilter("all"); + setMinBudget(""); + setMaxBudget(""); + setSelectedSkills([]); + setSortBy("newest"); + }; + const filtered = useMemo( - () => - projects.filter((p) => { - const matchSearch = p.title.toLowerCase().includes(searchTerm.toLowerCase()); - const matchStatus = statusFilter === "all" || p.status === statusFilter; - return matchSearch && matchStatus; - }), - [projects, searchTerm, statusFilter] + () => { + const min = minBudget ? parseFloat(minBudget) : 0; + const max = maxBudget ? parseFloat(maxBudget) : Infinity; + return projects + .filter((p) => { + const matchSearch = p.title + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase()); + const matchStatus = + statusFilter === "all" || + (statusFilter === "active" + ? p.status !== "completed" + : p.status === statusFilter); + const matchBudget = p.budget >= min && p.budget <= max; + const matchSkills = + selectedSkills.length === 0 || + selectedSkills.every((skill) => p.skills.includes(skill)); + return matchSearch && matchStatus && matchBudget && matchSkills; + }) + .sort((a, b) => { + if (sortBy === "budget-asc") return a.budget - b.budget; + if (sortBy === "budget-desc") return b.budget - a.budget; + if (sortBy === "deadline-asc") + return new Date(a.deadline).getTime() - new Date(b.deadline).getTime(); + if (sortBy === "deadline-desc") + return new Date(b.deadline).getTime() - new Date(a.deadline).getTime(); + const timeA = a.createdAt ? new Date(a.createdAt).getTime() || 0 : 0; + const timeB = b.createdAt ? new Date(b.createdAt).getTime() || 0 : 0; + return timeB - timeA || b.id.localeCompare(a.id); + }); + }, + [projects, debouncedSearchTerm, statusFilter, minBudget, maxBudget, selectedSkills, sortBy] ); return ( @@ -109,29 +209,124 @@ export default function ProjectsPage() {

View and manage all your projects

-
-
- - setSearchTerm(e.target.value)} - className="pl-10 border-border/40" - /> -
- setSearchTerm(e.target.value)} + className="pl-10 border-border/40" + /> +
+ + + + + {filtersOpen && ( +
+
+
+ + +
+
+ + setMinBudget(e.target.value)} + className="border-border/40" + /> +
+
+ + setMaxBudget(e.target.value)} + className="border-border/40" + /> +
+
+ + {allSkills.length > 0 && ( +
+ +
+ {allSkills.map((skill) => { + const isSelected = selectedSkills.includes(skill); + return ( + + ); + })} +
+
+ )} + +
+ +
+
+ )} {loading ? ( @@ -217,7 +412,7 @@ export default function ProjectsPage() { : "No projects match your filters."}

{projects.length > 0 && ( - )} From 5dcea3764f8a1d172350a2dfd84c84140e169c18 Mon Sep 17 00:00:00 2001 From: samuelelijah585 Date: Tue, 1 Sep 2026 10:45:48 +0100 Subject: [PATCH 2/4] fix: [Feature]: Advanced Project Filtering & Sorting (#181) --- lib/projects.ts | 120 +++++++++++++++++++++++++++++++----------------- 1 file changed, 78 insertions(+), 42 deletions(-) diff --git a/lib/projects.ts b/lib/projects.ts index dcd4b94..dac2236 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -14,6 +14,8 @@ import { sql } from "@/lib/db"; // ─── Types ───────────────────────────────────────────────────────────────── export type ProjectStatus = "open" | "in_progress" | "completed" | "cancelled"; +export type ProjectSortBy = "newest" | "budget" | "deadline"; +export type SortOrder = "asc" | "desc"; export interface Project { id: string; @@ -25,6 +27,8 @@ export interface Project { milestoneCount: number; createdAt: string; updatedAt: string; + deadline: string | null; + skills: string[]; } export interface CreateProjectInput { @@ -33,6 +37,8 @@ export interface CreateProjectInput { description?: string; budgetUsdc: number; milestoneCount?: number; + deadline?: string | null; + skills?: string[]; } export interface UpdateProjectInput { @@ -41,11 +47,18 @@ export interface UpdateProjectInput { budgetUsdc?: number; status?: ProjectStatus; milestoneCount?: number; + deadline?: string | null; + skills?: string[]; } export interface ListProjectsFilter { clientId?: string; status?: ProjectStatus; + budgetMin?: number; + budgetMax?: number; + skills?: string[]; + sortBy?: ProjectSortBy; + sortOrder?: SortOrder; limit?: number; offset?: number; } @@ -63,6 +76,8 @@ function rowToProject(row: Record): Project { milestoneCount: Number(row.milestone_count), createdAt: (row.created_at as Date).toISOString(), updatedAt: (row.updated_at as Date).toISOString(), + deadline: row.deadline ? new Date(row.deadline as string).toISOString() : null, + skills: Array.isArray(row.skills) ? (row.skills as string[]) : [], }; } @@ -78,14 +93,18 @@ export async function createProject(input: CreateProjectInput): Promise title, description, budget_usdc, - milestone_count + milestone_count, + deadline, + skills ) VALUES ( ${input.clientId}, ${input.title}, ${input.description ?? null}, ${input.budgetUsdc}, - ${input.milestoneCount ?? 0} + ${input.milestoneCount ?? 0}, + ${input.deadline ?? null}, + ${input.skills ?? []} ) RETURNING * `; @@ -93,52 +112,67 @@ 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, skills, and sorted by the provided criteria. + * Ordered by new projects first by default. */ 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[]; - } else { - rows = await sql` - SELECT * FROM projects - ORDER BY created_at DESC - LIMIT ${limit} - OFFSET ${offset} - ` as Record[]; + const { neon: neonFn } = await import("@neondatabase/serverless"); + const directSql = neonFn(process.env.DATABASE_URL!); + + const conditions: string[] = []; + const params: unknown[] = []; + + if (filter.clientId) { + params.push(filter.clientId); + conditions.push(`client_id = $${params.length}`); + } + if (filter.status) { + params.push(filter.status); + conditions.push(`status = $${params.length}`); + } + if (filter.budgetMin !== undefined) { + params.push(filter.budgetMin); + conditions.push(`budget_usdc >= $${params.length}`); + } + if (filter.budgetMax !== undefined) { + params.push(filter.budgetMax); + conditions.push(`budget_usdc <= $${params.length}`); + } + if (filter.skills && filter.skills.length > 0) { + params.push(filter.skills); + conditions.push(`skills && $${params.length}::text[]`); } + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + // Validate and build ORDER BY clause. + const allowedSortColumns: Record = { + newest: "created_at", + budget: "budget_usdc", + deadline: "deadline", + }; + const sortBy = filter.sortBy ?? "newest"; + const sortOrder = filter.sortOrder ?? (sortBy === "newest" ? "desc" : "asc"); + const orderColumn = allowedSortColumns[sortBy] ?? "created_at"; + const orderDirection = sortOrder === "asc" ? "ASC" : "DESC"; + const orderClause = `ORDER BY ${orderColumn} ${orderDirection}`; + + const query = ` + SELECT * + FROM projects + ${whereClause} + ${orderClause} + LIMIT $${params.length + 1} + OFFSET $${params.length + 2} + `; + + params.push(limit, offset); + + const rows = await directSql(query, params) as Record[]; return rows.map(rowToProject); } @@ -176,6 +210,8 @@ export async function updateProject( if (input.budgetUsdc !== undefined) { fields.push("budget_usdc"); values.push(input.budgetUsdc); } if (input.status !== undefined) { fields.push("status"); values.push(input.status); } if (input.milestoneCount !== undefined) { fields.push("milestone_count"); values.push(input.milestoneCount); } + if (input.deadline !== undefined) { fields.push("deadline"); values.push(input.deadline); } + if (input.skills !== undefined) { fields.push("skills"); values.push(input.skills); } if (fields.length === 0) { // Nothing to update — just fetch and return the current record. @@ -219,4 +255,4 @@ export async function deleteProject(id: string): Promise { ` as Record[]; return rows.length > 0; -} \ No newline at end of file +} From ae0acca06d861564db0648fd4dee963db5733902 Mon Sep 17 00:00:00 2001 From: samuelelijah585 Date: Tue, 1 Sep 2026 10:45:49 +0100 Subject: [PATCH 3/4] fix: [Feature]: Advanced Project Filtering & Sorting (#181) --- app/api/projects/route.ts | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index ec89f62..68bbc66 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -1,7 +1,7 @@ // app/api/projects/route.ts // // POST /api/projects — create a project -// GET /api/projects — list projects (with optional ?clientId= &status= &limit= &offset=) +// GET /api/projects — list projects (with optional filtering & sorting) import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; @@ -10,6 +10,8 @@ import { createProject, listProjects, type ProjectStatus } from "@/lib/projects" // ─── Validation schemas ──────────────────────────────────────────────────── const PROJECT_STATUS = ["open", "in_progress", "completed", "cancelled"] as const; +const SORT_BY = ["newest", "budget", "deadline"] as const; +const SORT_ORDER = ["asc", "desc"] as const; const CreateProjectSchema = z.object({ clientId: z @@ -32,11 +34,18 @@ const CreateProjectSchema = z.object({ .int("milestoneCount must be an integer") .min(0, "milestoneCount cannot be negative") .optional(), + deadline: z.string().datetime({ offset: true }).optional().nullable(), + skills: z.array(z.string()).optional(), }); const ListProjectsSchema = z.object({ clientId: z.string().uuid("clientId must be a valid UUID").optional(), status: z.enum(PROJECT_STATUS).optional(), + budgetMin: z.coerce.number().nonnegative("budgetMin must be non-negative").optional(), + budgetMax: z.coerce.number().nonnegative("budgetMax must be non-negative").optional(), + skills: z.string().optional(), + sortBy: z.enum(SORT_BY).optional(), + sortOrder: z.enum(SORT_ORDER).optional(), limit: z.coerce.number().int().min(1).max(100).optional(), offset: z.coerce.number().int().min(0).optional(), }); @@ -82,6 +91,11 @@ export async function GET(req: NextRequest) { const parsed = ListProjectsSchema.safeParse({ clientId: searchParams.get("clientId") ?? undefined, status: searchParams.get("status") ?? undefined, + budgetMin: searchParams.get("budgetMin") ?? undefined, + budgetMax: searchParams.get("budgetMax") ?? undefined, + skills: searchParams.get("skills") ?? undefined, + sortBy: searchParams.get("sortBy") ?? undefined, + sortOrder: searchParams.get("sortOrder") ?? undefined, limit: searchParams.get("limit") ?? undefined, offset: searchParams.get("offset") ?? undefined, }); @@ -95,10 +109,10 @@ 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, + ...parsed.data, + skills: parsed.data.skills + ? parsed.data.skills.split(",").map(s => s.trim()).filter(Boolean) + : undefined, }); return NextResponse.json(projects); } catch (err) { @@ -108,4 +122,4 @@ export async function GET(req: NextRequest) { { status: 500 }, ); } -} \ No newline at end of file +} From 643fb8634ba9af573be4551c2f4a1211bc16954f Mon Sep 17 00:00:00 2001 From: samuelelijah585 Date: Tue, 1 Sep 2026 10:45:51 +0100 Subject: [PATCH 4/4] fix: [Feature]: Advanced Project Filtering & Sorting (#181) --- components/dashboard/project-card.tsx | 528 ++++++++++++++++++++++++-- 1 file changed, 498 insertions(+), 30 deletions(-) diff --git a/components/dashboard/project-card.tsx b/components/dashboard/project-card.tsx index fdc9d67..874f33e 100644 --- a/components/dashboard/project-card.tsx +++ b/components/dashboard/project-card.tsx @@ -1,8 +1,17 @@ "use client"; import Link from "next/link"; -import { useState } from "react"; -import { AlertCircle, CheckCircle, Clock, ChevronRight } from "lucide-react"; +import { useMemo, useState, useEffect, useCallback from "react"; +import { useSearchParams, useRouter, usePathname from "next/navigation"; +import { + AlertCircle, + CheckCircle, + Clock, + ChevronRight, + SlidersHorizontal, + X, + SearchX, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Progress } from "@/components/ui/progress"; @@ -23,8 +32,10 @@ export interface Project { budget: number; progress: number; milestonesCount: number; - completedMilestones: number; + completed ilestones: number; deadline: string; + skills: string[]; + createdAt: string; } const statusConfig = { @@ -50,6 +61,42 @@ const statusConfig = { }, }; +const AVAILABLE_SKILLS = [ + "React", + "Next.js", + "TypeScript", + "Node.js", + "Python", + "UI/UX", + "AWS", + "DevOps", + "Go", + "Rust", +]; + +type SortOption = + | "newest" + | "budget-asc" + | "budget-desc" + | "deadline-asc" + | "deadline-desc"; + +interface Filters { + minBudget?: number; + maxBudget?: number; + statuses: string[]; + skills: string[]; + sortBy: SortOption; +} + +const defaultFilters: Filters = { + minBudget: undefined, + maxBudget: undefined, + statuses: [], + skills: [], + sortBy: "newest", +}; + export function ProjectCard({ project }: { project: Project }) { const [showMilestones, setShowMilestones] = useState(false); const [showApproval, setShowApproval] = useState(false); @@ -62,24 +109,37 @@ export function ProjectCard({ project }: { project: Project }) { return ( <> -
-
- {/* Header */} -
-
-
-

{project.title}

+
+
+ {** Header **/ +
+
+
+

{project.title}

{config.text}
-

+

{project.description}

+ {project.skills.length > 0 && ( +
+ {project.skills.map((skill) => ( + + {skill} + + )}) +
+ )}
- +
- {/* Progress and Stats */} -
-
-
- Progress - {project.progress}% + {** Progress and Stats */ +
+
+
+ Progress + {project.progress}%
-
+
-

Budget

-

+

Budget

+

${project.budget.toLocaleString()}

-

Milestones

-

+

Milestones

+

{project.completedMilestones}/{project.milestonesCount}

-

Deadline

-
+

Deadline

+

- {/* Actions */} -
+
- {/* Dialogs */} @@ -181,3 +239,413 @@ export function ProjectCard({ project }: { project: Project }) { ); } + +function parseFiltersFromSearchParams(params: URLSearchParams): Filters { + const minBudget = params.get("minBudget") ?? undefined; + const maxBudget = params.get("maxBudget") ?? undefined; + const statuses = params.get("status")?.split(",").filter(Boolean) ?? []; + const skills = params.get("skills")?.split(",").filter(Boolean) ?? []; + const sortBy = (params.get("sort") as SortOption) || "newest"; + return { + minBudget: minBudget ? Number(minBudget) : undefined, + maxBudget: maxBudget ? Number(maxBudget) : undefined, + statuses, + skills, + sortBy: ["newest", "budget-asc", "budget-desc", "deadline-asc", "deadline-desc"].includes(sortBy) + ? sortBy + : "newest", + }; +} + +function buildQueryString(filters: Filters): string { + const params = new URLSearchParams(); + if (filters.minBudget !== undefined) params.set("minBudget", String(filters.minBudget)); + if (filters.maxBudget !== undefined) params.set("maxBudget", String(filters.maxBudget)); + if (filters.statuses.length > 0) params.set("status", filters.statuses.join(",")); + if (filters.skills.length > 0) params.set("skills", filters.skills.join(",")); + params.set("sort", filters.sortBy); + return params.toString(); +} + +function useFiltersFromUrl() { + const searchParams = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + + const [filters, setFilters] = useState(() => + parseFiltersFromSearchParams(searchParams), + ); + + const updateFilters = useCallback( + (newFilters: Filters) => { + setFilters(newFilters); + const qs = buildQueryString(newFilters); + router.replace(${pathname}?${qs}, { scroll: false }); + }, + [router, pathname], + ); + + const clearFilters = useCallback(() => { + updateFilters(defaultFilters); + }, [updateFilters]); + + // Update state if the URL changes externally (e.g., browser back/forward) + useEffect(() => { + setFilters(parseFiltersFromSearchParams(searchParams)); + }, [searchParams)]); + + return { filters, updateFilters, clearFilters }; +} + +function ProjectFilters({ + filters, + onChange, + onClear, + mobileOpen, + setMobileOpen, +}: { + filters: Filters; + onChange: (filters: Filters) => void; + onClear: () => void; + mobileOpen: boolean; + setMobileOpen: (open: boolean) => void; +}) { + const update = (partial: Partial) => onChange({ ...filters, ...partial }); + + const toggleValue = (key: "statuses" | "skills", value: string) => { + const current = filters[key]; + const next = current.includes(value) + ? current.filter((v) => v !== value) + : [...current, value]; + update({ [key]: next }); + }; + + const hasActiveFilters = + filters.minBudget !== undefined || + filters.maxBudget !== undefined || + filters.statuses.length > 0 || + filters.skills.length > 0 || + filters.sortBy !== "newest"; + + const content = ( +
+
+

Sort By

+ +
+ +
+

Budget Range

+
+ + update({ + minBudget: e.target.value ? Number(e.target.value) : undefined, + }) + } + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder-text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring" + /> + "— + + update({ + maxBudget: e.target.value ? Number(e.target.value) : undefined, + }) + } + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder-text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring" + /> +
+
+ +
+

Status

+
+ {Object.entries(statusConfig).map(([value, config]) => ( + + )) +
+
+ +
+

Required Skills

+
+ {AVAILABLE_SKILLS.map((skill) => { + const selected = filters.skills.includes(skill); + return ( + + ); + })} +
+
+ + +
+ ); + + return ( + > + + + + +
+ +
+ + + {mobileOpen && ( +
+
setMobileOpen(false)} + /> +
+
+

Filters

+ +
+
{content}
+
+
+ )} + + ); +} + +function ProjectListSkeleton() { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+
+
+ ))} +
+ ); +} + +export function ProjectList({ projects }: { projects: Project[] }) { + const { filters, updateFilters, clearFilters } = useFiltersFromUrl(); + const [appliedFilters, setAppliedFilters] = useState(filters); + const [isLoading, setIsLoading] = useState(false); + const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false); + + // Debounce filter application + useEffect(() => { + setIsLoading(true); + const timeout = setTimeout(() => { + setAppliedFilters(filters); + setIsLoading(false); + }, 300); + return () => clearTimeout(timeout); + }, [filters]); + + const filteredProjects = useMemo(() => { + let result = projects.filter((project) => { + if ( + appliedFilters.minBudget !== undefined && + project.budget < appliedFilters.minBudget + ) { + return false; + } + if ( + appliedFilters.maxBudget !== undefined && + project.budget > appliedFilters.maxBudget + ) { + return false; + } + if ( + appliedFilters.statuses.length > 0 && + !appliedFilters.statuses.includes(project.status) + ) { + return false; + } + if ( + appliedFilters.skills.length > 0 && + !appliedFilters.skills.some((skill) => project.skills.includes(skill)) + ) { + return false; + } + return true; + }); + + switch (appliedFilters.sortBy) { + case "newest": + result = result.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + break; + case "budget-asc": + result = result.sort((a, b) => a.budget - b.budget); + break; + case "budget-desc": + result = result.sort((a, b) => b.budget - a.budget); + break; + case "deadline-asc": + result = result.sort( + (a, b) => new Date(a.deadline).getTime() - new Date(b.deadline).getTime(), + ); + break; + case "deadline-desc": + result = result.sort( + (a, b) => new Date(b.deadline).getTime() - new Date(a.deadline).getTime(), + ); + break; + } + return result; + }, [projects, appliedFilters]); + + const hasActiveFilters = + filters.minBudget !== undefined || + filters.maxBudget !== undefined || + filters.statuses.length > 0 || + filters.skills.length > 0 || + filters.sortBy !== "newest"; + + return ( +
+ + +
+
+

+ {isLoading + ? "Loading projects..." + : `${filteredProjects.length} project${filteredProjects.length === 1 ? "" : "s"} found` + } +

+ {hasActiveFilters && ( + + )} +
+ + {isLoading ? ( + + ) : filteredProjects.length > 0 ? ( +
+ {filteredProjects.map((project) => ( + + ))} +
+ ) : ( +
+ +

No projects found

+

+ Try adjusting or clearing your filters. +

+ +
+ )} +
+
+ ); +} \ No newline at end of file