From 5fa7fc147ea4c4519d0b682d3df5b3c0e4d177d4 Mon Sep 17 00:00:00 2001 From: Cmitchelle7 Date: Thu, 24 Sep 2026 00:13:46 -0700 Subject: [PATCH] feat: client proposal management dashboard (#213) --- app/api/proposals/[id]/route.ts | 43 +++++ app/api/proposals/route.ts | 43 +++++ app/dashboard/proposals/[projectId]/page.tsx | 48 ++++++ components/proposals/index.ts | 7 + components/proposals/proposal-card.tsx | 103 ++++++++++++ .../proposals/proposal-confirm-dialog.tsx | 52 ++++++ components/proposals/proposal-empty-state.tsx | 18 +++ components/proposals/proposal-filters.tsx | 58 +++++++ components/proposals/proposal-list.tsx | 125 ++++++++++++++ components/proposals/proposal-skeleton.tsx | 17 ++ .../proposals/proposal-status-badge.tsx | 17 ++ lib/db/migrations/010_proposals.sql | 32 ++++ lib/proposals/db.ts | 17 ++ lib/proposals/service.ts | 152 ++++++++++++++++++ lib/proposals/types.ts | 26 +++ 15 files changed, 758 insertions(+) create mode 100644 app/api/proposals/[id]/route.ts create mode 100644 app/api/proposals/route.ts create mode 100644 app/dashboard/proposals/[projectId]/page.tsx create mode 100644 components/proposals/index.ts create mode 100644 components/proposals/proposal-card.tsx create mode 100644 components/proposals/proposal-confirm-dialog.tsx create mode 100644 components/proposals/proposal-empty-state.tsx create mode 100644 components/proposals/proposal-filters.tsx create mode 100644 components/proposals/proposal-list.tsx create mode 100644 components/proposals/proposal-skeleton.tsx create mode 100644 components/proposals/proposal-status-badge.tsx create mode 100644 lib/db/migrations/010_proposals.sql create mode 100644 lib/proposals/db.ts create mode 100644 lib/proposals/service.ts create mode 100644 lib/proposals/types.ts diff --git a/app/api/proposals/[id]/route.ts b/app/api/proposals/[id]/route.ts new file mode 100644 index 0000000..cf53422 --- /dev/null +++ b/app/api/proposals/[id]/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { readAccessToken, verifyAccessToken } from "@/lib/auth/session"; +import { db as sql } from "@/lib/proposals/db"; +import { + listProposalsForClient, + listProposalsForFreelancer, +} from "@/lib/proposals/service"; + +async function resolveUser(request: NextRequest) { + const token = readAccessToken(request); + if (!token) return null; + const verified = verifyAccessToken(token); + if (!verified) return null; + + const rows = (await sql` + SELECT id, role FROM users + WHERE wallet_address = ${verified.walletAddress} + LIMIT 1 + `) as { id: string; role: "freelancer" | "client" | "admin" }[]; + + return rows[0] ?? null; +} + +export async function GET(request: NextRequest) { + const user = await resolveUser(request); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + if (user.role === "client" || user.role === "admin") { + const projectId = request.nextUrl.searchParams.get("projectId"); + if (!projectId) return NextResponse.json({ error: "projectId required" }, { status: 400 }); + + const proposals = await listProposalsForClient(user.id, projectId, { + status: (request.nextUrl.searchParams.get("status") as any) ?? "all", + sortBy: (request.nextUrl.searchParams.get("sortBy") as any) ?? "recent", + sortDir: (request.nextUrl.searchParams.get("sortDir") as any) ?? "desc", + search: request.nextUrl.searchParams.get("search") ?? undefined, + }); + return NextResponse.json({ proposals }); + } + + const proposals = await listProposalsForFreelancer(user.id); + return NextResponse.json({ proposals }); +} \ No newline at end of file diff --git a/app/api/proposals/route.ts b/app/api/proposals/route.ts new file mode 100644 index 0000000..cf53422 --- /dev/null +++ b/app/api/proposals/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { readAccessToken, verifyAccessToken } from "@/lib/auth/session"; +import { db as sql } from "@/lib/proposals/db"; +import { + listProposalsForClient, + listProposalsForFreelancer, +} from "@/lib/proposals/service"; + +async function resolveUser(request: NextRequest) { + const token = readAccessToken(request); + if (!token) return null; + const verified = verifyAccessToken(token); + if (!verified) return null; + + const rows = (await sql` + SELECT id, role FROM users + WHERE wallet_address = ${verified.walletAddress} + LIMIT 1 + `) as { id: string; role: "freelancer" | "client" | "admin" }[]; + + return rows[0] ?? null; +} + +export async function GET(request: NextRequest) { + const user = await resolveUser(request); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + if (user.role === "client" || user.role === "admin") { + const projectId = request.nextUrl.searchParams.get("projectId"); + if (!projectId) return NextResponse.json({ error: "projectId required" }, { status: 400 }); + + const proposals = await listProposalsForClient(user.id, projectId, { + status: (request.nextUrl.searchParams.get("status") as any) ?? "all", + sortBy: (request.nextUrl.searchParams.get("sortBy") as any) ?? "recent", + sortDir: (request.nextUrl.searchParams.get("sortDir") as any) ?? "desc", + search: request.nextUrl.searchParams.get("search") ?? undefined, + }); + return NextResponse.json({ proposals }); + } + + const proposals = await listProposalsForFreelancer(user.id); + return NextResponse.json({ proposals }); +} \ No newline at end of file diff --git a/app/dashboard/proposals/[projectId]/page.tsx b/app/dashboard/proposals/[projectId]/page.tsx new file mode 100644 index 0000000..de5c63e --- /dev/null +++ b/app/dashboard/proposals/[projectId]/page.tsx @@ -0,0 +1,48 @@ +import { notFound, redirect } from "next/navigation"; +import { cookies } from "next/headers"; +import { db as sql } from "@/lib/proposals/db"; +import { verifyAccessToken } from "@/lib/auth/session"; +import { ACCESS_TOKEN_COOKIE } from "@/lib/auth/constants"; +import { ProposalList } from "@/components/proposals"; + +export default async function ProposalsPage({ + params, +}: { + params: Promise<{ projectId: string }>; +}) { + const { projectId } = await params; + + const jar = await cookies(); + const token = jar.get(ACCESS_TOKEN_COOKIE)?.value; + if (!token) redirect("/login"); + + const verified = verifyAccessToken(token); + if (!verified) redirect("/login"); + + const users = (await sql` + SELECT id, role FROM users + WHERE wallet_address = ${verified.walletAddress} + LIMIT 1 + `) as { id: string; role: string }[]; + + const user = users[0]; + if (!user) notFound(); + + const isClient = user.role === "client" || user.role === "admin"; + const isFreelancer = user.role === "freelancer"; + if (!isClient && !isFreelancer) notFound(); + + return ( +
+
+

Proposals

+

+ {isClient + ? "Review, filter, and manage freelancer proposals." + : "Your submitted proposals and their status."} +

+
+ +
+ ); +} \ No newline at end of file diff --git a/components/proposals/index.ts b/components/proposals/index.ts new file mode 100644 index 0000000..d56dd20 --- /dev/null +++ b/components/proposals/index.ts @@ -0,0 +1,7 @@ +export * from "./proposal-list"; +export * from "./proposal-card"; +export * from "./proposal-filters"; +export * from "./proposal-status-badge"; +export * from "./proposal-confirm-dialog"; +export * from "./proposal-skeleton"; +export * from "./proposal-empty-state"; \ No newline at end of file diff --git a/components/proposals/proposal-card.tsx b/components/proposals/proposal-card.tsx new file mode 100644 index 0000000..52d8120 --- /dev/null +++ b/components/proposals/proposal-card.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { ProposalStatusBadge } from "./proposal-status-badge"; +import type { Proposal } from "@/lib/proposals/types"; + +interface Props { + proposal: Proposal; + readOnly?: boolean; + onAccept?: (p: Proposal) => void; + onReject?: (p: Proposal) => void; + onViewProfile?: (p: Proposal) => void; +} + +export function ProposalCard({ + proposal, + readOnly, + onAccept, + onReject, + onViewProfile, +}: Props) { + const ISO = /^[A-Z]{3}$/; + const code = (proposal.currency || "").toUpperCase(); + const budget = ISO.test(code) + ? new Intl.NumberFormat("en-US", { style: "currency", currency: code }).format(proposal.proposedBudget) + : `${proposal.proposedBudget.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${code}`; + + const initials = proposal.freelancerName + .split(" ") + .map((s) => s[0]) + .slice(0, 2) + .join("") + .toUpperCase(); + + return ( + + +
+
+ {initials} +
+
+

{proposal.freelancerName}

+
+ {proposal.freelancerRating.toFixed(1)} / 5 + · + {proposal.freelancerPastContracts} jobs +
+
+
+ +
+ + {proposal.freelancerSkills.length > 0 && ( +
+ {proposal.freelancerSkills.slice(0, 6).map((s) => ( + + {s} + + ))} +
+ )} +

+ {proposal.coverLetter} +

+
+
+ Budget: + {budget} +
+
+ Delivery: + {proposal.estimatedDuration || "-"} +
+
+
+ + {!readOnly && proposal.status === "pending" && ( + <> + + + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/components/proposals/proposal-confirm-dialog.tsx b/components/proposals/proposal-confirm-dialog.tsx new file mode 100644 index 0000000..692132b --- /dev/null +++ b/components/proposals/proposal-confirm-dialog.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +interface Props { + open: boolean; + action: "accept" | "reject" | null; + freelancerName?: string; + onCancel: () => void; + onConfirm: () => void; +} + +export function ProposalConfirmDialog({ + open, + action, + freelancerName, + onCancel, + onConfirm, +}: Props) { + const isAccept = action === "accept"; + return ( + !o && onCancel()}> + + + + {isAccept ? "Accept proposal?" : "Reject proposal?"} + + + {isAccept + ? `Accepting ${freelancerName ?? "this freelancer"}'s proposal will notify them and start the project.` + : `Rejecting ${freelancerName ?? "this freelancer"}'s proposal will notify them.`} + + + + Cancel + + {isAccept ? "Accept" : "Reject"} + + + + + ); +} \ No newline at end of file diff --git a/components/proposals/proposal-empty-state.tsx b/components/proposals/proposal-empty-state.tsx new file mode 100644 index 0000000..e99af17 --- /dev/null +++ b/components/proposals/proposal-empty-state.tsx @@ -0,0 +1,18 @@ +import { Card, CardContent } from "@/components/ui/card"; + +export function ProposalEmptyState({ filtered }: { filtered?: boolean }) { + return ( + + +

+ {filtered ? "No proposals match your filters" : "No proposals yet"} +

+

+ {filtered + ? "Try changing the status filter or search term." + : "Proposals will appear here once freelancers apply to your project."} +

+
+
+ ); +} \ No newline at end of file diff --git a/components/proposals/proposal-filters.tsx b/components/proposals/proposal-filters.tsx new file mode 100644 index 0000000..f682f3d --- /dev/null +++ b/components/proposals/proposal-filters.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { ProposalFilters as Filters } from "@/lib/proposals/types"; + +interface Props { + value: Filters; + onChange: (next: Filters) => void; +} + +export function ProposalFiltersBar({ value, onChange }: Props) { + return ( +
+ onChange({ ...value, search: e.target.value })} + className="md:max-w-xs" + /> + + +
+ ); +} \ No newline at end of file diff --git a/components/proposals/proposal-list.tsx b/components/proposals/proposal-list.tsx new file mode 100644 index 0000000..54fdac9 --- /dev/null +++ b/components/proposals/proposal-list.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { ProposalCard } from "./proposal-card"; +import { ProposalFiltersBar } from "./proposal-filters"; +import { ProposalConfirmDialog } from "./proposal-confirm-dialog"; +import { ProposalSkeleton } from "./proposal-skeleton"; +import { ProposalEmptyState } from "./proposal-empty-state"; +import type { Proposal, ProposalFilters } from "@/lib/proposals/types"; + +interface Props { + projectId: string; + readOnly?: boolean; +} + +export function ProposalList({ projectId, readOnly }: Props) { + const [proposals, setProposals] = useState([]); + const [loading, setLoading] = useState(true); + const [filters, setFilters] = useState({ + status: "all", + sortBy: "recent", + sortDir: "desc", + search: "", + }); + const [pendingAction, setPendingAction] = useState<{ + proposal: Proposal; + action: "accept" | "reject"; + } | null>(null); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + let cancelled = false; + setLoading(true); + const params = new URLSearchParams({ projectId }); + if (filters.status && filters.status !== "all") params.set("status", filters.status); + if (filters.sortBy) params.set("sortBy", filters.sortBy); + if (filters.sortDir) params.set("sortDir", filters.sortDir); + if (filters.search) params.set("search", filters.search); + + fetch(`/api/proposals?${params}`) + .then((r) => r.json()) + .then((data) => { + if (!cancelled) setProposals(data.proposals ?? []); + }) + .catch(() => toast.error("Failed to load proposals")) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [projectId, filters]); + + async function confirmAction() { + if (!pendingAction) return; + setSubmitting(true); + try { + const res = await fetch(`/api/proposals/${pendingAction.proposal.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + status: pendingAction.action === "accept" ? "accepted" : "rejected", + }), + }); + if (!res.ok) throw new Error(); + const { proposal } = await res.json(); + setProposals((prev) => + prev.map((p) => (p.id === proposal.id ? { ...p, ...proposal } : p)) + ); + toast.success(`Proposal ${pendingAction.action}ed`); + setPendingAction(null); + } catch { + toast.error("Action failed"); + } finally { + setSubmitting(false); + } + } + + return ( +
+ + + {loading ? ( +
+ + + +
+ ) : proposals.length === 0 ? ( + + ) : ( +
+ {proposals.map((p) => ( + + setPendingAction({ proposal: prop, action: "accept" }) + } + onReject={(prop) => + setPendingAction({ proposal: prop, action: "reject" }) + } + onViewProfile={(prop) => { + window.location.href = `/freelancers/${prop.freelancerId}`; + }} + /> + ))} +
+ )} + + !submitting && setPendingAction(null)} + onConfirm={confirmAction} + /> +
+ ); +} \ No newline at end of file diff --git a/components/proposals/proposal-skeleton.tsx b/components/proposals/proposal-skeleton.tsx new file mode 100644 index 0000000..c25fdc5 --- /dev/null +++ b/components/proposals/proposal-skeleton.tsx @@ -0,0 +1,17 @@ +export function ProposalSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/components/proposals/proposal-status-badge.tsx b/components/proposals/proposal-status-badge.tsx new file mode 100644 index 0000000..28e4f87 --- /dev/null +++ b/components/proposals/proposal-status-badge.tsx @@ -0,0 +1,17 @@ +import { Badge } from "@/components/ui/badge"; +import type { ProposalStatus } from "@/lib/proposals/types"; + +const STYLES: Record = { + pending: "bg-yellow-100 text-yellow-800 border-yellow-200", + accepted: "bg-green-100 text-green-800 border-green-200", + rejected: "bg-red-100 text-red-800 border-red-200", + updated: "bg-blue-100 text-blue-800 border-blue-200", +}; + +export function ProposalStatusBadge({ status }: { status: ProposalStatus }) { + return ( + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); +} \ No newline at end of file diff --git a/lib/db/migrations/010_proposals.sql b/lib/db/migrations/010_proposals.sql new file mode 100644 index 0000000..c717d5e --- /dev/null +++ b/lib/db/migrations/010_proposals.sql @@ -0,0 +1,32 @@ +CREATE TABLE IF NOT EXISTS proposals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + freelancer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + client_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + cover_letter TEXT NOT NULL, + proposed_budget NUMERIC(18,6) NOT NULL, + currency TEXT NOT NULL DEFAULT 'USDC', + estimated_duration TEXT, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','accepted','rejected','updated')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (project_id, freelancer_id) +); + +CREATE INDEX IF NOT EXISTS idx_proposals_project ON proposals(project_id); +CREATE INDEX IF NOT EXISTS idx_proposals_freelancer ON proposals(freelancer_id); +CREATE INDEX IF NOT EXISTS idx_proposals_client_status ON proposals(client_id, status); + +CREATE TABLE IF NOT EXISTS proposal_audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + proposal_id UUID NOT NULL REFERENCES proposals(id) ON DELETE CASCADE, + actor_id UUID NOT NULL REFERENCES users(id), + action TEXT NOT NULL CHECK (action IN ('accept','reject','update','create')), + previous_status TEXT, + new_status TEXT, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_proposal_audit_proposal ON proposal_audit_logs(proposal_id); \ No newline at end of file diff --git a/lib/proposals/db.ts b/lib/proposals/db.ts new file mode 100644 index 0000000..b86a2cf --- /dev/null +++ b/lib/proposals/db.ts @@ -0,0 +1,17 @@ +import { neon } from "@neondatabase/serverless"; + +let _client: ReturnType | null = null; + +function getClient() { + if (!_client) { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("DATABASE_URL not set"); + _client = neon(url); + } + return _client; +} + +// Cast to a simple callable-tagged-template type so Turbopack doesn't +// choke on the Proxy that lib/db.ts exports. +export const db = ((...args: unknown[]) => + (getClient() as unknown as (...a: unknown[]) => unknown)(...args)) as unknown as ReturnType; \ No newline at end of file diff --git a/lib/proposals/service.ts b/lib/proposals/service.ts new file mode 100644 index 0000000..66fcc05 --- /dev/null +++ b/lib/proposals/service.ts @@ -0,0 +1,152 @@ +import { db as sql } from "./db"; +import type { Proposal, ProposalFilters, ProposalStatus } from "./types"; + +interface DbRow { + id: string; + project_id: string; + project_title: string | null; + freelancer_id: string; + freelancer_name: string | null; + freelancer_skills: string[] | null; + freelancer_rating: string | number | null; + freelancer_completed: number | null; + cover_letter: string | null; + proposed_budget: string | number; + currency: string | null; + estimated_duration: string | null; + status: ProposalStatus; + created_at: string; + updated_at: string; +} + +function mapRow(r: DbRow): Proposal { + return { + id: r.id, + projectId: r.project_id, + projectTitle: r.project_title ?? "", + freelancerId: r.freelancer_id, + freelancerName: r.freelancer_name ?? "Freelancer", + freelancerSkills: Array.isArray(r.freelancer_skills) ? r.freelancer_skills : [], + freelancerRating: Number(r.freelancer_rating ?? 0), + freelancerPastContracts: Number(r.freelancer_completed ?? 0), + coverLetter: r.cover_letter ?? "", + proposedBudget: Number(r.proposed_budget), + currency: r.currency ?? "USDC", + estimatedDuration: r.estimated_duration ?? "", + status: r.status, + createdAt: r.created_at, + updatedAt: r.updated_at, + }; +} + +export async function listProposalsForClient( + clientId: string, + projectId: string, + filters: ProposalFilters = {} +): Promise { + const status = filters.status ?? "all"; + const search = (filters.search ?? "").trim().toLowerCase(); + const sortBy = filters.sortBy ?? "recent"; + const sortDir = filters.sortDir === "asc" ? "asc" : "desc"; + + // Neon tagged template: use sql\`...\` for execution. Build ORDER BY via + // a whitelist and switch through four pre-canned queries instead of unsafe(). + const orderClause = + sortBy === "budget" + ? (sortDir === "asc" ? sql`p.proposed_budget ASC` : sql`p.proposed_budget DESC`) + : sortBy === "delivery" + ? (sortDir === "asc" ? sql`p.estimated_duration ASC` : sql`p.estimated_duration DESC`) + : sortBy === "rating" + ? (sortDir === "asc" ? sql`u.avg_rating ASC` : sql`u.avg_rating DESC`) + : (sortDir === "asc" ? sql`p.created_at ASC` : sql`p.created_at DESC`); + + const hasStatus = status !== "all"; + const hasSearch = search.length > 0; + + const rows = (await sql` + SELECT + p.id, p.project_id, pr.title AS project_title, + p.freelancer_id, u.username AS freelancer_name, + u.skills AS freelancer_skills, + u.avg_rating AS freelancer_rating, + u.completed_jobs AS freelancer_completed, + p.cover_letter, p.proposed_budget, p.currency, + p.estimated_duration, p.status, p.created_at, p.updated_at + FROM proposals p + JOIN users u ON u.id = p.freelancer_id + JOIN projects pr ON pr.id = p.project_id + WHERE p.client_id = ${clientId} + AND p.project_id = ${projectId} + AND (${hasStatus} = false OR p.status = ${status}) + AND (${hasSearch} = false OR LOWER(u.username) LIKE ${"%" + search + "%"}) + ORDER BY ${orderClause} + `) as unknown as DbRow[]; + + return rows.map(mapRow); +} + +export async function listProposalsForFreelancer( + freelancerId: string +): Promise { + const rows = (await sql` + SELECT + p.id, p.project_id, pr.title AS project_title, + p.freelancer_id, u.username AS freelancer_name, + u.skills AS freelancer_skills, + u.avg_rating AS freelancer_rating, + u.completed_jobs AS freelancer_completed, + p.cover_letter, p.proposed_budget, p.currency, + p.estimated_duration, p.status, p.created_at, p.updated_at + FROM proposals p + JOIN users u ON u.id = p.freelancer_id + JOIN projects pr ON pr.id = p.project_id + WHERE p.freelancer_id = ${freelancerId} + ORDER BY p.created_at DESC + `) as unknown as DbRow[]; + + return rows.map(mapRow); +} + +export async function updateProposalStatus( + proposalId: string, + clientId: string, + next: Extract +): Promise { + const check = (await sql` + SELECT status, client_id FROM proposals WHERE id = ${proposalId} + `) as unknown as { status: ProposalStatus; client_id: string }[]; + + if (!check[0]) throw new Error("NOT_FOUND"); + if (check[0].client_id !== clientId) throw new Error("NOT_FOUND"); + const previous = check[0].status; + if (previous === next) throw new Error("NO_CHANGE"); + + await sql`UPDATE proposals SET status = ${next}, updated_at = NOW() WHERE id = ${proposalId}`; + + await sql` + INSERT INTO proposal_audit_logs + (proposal_id, actor_id, action, previous_status, new_status) + VALUES ( + ${proposalId}, ${clientId}, + ${next === "accepted" ? "accept" : "reject"}, + ${previous}, ${next} + ) + `; + + const full = (await sql` + SELECT + p.id, p.project_id, pr.title AS project_title, + p.freelancer_id, u.username AS freelancer_name, + u.skills AS freelancer_skills, + u.avg_rating AS freelancer_rating, + u.completed_jobs AS freelancer_completed, + p.cover_letter, p.proposed_budget, p.currency, + p.estimated_duration, p.status, p.created_at, p.updated_at + FROM proposals p + JOIN users u ON u.id = p.freelancer_id + JOIN projects pr ON pr.id = p.project_id + WHERE p.id = ${proposalId} + `) as unknown as DbRow[]; + + return mapRow(full[0]); +} \ No newline at end of file diff --git a/lib/proposals/types.ts b/lib/proposals/types.ts new file mode 100644 index 0000000..84a8388 --- /dev/null +++ b/lib/proposals/types.ts @@ -0,0 +1,26 @@ +export type ProposalStatus = "pending" | "accepted" | "rejected" | "updated"; + +export interface Proposal { + id: string; + projectId: string; + projectTitle: string; + freelancerId: string; + freelancerName: string; + freelancerSkills: string[]; + freelancerRating: number; + freelancerPastContracts: number; + coverLetter: string; + proposedBudget: number; + currency: string; + estimatedDuration: string; + status: ProposalStatus; + createdAt: string; + updatedAt: string; +} + +export interface ProposalFilters { + status?: ProposalStatus | "all"; + sortBy?: "budget" | "delivery" | "rating" | "recent"; + sortDir?: "asc" | "desc"; + search?: string; +} \ No newline at end of file