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:
kovakmedya
2026-05-22 16:08:06 +03:00
parent 503a98fcb3
commit df02ea7107
4 changed files with 213 additions and 44 deletions
@@ -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>
);
}
+23 -12
View File
@@ -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 listesi sadece laboratuvar tarafında görünür.
+23 -9
View File
@@ -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 listesi sadece klinik tarafında görünür.
+53 -23
View File
@@ -45,38 +45,68 @@ function enrichJob(j: Job, counterpartId: string, map: Map<string, JobCounterpar
return { ...j, counterpart: map.get(counterpartId) ?? null };
}
/** Inbound for a lab tenant — jobs the lab has received. */
export async function listInboundJobs(labTenantId: string): Promise<JobWithCounterpart[]> {
export type JobListFilters = {
status?: string;
location?: string;
/** Free-text matched client-side against patientCode + counterpart name. */
q?: string;
};
async function listJobsFor(
side: "lab" | "clinic",
tenantId: string,
filters: JobListFilters = {},
): Promise<JobWithCounterpart[]> {
const { tablesDB } = createAdminClient();
const sideField = side === "lab" ? "labTenantId" : "clinicTenantId";
const queries = [
Query.equal(sideField, tenantId),
Query.orderDesc("$createdAt"),
Query.limit(200),
];
if (filters.status) queries.unshift(Query.equal("status", filters.status));
if (filters.location) queries.unshift(Query.equal("location", filters.location));
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.jobs,
queries: [
Query.equal("labTenantId", labTenantId),
Query.orderDesc("$createdAt"),
Query.limit(200),
],
queries,
});
const jobs = result.rows as unknown as Job[];
const map = await fetchTenants(Array.from(new Set(jobs.map((j) => j.clinicTenantId))));
return toPlain(jobs.map((j) => enrichJob(j, j.clinicTenantId, map)));
const counterpartField = side === "lab" ? "clinicTenantId" : "labTenantId";
const map = await fetchTenants(
Array.from(new Set(jobs.map((j) => j[counterpartField]))),
);
const enriched = jobs.map((j) => enrichJob(j, j[counterpartField], map));
// Free-text filter applied after fetch — only against the fields a user
// would actually type (patient code, counterpart company name).
const q = filters.q?.trim().toLocaleLowerCase("tr-TR");
const filtered = q
? enriched.filter((j) => {
const hay = `${j.patientCode} ${j.counterpart?.companyName ?? ""}`
.toLocaleLowerCase("tr-TR");
return hay.includes(q);
})
: enriched;
return toPlain(filtered);
}
/** Inbound for a lab tenant — jobs the lab has received. */
export async function listInboundJobs(
labTenantId: string,
filters: JobListFilters = {},
): Promise<JobWithCounterpart[]> {
return listJobsFor("lab", labTenantId, filters);
}
/** Outbound for a clinic tenant — jobs the clinic has sent. */
export async function listOutboundJobs(clinicTenantId: string): Promise<JobWithCounterpart[]> {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.jobs,
queries: [
Query.equal("clinicTenantId", clinicTenantId),
Query.orderDesc("$createdAt"),
Query.limit(200),
],
});
const jobs = result.rows as unknown as Job[];
const map = await fetchTenants(Array.from(new Set(jobs.map((j) => j.labTenantId))));
return toPlain(jobs.map((j) => enrichJob(j, j.labTenantId, map)));
export async function listOutboundJobs(
clinicTenantId: string,
filters: JobListFilters = {},
): Promise<JobWithCounterpart[]> {
return listJobsFor("clinic", clinicTenantId, filters);
}
/** For clinic's "Yeni İş Yayınla" form: list approved labs they can send to. */