From df02ea71072451915591bbce491f9ae914404780 Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Fri, 22 May 2026 16:08:06 +0300 Subject: [PATCH] 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. --- .../jobs/_components/jobs-filter-bar.tsx | 114 ++++++++++++++++++ src/app/(dashboard)/jobs/inbound/page.tsx | 35 ++++-- src/app/(dashboard)/jobs/outbound/page.tsx | 32 +++-- src/lib/appwrite/job-queries.ts | 76 ++++++++---- 4 files changed, 213 insertions(+), 44 deletions(-) create mode 100644 src/app/(dashboard)/jobs/_components/jobs-filter-bar.tsx diff --git a/src/app/(dashboard)/jobs/_components/jobs-filter-bar.tsx b/src/app/(dashboard)/jobs/_components/jobs-filter-bar.tsx new file mode 100644 index 0000000..a0b0b2c --- /dev/null +++ b/src/app/(dashboard)/jobs/_components/jobs-filter-bar.tsx @@ -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) { + 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 ( +
+
+ + setQ(e.target.value)} + placeholder="Hasta kodu veya karşı taraf ara..." + className="pl-9" + /> +
+ + + {isFiltering && ( + + )} +
+ ); +} diff --git a/src/app/(dashboard)/jobs/inbound/page.tsx b/src/app/(dashboard)/jobs/inbound/page.tsx index 6f78094..6482507 100644 --- a/src/app/(dashboard)/jobs/inbound/page.tsx +++ b/src/app/(dashboard)/jobs/inbound/page.tsx @@ -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 (
@@ -37,18 +45,21 @@ export default async function InboundJobsPage() { {ctx.kind === "lab" ? rows.length === 0 - ? "Henüz gelen iş yok." + ? "Filtreye uyan iş yok." : `${rows.length} kalem` : "Bu sayfa laboratuvar hesapları içindir."} - + {ctx.kind === "lab" ? ( - + <> + + + ) : (

Klinik hesabıyla giriş yaptınız — gelen iş listesi sadece laboratuvar tarafında görünür. diff --git a/src/app/(dashboard)/jobs/outbound/page.tsx b/src/app/(dashboard)/jobs/outbound/page.tsx index a63dd3b..ad90d48 100644 --- a/src/app/(dashboard)/jobs/outbound/page.tsx +++ b/src/app/(dashboard)/jobs/outbound/page.tsx @@ -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 (

@@ -34,18 +45,21 @@ export default async function OutboundJobsPage() { {ctx.kind === "clinic" ? rows.length === 0 - ? "Henüz iş göndermediniz." + ? "Filtreye uyan iş yok." : `${rows.length} kalem` : "Bu sayfa klinik hesapları içindir."} - + {ctx.kind === "clinic" ? ( - + <> + + + ) : (

Laboratuvar hesabıyla giriş yaptınız — giden iş listesi sadece klinik tarafında görünür. diff --git a/src/lib/appwrite/job-queries.ts b/src/lib/appwrite/job-queries.ts index 2dc8e13..875903e 100644 --- a/src/lib/appwrite/job-queries.ts +++ b/src/lib/appwrite/job-queries.ts @@ -45,38 +45,68 @@ function enrichJob(j: Job, counterpartId: string, map: Map { +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 { 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 { + return listJobsFor("lab", labTenantId, filters); } /** Outbound for a clinic tenant — jobs the clinic has sent. */ -export async function listOutboundJobs(clinicTenantId: string): Promise { - 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 { + return listJobsFor("clinic", clinicTenantId, filters); } /** For clinic's "Yeni İş Yayınla" form: list approved labs they can send to. */