feat(jobs): filter + search on inbound and outbound lists
The inbox pages were single-table dumps of every job ever, which gets
useless past ~50 records. Added a filter bar driven by URL search params
so links and back-button work properly.
- listInboundJobs / listOutboundJobs accept a JobListFilters arg
({ status, location, q }). Status and location push down to
Appwrite via Query.equal; q is applied in-memory against
patientCode + counterpart company name (case-insensitive,
locale-aware via Turkish toLocaleLowerCase). Both list functions
delegate to a shared listJobsFor() so they can't drift apart.
- JobsFilterBar (client): debounced text input (250ms) for q,
Select for status (all/pending/in_progress/sent/delivered/cancelled)
and location (all/at_lab/at_clinic). All three commit to the URL
via router.replace inside startTransition so the table re-renders
with the server data without a full reload. 'Temizle' button
appears once any filter is active.
- /jobs/inbound and /jobs/outbound now read searchParams (awaited per
Next 16 conventions), pass them as filters, and render the bar
above the table. Empty state copy points to the filters so users
don't think the system lost their jobs.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { Search, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "all", label: "Tüm durumlar" },
|
||||
{ value: "pending", label: "Bekliyor" },
|
||||
{ value: "in_progress", label: "İşlemde" },
|
||||
{ value: "sent", label: "Gönderildi" },
|
||||
{ value: "delivered", label: "Teslim alındı" },
|
||||
{ value: "cancelled", label: "İptal" },
|
||||
];
|
||||
|
||||
const LOCATION_OPTIONS = [
|
||||
{ value: "all", label: "Her yerde" },
|
||||
{ value: "at_lab", label: "Laboratuvarda" },
|
||||
{ value: "at_clinic", label: "Klinikte" },
|
||||
];
|
||||
|
||||
export function JobsFilterBar() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [q, setQ] = useState(params.get("q") ?? "");
|
||||
const status = params.get("status") ?? "all";
|
||||
const location = params.get("location") ?? "all";
|
||||
|
||||
// Debounce text input so we don't refetch on every keystroke.
|
||||
useEffect(() => {
|
||||
const current = params.get("q") ?? "";
|
||||
if (current === q) return;
|
||||
const t = setTimeout(() => commit({ q }), 250);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [q]);
|
||||
|
||||
function commit(patch: Record<string, string | undefined>) {
|
||||
const next = new URLSearchParams(params.toString());
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (!value || value === "all") next.delete(key);
|
||||
else next.set(key, value);
|
||||
}
|
||||
startTransition(() => {
|
||||
router.replace(`${pathname}?${next.toString()}`);
|
||||
});
|
||||
}
|
||||
|
||||
const isFiltering =
|
||||
q.trim() !== "" || status !== "all" || location !== "all";
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_180px_180px_auto]">
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute left-3 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Hasta kodu veya karşı taraf ara..."
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={status} onValueChange={(v) => commit({ status: v })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={location} onValueChange={(v) => commit({ location: v })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOCATION_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isFiltering && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setQ("");
|
||||
commit({ q: undefined, status: undefined, location: undefined });
|
||||
}}
|
||||
>
|
||||
<X className="size-4" />
|
||||
Temizle
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,18 @@ import { redirect } from "next/navigation";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { listInboundJobs } from "@/lib/appwrite/job-queries";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { JobsFilterBar } from "../_components/jobs-filter-bar";
|
||||
import { JobsTable } from "../_components/jobs-table";
|
||||
|
||||
export const metadata = {
|
||||
title: "DLS — Gelen İşler",
|
||||
};
|
||||
|
||||
export default async function InboundJobsPage() {
|
||||
export default async function InboundJobsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ status?: string; location?: string; q?: string }>;
|
||||
}) {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
@@ -17,10 +22,13 @@ export default async function InboundJobsPage() {
|
||||
redirect("/onboarding");
|
||||
}
|
||||
|
||||
// Inbound = jobs where this tenant is the lab side.
|
||||
// A clinic tenant can also receive jobs only via labTenantId match, which
|
||||
// would be unusual; we still surface whatever matches.
|
||||
const rows = ctx.kind === "lab" ? await listInboundJobs(ctx.tenantId) : [];
|
||||
const sp = await searchParams;
|
||||
const filters = {
|
||||
status: sp.status && sp.status !== "all" ? sp.status : undefined,
|
||||
location: sp.location && sp.location !== "all" ? sp.location : undefined,
|
||||
q: sp.q,
|
||||
};
|
||||
const rows = ctx.kind === "lab" ? await listInboundJobs(ctx.tenantId, filters) : [];
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-6 px-6">
|
||||
@@ -37,18 +45,21 @@ export default async function InboundJobsPage() {
|
||||
<CardDescription>
|
||||
{ctx.kind === "lab"
|
||||
? rows.length === 0
|
||||
? "Henüz gelen iş yok."
|
||||
? "Filtreye uyan iş yok."
|
||||
: `${rows.length} kalem`
|
||||
: "Bu sayfa laboratuvar hesapları içindir."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="space-y-4">
|
||||
{ctx.kind === "lab" ? (
|
||||
<JobsTable
|
||||
rows={rows}
|
||||
counterpartLabel="Klinik"
|
||||
emptyMessage="Henüz size gönderilmiş iş yok. Klinik tarafa Bağlantı Kodunuzu paylaşın."
|
||||
/>
|
||||
<>
|
||||
<JobsFilterBar />
|
||||
<JobsTable
|
||||
rows={rows}
|
||||
counterpartLabel="Klinik"
|
||||
emptyMessage="Filtreye uyan iş yok. Üstteki filtreleri değiştirin veya temizleyin."
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||
Klinik hesabıyla giriş yaptınız — gelen iş listesi sadece laboratuvar tarafında görünür.
|
||||
|
||||
@@ -3,13 +3,18 @@ import { redirect } from "next/navigation";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { listOutboundJobs } from "@/lib/appwrite/job-queries";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { JobsFilterBar } from "../_components/jobs-filter-bar";
|
||||
import { JobsTable } from "../_components/jobs-table";
|
||||
|
||||
export const metadata = {
|
||||
title: "DLS — Giden İşler",
|
||||
};
|
||||
|
||||
export default async function OutboundJobsPage() {
|
||||
export default async function OutboundJobsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ status?: string; location?: string; q?: string }>;
|
||||
}) {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
@@ -17,7 +22,13 @@ export default async function OutboundJobsPage() {
|
||||
redirect("/onboarding");
|
||||
}
|
||||
|
||||
const rows = ctx.kind === "clinic" ? await listOutboundJobs(ctx.tenantId) : [];
|
||||
const sp = await searchParams;
|
||||
const filters = {
|
||||
status: sp.status && sp.status !== "all" ? sp.status : undefined,
|
||||
location: sp.location && sp.location !== "all" ? sp.location : undefined,
|
||||
q: sp.q,
|
||||
};
|
||||
const rows = ctx.kind === "clinic" ? await listOutboundJobs(ctx.tenantId, filters) : [];
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-6 px-6">
|
||||
@@ -34,18 +45,21 @@ export default async function OutboundJobsPage() {
|
||||
<CardDescription>
|
||||
{ctx.kind === "clinic"
|
||||
? rows.length === 0
|
||||
? "Henüz iş göndermediniz."
|
||||
? "Filtreye uyan iş yok."
|
||||
: `${rows.length} kalem`
|
||||
: "Bu sayfa klinik hesapları içindir."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="space-y-4">
|
||||
{ctx.kind === "clinic" ? (
|
||||
<JobsTable
|
||||
rows={rows}
|
||||
counterpartLabel="Laboratuvar"
|
||||
emptyMessage="Henüz iş göndermediniz. 'Yeni İş Yayınla' butonundan başlayabilirsiniz."
|
||||
/>
|
||||
<>
|
||||
<JobsFilterBar />
|
||||
<JobsTable
|
||||
rows={rows}
|
||||
counterpartLabel="Laboratuvar"
|
||||
emptyMessage="Filtreye uyan iş yok. Üstteki filtreleri değiştirin veya temizleyin."
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||
Laboratuvar hesabıyla giriş yaptınız — giden iş listesi sadece klinik tarafında görünür.
|
||||
|
||||
Reference in New Issue
Block a user