518 lines
19 KiB
TypeScript
518 lines
19 KiB
TypeScript
/**
|
|
* Customer Requirements Page - Wired to real backend APIs
|
|
* Endpoints:
|
|
* GET /api/customers/requirements - List customer's requirements
|
|
* POST /api/customers/requirements - Create new requirement
|
|
* PATCH /api/customers/requirements/:id - Update requirement
|
|
* DELETE /api/customers/requirements/:id - Delete requirement
|
|
*/
|
|
import { For, Show, createMemo, createSignal, onMount } from "solid-js";
|
|
import { BTN_GHOST, BTN_PRIMARY, CARD, INPUT, LABEL } from "~/components/DashboardShell";
|
|
|
|
const API = "/api";
|
|
|
|
type RequirementItem = {
|
|
id: string;
|
|
title: string;
|
|
description?: string | null;
|
|
status?: string;
|
|
budget_inr?: number | null;
|
|
area?: string | null;
|
|
location?: string | null;
|
|
tags?: string[] | null;
|
|
created_at?: string;
|
|
};
|
|
|
|
type SortKey = "newest" | "budget_desc" | "budget_asc" | "title_asc";
|
|
|
|
async function apiFetch(path: string, opts?: RequestInit) {
|
|
const token =
|
|
typeof window !== "undefined"
|
|
? window.sessionStorage.getItem("nxtgauge_access_token") || ""
|
|
: "";
|
|
const cleanPath = path.startsWith("/api/") ? path.slice(4) : path;
|
|
return fetch(`${API}${cleanPath}`, {
|
|
...opts,
|
|
credentials: "include",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
...(opts?.headers ?? {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
export default function CustomerRequirementsPage() {
|
|
const [requirements, setRequirements] = createSignal<RequirementItem[]>([]);
|
|
const [loading, setLoading] = createSignal(true);
|
|
const [busyId, setBusyId] = createSignal<string | null>(null);
|
|
const [saving, setSaving] = createSignal(false);
|
|
const [msg, setMsg] = createSignal("");
|
|
const [err, setErr] = createSignal("");
|
|
const [search, setSearch] = createSignal("");
|
|
const [sortBy, setSortBy] = createSignal<SortKey>("newest");
|
|
const [activeTag, setActiveTag] = createSignal("");
|
|
const [form, setForm] = createSignal({
|
|
title: "",
|
|
description: "",
|
|
budget_min: "",
|
|
budget_max: "",
|
|
area: "",
|
|
location: "",
|
|
tags: "",
|
|
profession_key: "",
|
|
preferred_date: "",
|
|
});
|
|
|
|
const rowTags = (row: RequirementItem) =>
|
|
Array.isArray(row.tags)
|
|
? row.tags.map((tag) => String(tag || "").trim()).filter(Boolean)
|
|
: [];
|
|
|
|
const availableTags = createMemo(() => {
|
|
const tags = new Set<string>();
|
|
for (const row of requirements()) {
|
|
for (const tag of rowTags(row)) tags.add(tag);
|
|
}
|
|
return Array.from(tags).sort((a, b) => a.localeCompare(b));
|
|
});
|
|
|
|
const filteredSortedRows = createMemo(() => {
|
|
const q = search().trim().toLowerCase();
|
|
const tag = activeTag().trim().toLowerCase();
|
|
const next = requirements().filter((row) => {
|
|
const tags = rowTags(row);
|
|
const matchesTag = !tag || tags.some((t) => t.toLowerCase() === tag);
|
|
if (!matchesTag) return false;
|
|
if (!q) return true;
|
|
return (
|
|
String(row.title || "").toLowerCase().includes(q) ||
|
|
String(row.description || "").toLowerCase().includes(q) ||
|
|
String(row.location || "").toLowerCase().includes(q) ||
|
|
String(row.area || "").toLowerCase().includes(q) ||
|
|
tags.some((t) => t.toLowerCase().includes(q))
|
|
);
|
|
});
|
|
|
|
next.sort((a, b) => {
|
|
if (sortBy() === "budget_desc") return Number(b.budget_inr || 0) - Number(a.budget_inr || 0);
|
|
if (sortBy() === "budget_asc") return Number(a.budget_inr || 0) - Number(b.budget_inr || 0);
|
|
if (sortBy() === "title_asc") return String(a.title || "").localeCompare(String(b.title || ""));
|
|
return new Date(String(b.created_at || 0)).getTime() - new Date(String(a.created_at || 0)).getTime();
|
|
});
|
|
return next;
|
|
});
|
|
|
|
const loadRequirements = async () => {
|
|
setLoading(true);
|
|
setErr("");
|
|
try {
|
|
const res = await apiFetch("/api/customers/requirements?page=1&limit=100");
|
|
const payload = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
setErr(payload.error || payload.message || "Failed to load requirements.");
|
|
setRequirements([]);
|
|
return;
|
|
}
|
|
setRequirements(Array.isArray(payload?.data) ? payload.data : []);
|
|
} catch {
|
|
setErr("Network error while loading requirements.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
onMount(loadRequirements);
|
|
|
|
const setField = (key: keyof ReturnType<typeof form>, value: string) =>
|
|
setForm((prev) => ({ ...prev, [key]: value }));
|
|
|
|
const createRequirement = async () => {
|
|
setSaving(true);
|
|
setMsg("");
|
|
setErr("");
|
|
try {
|
|
const payload = {
|
|
profession_key: form().profession_key,
|
|
title: form().title.trim(),
|
|
description: form().description.trim() || "",
|
|
budget:
|
|
form().budget_min || form().budget_max
|
|
? Number(form().budget_min) || Number(form().budget_max)
|
|
: undefined,
|
|
area: form().area.trim() || undefined,
|
|
location: form().location.trim() || "",
|
|
tags: form().tags.split(",").map((t) => t.trim()).filter(Boolean),
|
|
preferred_date: form().preferred_date || undefined,
|
|
};
|
|
const res = await apiFetch("/api/customers/requirements", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
setErr(data.error || data.message || "Failed to create requirement.");
|
|
return;
|
|
}
|
|
setMsg("Requirement created.");
|
|
setForm({
|
|
title: "",
|
|
description: "",
|
|
budget_min: "",
|
|
budget_max: "",
|
|
area: "",
|
|
location: "",
|
|
tags: "",
|
|
profession_key: "",
|
|
preferred_date: "",
|
|
});
|
|
await loadRequirements();
|
|
} catch {
|
|
setErr("Network error while creating requirement.");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const submitRequirement = async (id: string) => {
|
|
setBusyId(id);
|
|
setMsg("");
|
|
setErr("");
|
|
try {
|
|
const res = await apiFetch(`/api/customers/requirements/${id}/submit`, { method: "POST" });
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
setErr(data.error || data.message || "Failed to submit requirement.");
|
|
return;
|
|
}
|
|
setMsg("Requirement submitted to verification.");
|
|
await loadRequirements();
|
|
} catch {
|
|
setErr("Network error while submitting requirement.");
|
|
} finally {
|
|
setBusyId(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div style={{ display: "grid", gap: "14px", "max-width": "980px" }}>
|
|
<div style={CARD}>
|
|
<p style={{ margin: "0", "font-size": "18px", "font-weight": "800", color: "#111827" }}>
|
|
My Requirements
|
|
</p>
|
|
<p style={{ margin: "4px 0 0", "font-size": "13px", color: "#6B7280" }}>
|
|
Create requirements. They move to verification first, then final approval.
|
|
</p>
|
|
<p style={{ margin: "6px 0 0", "font-size": "12px", color: "#F59E0B", "font-weight": "600" }}>
|
|
{requirements().length} of 2 free requirements used. Additional requirements may require a paid plan.
|
|
</p>
|
|
</div>
|
|
|
|
<div style={CARD}>
|
|
<p
|
|
style={{
|
|
margin: "0 0 10px",
|
|
"font-size": "16px",
|
|
"font-weight": "700",
|
|
color: "#111827",
|
|
}}
|
|
>
|
|
Post New Requirement
|
|
</p>
|
|
<div style={{ display: "grid", "grid-template-columns": "1fr 1fr", gap: "12px" }}>
|
|
<div style={{ "grid-column": "1 / -1" }}>
|
|
<label style={LABEL}>Title</label>
|
|
<input
|
|
value={form().title}
|
|
onInput={(e) => setField("title", e.currentTarget.value)}
|
|
style={INPUT}
|
|
placeholder="Wedding photographer in Chennai"
|
|
/>
|
|
</div>
|
|
<div style={{ "grid-column": "1 / -1" }}>
|
|
<label style={LABEL}>Description</label>
|
|
<textarea
|
|
rows={3}
|
|
value={form().description}
|
|
onInput={(e) => setField("description", e.currentTarget.value)}
|
|
style={{ ...INPUT, height: "auto", padding: "10px 12px", resize: "vertical" }}
|
|
placeholder="Describe what you need"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label style={LABEL}>Budget Min</label>
|
|
<input
|
|
value={form().budget_min}
|
|
onInput={(e) => setField("budget_min", e.currentTarget.value)}
|
|
style={INPUT}
|
|
placeholder="10000"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label style={LABEL}>Budget Max</label>
|
|
<input
|
|
value={form().budget_max}
|
|
onInput={(e) => setField("budget_max", e.currentTarget.value)}
|
|
style={INPUT}
|
|
placeholder="50000"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label style={LABEL}>Area</label>
|
|
<input
|
|
value={form().area}
|
|
onInput={(e) => setField("area", e.currentTarget.value)}
|
|
style={INPUT}
|
|
placeholder="T Nagar"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label style={LABEL}>Location</label>
|
|
<input
|
|
value={form().location}
|
|
onInput={(e) => setField("location", e.currentTarget.value)}
|
|
style={INPUT}
|
|
placeholder="Chennai"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label style={LABEL}>Service Type <span style={{ color: "#EF4444" }}>*</span></label>
|
|
<select
|
|
value={form().profession_key}
|
|
onChange={(e) => setField("profession_key", e.currentTarget.value)}
|
|
style={{ ...INPUT, height: "38px" }}
|
|
>
|
|
<option value="">Select a service...</option>
|
|
<option value="PHOTOGRAPHER">Photographer</option>
|
|
<option value="MAKEUP_ARTIST">Makeup Artist</option>
|
|
<option value="TUTOR">Tutor</option>
|
|
<option value="DEVELOPER">Developer</option>
|
|
<option value="VIDEO_EDITOR">Video Editor</option>
|
|
<option value="UGC_CONTENT_CREATOR">UGC Content Creator</option>
|
|
<option value="GRAPHIC_DESIGNER">Graphic Designer</option>
|
|
<option value="SOCIAL_MEDIA_MANAGER">Social Media Manager</option>
|
|
<option value="FITNESS_TRAINER">Fitness Trainer</option>
|
|
<option value="CATERING_SERVICES">Catering Services</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label style={LABEL}>Preferred Date</label>
|
|
<input
|
|
type="date"
|
|
value={form().preferred_date}
|
|
onInput={(e) => setField("preferred_date", e.currentTarget.value)}
|
|
style={INPUT}
|
|
/>
|
|
</div>
|
|
<div style={{ "grid-column": "1 / -1" }}>
|
|
<label style={LABEL}>Tags (comma separated)</label>
|
|
<input
|
|
value={form().tags}
|
|
onInput={(e) => setField("tags", e.currentTarget.value)}
|
|
style={INPUT}
|
|
placeholder="e.g. wedding, candid, weekend"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div style={{ display: "flex", "justify-content": "flex-end", "margin-top": "12px" }}>
|
|
<button
|
|
type="button"
|
|
onClick={createRequirement}
|
|
disabled={saving() || !form().title.trim() || !form().profession_key}
|
|
style={{ ...BTN_PRIMARY, opacity: saving() ? "0.7" : "1" }}
|
|
>
|
|
{saving() ? "Posting..." : "Post Requirement"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<Show when={msg()}>
|
|
<div
|
|
style={{
|
|
...CARD,
|
|
border: "1px solid #BBF7D0",
|
|
background: "#ECFDF5",
|
|
padding: "12px 14px",
|
|
color: "#065F46",
|
|
"font-size": "13px",
|
|
"font-weight": "600",
|
|
}}
|
|
>
|
|
{msg()}
|
|
</div>
|
|
</Show>
|
|
<Show when={err()}>
|
|
<div
|
|
style={{
|
|
...CARD,
|
|
border: "1px solid #FECACA",
|
|
background: "#FEF2F2",
|
|
padding: "12px 14px",
|
|
color: "#B91C1C",
|
|
"font-size": "13px",
|
|
"font-weight": "600",
|
|
}}
|
|
>
|
|
{err()}
|
|
</div>
|
|
</Show>
|
|
|
|
<div style={CARD}>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
"justify-content": "space-between",
|
|
"align-items": "center",
|
|
"margin-bottom": "10px",
|
|
gap: "10px",
|
|
"flex-wrap": "wrap",
|
|
}}
|
|
>
|
|
<p style={{ margin: "0", "font-size": "16px", "font-weight": "700", color: "#111827" }}>
|
|
My Requirement List
|
|
</p>
|
|
<button type="button" onClick={loadRequirements} style={BTN_GHOST}>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
<div style={{ display: "grid", gap: "10px", "margin-bottom": "12px" }}>
|
|
<div style={{ display: "grid", "grid-template-columns": "1fr 180px", gap: "10px" }}>
|
|
<input
|
|
value={search()}
|
|
onInput={(e) => setSearch(e.currentTarget.value)}
|
|
style={INPUT}
|
|
placeholder="Search by title, location, area, description, tags"
|
|
/>
|
|
<select value={sortBy()} onChange={(e) => setSortBy(e.currentTarget.value as SortKey)} style={INPUT}>
|
|
<option value="newest">Sort: Newest</option>
|
|
<option value="budget_desc">Budget High to Low</option>
|
|
<option value="budget_asc">Budget Low to High</option>
|
|
<option value="title_asc">Title A-Z</option>
|
|
</select>
|
|
</div>
|
|
<Show when={availableTags().length > 0}>
|
|
<div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap" }}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setActiveTag("")}
|
|
style={{ ...BTN_GHOST, height: "28px", padding: "0 10px", "font-size": "11px", ...(activeTag() ? {} : { border: "1px solid #0D0D2A", color: "#0D0D2A" }) }}
|
|
>
|
|
All Tags
|
|
</button>
|
|
<For each={availableTags()}>
|
|
{(tag) => (
|
|
<button
|
|
type="button"
|
|
onClick={() => setActiveTag(tag)}
|
|
style={{ ...BTN_GHOST, height: "28px", padding: "0 10px", "font-size": "11px", ...(activeTag() === tag ? { border: "1px solid #0D0D2A", color: "#0D0D2A" } : {}) }}
|
|
>
|
|
{tag}
|
|
</button>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
<Show when={loading()}>
|
|
<p style={{ margin: "0", color: "#9CA3AF", "font-size": "13px" }}>
|
|
Loading requirements...
|
|
</p>
|
|
</Show>
|
|
<Show when={!loading() && filteredSortedRows().length === 0}>
|
|
<p style={{ margin: "0", color: "#6B7280", "font-size": "13px" }}>
|
|
No requirements match your filters.
|
|
</p>
|
|
</Show>
|
|
<Show when={!loading() && filteredSortedRows().length > 0}>
|
|
<div style={{ display: "grid", gap: "10px" }}>
|
|
<For each={filteredSortedRows()}>
|
|
{(row) => (
|
|
<div
|
|
style={{
|
|
border: "1px solid #E5E7EB",
|
|
"border-radius": "12px",
|
|
padding: "12px",
|
|
background: "#FCFCFD",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
"justify-content": "space-between",
|
|
gap: "10px",
|
|
"flex-wrap": "wrap",
|
|
}}
|
|
>
|
|
<div>
|
|
<p
|
|
style={{
|
|
margin: "0",
|
|
"font-size": "14px",
|
|
"font-weight": "800",
|
|
color: "#111827",
|
|
}}
|
|
>
|
|
{row.title}
|
|
</p>
|
|
<p style={{ margin: "4px 0 0", "font-size": "12px", color: "#6B7280" }}>
|
|
{row.location || "—"} {row.area ? `• ${row.area}` : ""}{" "}
|
|
{row.created_at
|
|
? `• ${new Date(row.created_at).toLocaleString("en-IN")}`
|
|
: ""}
|
|
</p>
|
|
</div>
|
|
<span
|
|
style={{
|
|
display: "inline-flex",
|
|
height: "24px",
|
|
"align-items": "center",
|
|
padding: "0 10px",
|
|
"border-radius": "999px",
|
|
background: "#EEF2FF",
|
|
color: "#3730A3",
|
|
"font-size": "11px",
|
|
"font-weight": "700",
|
|
}}
|
|
>
|
|
{String(row.status || "DRAFT").replace(/_/g, " ")}
|
|
</span>
|
|
</div>
|
|
<p style={{ margin: "8px 0 0", "font-size": "13px", color: "#374151" }}>
|
|
{row.description || "No description added."}
|
|
</p>
|
|
<Show when={rowTags(row).length > 0}>
|
|
<div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap", "margin-top": "8px" }}>
|
|
<For each={rowTags(row).slice(0, 6)}>
|
|
{(tag) => (
|
|
<span style={{ height: "22px", display: "inline-flex", "align-items": "center", padding: "0 8px", "border-radius": "999px", border: "1px solid #E5E7EB", background: "#F9FAFB", "font-size": "11px", color: "#374151" }}>{tag}</span>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</Show>
|
|
<div
|
|
style={{ display: "flex", "justify-content": "flex-end", "margin-top": "10px" }}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => submitRequirement(row.id)}
|
|
disabled={busyId() === row.id}
|
|
style={{
|
|
...BTN_PRIMARY,
|
|
height: "32px",
|
|
"font-size": "12px",
|
|
padding: "0 12px",
|
|
opacity: busyId() === row.id ? "0.7" : "1",
|
|
}}
|
|
>
|
|
{busyId() === row.id ? "Submitting..." : "Submit for Verification"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|