feat(history): allow filtering PO history by multiple statuses
The PO history page previously allowed only a single status filter. This
enhances it to accept multiple statuses that are OR-ed together (e.g.
Closed + Approved shows all POs in either state), as requested.
- Status filter is now a multi-select checkbox dropdown that serialises
selections as repeated `status` query params.
- History page and the reports export endpoint read all `status` values
and query with `status: { in: [...] }` (OR semantics).
- Single-status and no-status cases remain unchanged.
Verified OR-query semantics against the test DB and confirmed both routes
compile and respond. type-check passes for the changed files.
Fixes #31
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7daf3091bc
commit
e94c7f99a3
3 changed files with 55 additions and 16 deletions
|
|
@ -1,10 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
const STATUSES = [
|
const STATUSES = [
|
||||||
{ value: "", label: "All statuses" },
|
|
||||||
{ value: "DRAFT", label: "Draft" },
|
{ value: "DRAFT", label: "Draft" },
|
||||||
{ value: "SUBMITTED", label: "Submitted" },
|
{ value: "SUBMITTED", label: "Submitted" },
|
||||||
{ value: "MGR_REVIEW", label: "Pending Approval" },
|
{ value: "MGR_REVIEW", label: "Pending Approval" },
|
||||||
|
|
@ -28,23 +27,48 @@ export function HistoryFilters({ vessels }: Props) {
|
||||||
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
|
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
|
||||||
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
|
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
|
||||||
const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? "");
|
const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? "");
|
||||||
const [status, setStatus] = useState(sp.get("status") ?? "");
|
const [statuses, setStatuses] = useState<string[]>(sp.getAll("status"));
|
||||||
|
const [statusOpen, setStatusOpen] = useState(false);
|
||||||
|
const statusRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onClick(e: MouseEvent) {
|
||||||
|
if (statusRef.current && !statusRef.current.contains(e.target as Node)) {
|
||||||
|
setStatusOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", onClick);
|
||||||
|
return () => document.removeEventListener("mousedown", onClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function toggleStatus(value: string) {
|
||||||
|
setStatuses((prev) =>
|
||||||
|
prev.includes(value) ? prev.filter((s) => s !== value) : [...prev, value]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function apply() {
|
function apply() {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (dateFrom) params.set("dateFrom", dateFrom);
|
if (dateFrom) params.set("dateFrom", dateFrom);
|
||||||
if (dateTo) params.set("dateTo", dateTo);
|
if (dateTo) params.set("dateTo", dateTo);
|
||||||
if (vesselId) params.set("vesselId", vesselId);
|
if (vesselId) params.set("vesselId", vesselId);
|
||||||
if (status) params.set("status", status);
|
for (const s of statuses) params.append("status", s);
|
||||||
router.push(`/history?${params.toString()}`);
|
router.push(`/history?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
setDateFrom(""); setDateTo(""); setVesselId(""); setStatus("");
|
setDateFrom(""); setDateTo(""); setVesselId(""); setStatuses([]);
|
||||||
router.push("/history");
|
router.push("/history");
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasFilters = dateFrom || dateTo || vesselId || status;
|
const hasFilters = dateFrom || dateTo || vesselId || statuses.length > 0;
|
||||||
|
|
||||||
|
const statusLabel =
|
||||||
|
statuses.length === 0
|
||||||
|
? "All statuses"
|
||||||
|
: statuses.length === 1
|
||||||
|
? (STATUSES.find((s) => s.value === statuses[0])?.label ?? statuses[0])
|
||||||
|
: `${statuses.length} statuses`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 rounded-lg border border-neutral-200 bg-white p-4">
|
<div className="mb-4 rounded-lg border border-neutral-200 bg-white p-4">
|
||||||
|
|
@ -67,12 +91,26 @@ export function HistoryFilters({ vessels }: Props) {
|
||||||
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="relative" ref={statusRef}>
|
||||||
<label className="block text-xs font-medium text-neutral-600 mb-1">Status</label>
|
<label className="block text-xs font-medium text-neutral-600 mb-1">Status</label>
|
||||||
<select value={status} onChange={(e) => setStatus(e.target.value)}
|
<button type="button" onClick={() => setStatusOpen((o) => !o)}
|
||||||
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20">
|
className="flex w-full items-center justify-between rounded-lg border border-neutral-300 px-3 py-2 text-left text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20">
|
||||||
{STATUSES.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
|
<span className={statuses.length === 0 ? "text-neutral-500" : "text-neutral-900"}>{statusLabel}</span>
|
||||||
</select>
|
<svg className="ml-2 h-4 w-4 shrink-0 text-neutral-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fillRule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.17l3.71-3.94a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{statusOpen && (
|
||||||
|
<div className="absolute z-10 mt-1 max-h-64 w-full overflow-auto rounded-lg border border-neutral-200 bg-white py-1 shadow-lg">
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<label key={s.value} className="flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm hover:bg-neutral-50">
|
||||||
|
<input type="checkbox" checked={statuses.includes(s.value)} onChange={() => toggleStatus(s.value)}
|
||||||
|
className="h-4 w-4 rounded border-neutral-300 text-primary-600 focus:ring-primary-500/20" />
|
||||||
|
<span className="text-neutral-700">{s.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex items-center gap-2">
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ interface Props {
|
||||||
dateFrom?: string;
|
dateFrom?: string;
|
||||||
dateTo?: string;
|
dateTo?: string;
|
||||||
vesselId?: string;
|
vesselId?: string;
|
||||||
status?: string;
|
status?: string | string[];
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,7 +41,8 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
where.createdAt = createdAt;
|
where.createdAt = createdAt;
|
||||||
}
|
}
|
||||||
if (vesselId) where.vesselId = vesselId;
|
if (vesselId) where.vesselId = vesselId;
|
||||||
if (status) where.status = status as POStatus;
|
const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean);
|
||||||
|
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
|
||||||
|
|
||||||
const [orders, vessels] = await Promise.all([
|
const [orders, vessels] = await Promise.all([
|
||||||
db.purchaseOrder.findMany({
|
db.purchaseOrder.findMany({
|
||||||
|
|
@ -57,7 +58,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
if (dateFrom) exportParams.set("dateFrom", dateFrom);
|
if (dateFrom) exportParams.set("dateFrom", dateFrom);
|
||||||
if (dateTo) exportParams.set("dateTo", dateTo);
|
if (dateTo) exportParams.set("dateTo", dateTo);
|
||||||
if (vesselId) exportParams.set("vesselId", vesselId);
|
if (vesselId) exportParams.set("vesselId", vesselId);
|
||||||
if (status) exportParams.set("status", status);
|
for (const s of statuses) exportParams.append("status", s);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export async function GET(request: NextRequest) {
|
||||||
const dateFrom = sp.get("dateFrom");
|
const dateFrom = sp.get("dateFrom");
|
||||||
const dateTo = sp.get("dateTo");
|
const dateTo = sp.get("dateTo");
|
||||||
const vesselId = sp.get("vesselId");
|
const vesselId = sp.get("vesselId");
|
||||||
const status = sp.get("status");
|
const statuses = sp.getAll("status").filter(Boolean);
|
||||||
|
|
||||||
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
||||||
if (dateFrom || dateTo) {
|
if (dateFrom || dateTo) {
|
||||||
|
|
@ -39,7 +39,7 @@ export async function GET(request: NextRequest) {
|
||||||
where.createdAt = createdAt;
|
where.createdAt = createdAt;
|
||||||
}
|
}
|
||||||
if (vesselId) where.vesselId = vesselId;
|
if (vesselId) where.vesselId = vesselId;
|
||||||
if (status) where.status = status as POStatus;
|
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
|
||||||
|
|
||||||
const orders = await db.purchaseOrder.findMany({
|
const orders = await db.purchaseOrder.findMany({
|
||||||
where,
|
where,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue