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";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const STATUSES = [
|
||||
{ value: "", label: "All statuses" },
|
||||
{ value: "DRAFT", label: "Draft" },
|
||||
{ value: "SUBMITTED", label: "Submitted" },
|
||||
{ value: "MGR_REVIEW", label: "Pending Approval" },
|
||||
|
|
@ -28,23 +27,48 @@ export function HistoryFilters({ vessels }: Props) {
|
|||
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
|
||||
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
|
||||
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() {
|
||||
const params = new URLSearchParams();
|
||||
if (dateFrom) params.set("dateFrom", dateFrom);
|
||||
if (dateTo) params.set("dateTo", dateTo);
|
||||
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()}`);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
setDateFrom(""); setDateTo(""); setVesselId(""); setStatus("");
|
||||
setDateFrom(""); setDateTo(""); setVesselId(""); setStatuses([]);
|
||||
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 (
|
||||
<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>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div className="relative" ref={statusRef}>
|
||||
<label className="block text-xs font-medium text-neutral-600 mb-1">Status</label>
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)}
|
||||
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">
|
||||
{STATUSES.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
<button type="button" onClick={() => setStatusOpen((o) => !o)}
|
||||
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">
|
||||
<span className={statuses.length === 0 ? "text-neutral-500" : "text-neutral-900"}>{statusLabel}</span>
|
||||
<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 className="mt-3 flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ interface Props {
|
|||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
vesselId?: string;
|
||||
status?: string;
|
||||
status?: string | string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +41,8 @@ export default async function HistoryPage({ searchParams }: Props) {
|
|||
where.createdAt = createdAt;
|
||||
}
|
||||
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([
|
||||
db.purchaseOrder.findMany({
|
||||
|
|
@ -57,7 +58,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
|||
if (dateFrom) exportParams.set("dateFrom", dateFrom);
|
||||
if (dateTo) exportParams.set("dateTo", dateTo);
|
||||
if (vesselId) exportParams.set("vesselId", vesselId);
|
||||
if (status) exportParams.set("status", status);
|
||||
for (const s of statuses) exportParams.append("status", s);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export async function GET(request: NextRequest) {
|
|||
const dateFrom = sp.get("dateFrom");
|
||||
const dateTo = sp.get("dateTo");
|
||||
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"] = {};
|
||||
if (dateFrom || dateTo) {
|
||||
|
|
@ -39,7 +39,7 @@ export async function GET(request: NextRequest) {
|
|||
where.createdAt = createdAt;
|
||||
}
|
||||
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({
|
||||
where,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue