feat(modules): connections, products, jobs (list/form/detail-placeholder)

Connections (clinic ↔ lab)
  - request via member number, approve/reject (counterparty), cancel pending,
    delete approved
  - permission rows opened to both teams; audit log for every mutation
  - /connections page: own code card, request form, pending inbound/outbound
    tables, approved connections table with delete confirm

Products (lab catalog)
  - createProstheticAction + update + archive/restore + delete (lab-only)
  - zod validation, dev-mode error surfacing
  - /products page: catalog table + add form + edit dialog. Hidden from
    clinic accounts via requireTenantKind.

Jobs (work orders)
  - createJobAction (clinic-only) — checks approved connection before write,
    permissions opened to both clinic and lab teams
  - listInboundJobs (lab perspective), listOutboundJobs (clinic perspective),
    listApprovedLabsForClinic for the new-job form
  - /jobs/inbound + /jobs/outbound tables with role-aware copy
  - /jobs/new full form (lab select, patient code, prosthetic type, member
    count, color, due date, price/currency, description)
  - /jobs/[jobId] placeholder detail page with stepper visualisation;
    status/step updates and file upload come next session

All new mutations follow the memory rules: schema-checked row payloads,
admin client behind requireTenant + requireRole/requireTenantKind, audit
log calls best-effort, no empty-string Radix Select values.
This commit is contained in:
kovakmedya
2026-05-21 19:59:23 +03:00
parent 7fb8288f79
commit 76e02754b8
26 changed files with 2765 additions and 42 deletions
@@ -0,0 +1,42 @@
"use client";
import { useState } from "react";
import { Check, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export function ConnectionCodeCard({ memberNumber }: { memberNumber: string }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
if (!memberNumber) return;
try {
await navigator.clipboard.writeText(memberNumber);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
/* ignore */
}
};
return (
<Card>
<CardHeader>
<CardTitle>Bağlantı kodunuz</CardTitle>
<CardDescription>
Karşı taraf bu kodu girerek size bağlantı talebi gönderir.
</CardDescription>
</CardHeader>
<CardContent>
<div className="bg-muted/40 flex items-center justify-between gap-3 rounded-md border px-4 py-3">
<span className="font-mono text-2xl tracking-[0.4em]">{memberNumber || "—"}</span>
<Button type="button" variant="outline" size="sm" onClick={copy} disabled={!memberNumber}>
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
{copied ? "Kopyalandı" : "Kopyala"}
</Button>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,61 @@
"use client";
import { useActionState, useEffect, useRef } from "react";
import { Link2, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { requestConnectionAction } from "@/lib/appwrite/connection-actions";
import { initialConnectionRequestState } from "@/lib/appwrite/connection-types";
export function ConnectionRequestForm({ counterpartLabel }: { counterpartLabel: string }) {
const [state, formAction, isPending] = useActionState(
requestConnectionAction,
initialConnectionRequestState,
);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (state.ok) {
toast.success("Bağlantı talebi gönderildi.");
formRef.current?.reset();
} else if (state.error) {
toast.error(state.error);
}
}, [state]);
return (
<form ref={formRef} action={formAction} className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
<div className="grid gap-2">
<Label htmlFor="memberNumber">{counterpartLabel.charAt(0).toUpperCase() + counterpartLabel.slice(1)} bağlantı kodu</Label>
<Input
id="memberNumber"
name="memberNumber"
type="text"
placeholder="6 hane"
maxLength={12}
autoCapitalize="characters"
autoComplete="off"
required
style={{ textTransform: "uppercase", letterSpacing: "0.3em" }}
className="font-mono"
/>
</div>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Gönderiliyor...
</>
) : (
<>
<Link2 className="size-4" />
Talep gönder
</>
)}
</Button>
</form>
);
}
@@ -0,0 +1,135 @@
"use client";
import { useActionState, useEffect, useState, useTransition } from "react";
import { Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { deleteConnectionAction } from "@/lib/appwrite/connection-actions";
import { initialConnectionActionState } from "@/lib/appwrite/connection-types";
import type { ConnectionWithCounterpart } from "@/lib/appwrite/connection-queries";
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
export function ConnectionsTable({ rows }: { rows: ConnectionWithCounterpart[] }) {
if (rows.length === 0) {
return (
<p className="text-muted-foreground py-6 text-center text-sm">
Henüz onaylanmış bağlantınız yok. Yukarıdan talep gönderebilir veya kodunuzu paylaşabilirsiniz.
</p>
);
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Karşı taraf</TableHead>
<TableHead>Tür</TableHead>
<TableHead>Onay tarihi</TableHead>
<TableHead className="text-right">İşlem</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<ApprovedRow key={r.$id} row={r} />
))}
</TableBody>
</Table>
);
}
function ApprovedRow({ row }: { row: ConnectionWithCounterpart }) {
const [state, action, pending] = useActionState(
deleteConnectionAction,
initialConnectionActionState,
);
const [, startTransition] = useTransition();
const [open, setOpen] = useState(false);
useEffect(() => {
if (state.ok) {
toast.success("Bağlantı silindi.");
setOpen(false);
} else if (state.error) {
toast.error(state.error);
}
}, [state]);
const kindLabel =
row.counterpart?.kind === "lab"
? "Laboratuvar"
: row.counterpart?.kind === "clinic"
? "Klinik"
: "—";
return (
<TableRow>
<TableCell className="font-medium">{row.counterpart?.companyName ?? "—"}</TableCell>
<TableCell>
<Badge variant="secondary">{kindLabel}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{row.approvedAt ? dateFormatter.format(new Date(row.approvedAt)) : "—"}
</TableCell>
<TableCell className="text-right">
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<Trash2 className="size-4" />
Sil
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Bağlantı silinsin mi?</DialogTitle>
<DialogDescription>
{row.counterpart?.companyName ?? "Karşı taraf"} ile bağlantınız sonlandırılacak.
Mevcut işleriniz etkilenmez ancak yeni gönderemezsiniz.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">Vazgeç</Button>
</DialogClose>
<form
action={(fd) => {
startTransition(() => action(fd));
}}
>
<input type="hidden" name="connectionId" value={row.$id} />
<Button type="submit" disabled={pending} variant="destructive">
{pending ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
</TableCell>
</TableRow>
);
}
@@ -0,0 +1,128 @@
"use client";
import { useActionState, useEffect, useTransition } from "react";
import { Check, Loader2, X } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
approveConnectionAction,
rejectConnectionAction,
} from "@/lib/appwrite/connection-actions";
import { initialConnectionActionState } from "@/lib/appwrite/connection-types";
import type { ConnectionWithCounterpart } from "@/lib/appwrite/connection-queries";
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
export function PendingInboundTable({ rows }: { rows: ConnectionWithCounterpart[] }) {
if (rows.length === 0) {
return (
<p className="text-muted-foreground py-6 text-center text-sm">
Bekleyen talep yok.
</p>
);
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Talep eden</TableHead>
<TableHead>Tür</TableHead>
<TableHead>Tarih</TableHead>
<TableHead className="text-right">İşlem</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<InboundRow key={r.$id} row={r} />
))}
</TableBody>
</Table>
);
}
function InboundRow({ row }: { row: ConnectionWithCounterpart }) {
const [approveState, approveAction, approvePending] = useActionState(
approveConnectionAction,
initialConnectionActionState,
);
const [rejectState, rejectAction, rejectPending] = useActionState(
rejectConnectionAction,
initialConnectionActionState,
);
const [, startTransition] = useTransition();
useEffect(() => {
if (approveState.ok) toast.success("Bağlantı onaylandı.");
else if (approveState.error) toast.error(approveState.error);
}, [approveState]);
useEffect(() => {
if (rejectState.ok) toast.success("Talep reddedildi.");
else if (rejectState.error) toast.error(rejectState.error);
}, [rejectState]);
const kindLabel =
row.counterpart?.kind === "lab"
? "Laboratuvar"
: row.counterpart?.kind === "clinic"
? "Klinik"
: "—";
return (
<TableRow>
<TableCell className="font-medium">{row.counterpart?.companyName ?? "—"}</TableCell>
<TableCell>
<Badge variant="secondary">{kindLabel}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{dateFormatter.format(new Date(row.requestedAt))}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<form
action={(fd) => {
startTransition(() => approveAction(fd));
}}
>
<input type="hidden" name="connectionId" value={row.$id} />
<Button type="submit" size="sm" disabled={approvePending || rejectPending}>
{approvePending ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
Onayla
</Button>
</form>
<form
action={(fd) => {
startTransition(() => rejectAction(fd));
}}
>
<input type="hidden" name="connectionId" value={row.$id} />
<Button
type="submit"
size="sm"
variant="outline"
disabled={approvePending || rejectPending}
>
{rejectPending ? <Loader2 className="size-4 animate-spin" /> : <X className="size-4" />}
Reddet
</Button>
</form>
</div>
</TableCell>
</TableRow>
);
}
@@ -0,0 +1,98 @@
"use client";
import { useActionState, useEffect, useTransition } from "react";
import { Loader2, X } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cancelConnectionAction } from "@/lib/appwrite/connection-actions";
import { initialConnectionActionState } from "@/lib/appwrite/connection-types";
import type { ConnectionWithCounterpart } from "@/lib/appwrite/connection-queries";
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
export function PendingOutboundTable({ rows }: { rows: ConnectionWithCounterpart[] }) {
if (rows.length === 0) {
return (
<p className="text-muted-foreground py-6 text-center text-sm">
Gönderilmiş talebiniz yok.
</p>
);
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Karşı taraf</TableHead>
<TableHead>Tür</TableHead>
<TableHead>Gönderim</TableHead>
<TableHead className="text-right">İşlem</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<OutboundRow key={r.$id} row={r} />
))}
</TableBody>
</Table>
);
}
function OutboundRow({ row }: { row: ConnectionWithCounterpart }) {
const [state, action, pending] = useActionState(
cancelConnectionAction,
initialConnectionActionState,
);
const [, startTransition] = useTransition();
useEffect(() => {
if (state.ok) toast.success("Talep iptal edildi.");
else if (state.error) toast.error(state.error);
}, [state]);
const kindLabel =
row.counterpart?.kind === "lab"
? "Laboratuvar"
: row.counterpart?.kind === "clinic"
? "Klinik"
: "—";
return (
<TableRow>
<TableCell className="font-medium">{row.counterpart?.companyName ?? "—"}</TableCell>
<TableCell>
<Badge variant="secondary">{kindLabel}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{dateFormatter.format(new Date(row.requestedAt))}
</TableCell>
<TableCell className="text-right">
<form
action={(fd) => {
startTransition(() => action(fd));
}}
>
<input type="hidden" name="connectionId" value={row.$id} />
<Button type="submit" size="sm" variant="outline" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <X className="size-4" />}
İptal et
</Button>
</form>
</TableCell>
</TableRow>
);
}
+72 -13
View File
@@ -1,7 +1,21 @@
import { redirect } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
listApprovedConnections,
listPendingInbound,
listPendingOutbound,
} from "@/lib/appwrite/connection-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { ConnectionCodeCard } from "./components/connection-code-card";
import { ConnectionRequestForm } from "./components/connection-request-form";
import { PendingInboundTable } from "./components/pending-inbound-table";
import { PendingOutboundTable } from "./components/pending-outbound-table";
import { ConnectionsTable } from "./components/connections-table";
export const metadata = {
title: "DLS — Bağlantı Kur",
};
export default async function ConnectionsPage() {
let ctx;
@@ -11,32 +25,77 @@ export default async function ConnectionsPage() {
redirect("/onboarding");
}
const memberNumber = ctx.settings?.memberNumber ?? "";
const isLab = ctx.kind === "lab";
const counterpartLabel = isLab ? "klinik" : "laboratuvar";
const [approved, pendingInbound, pendingOutbound] = await Promise.all([
listApprovedConnections(ctx.tenantId),
listPendingInbound(ctx.tenantId, ctx.user.id),
listPendingOutbound(ctx.tenantId, ctx.user.id),
]);
return (
<div className="flex-1 space-y-6 px-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Bağlantı Kur</h1>
<p className="text-muted-foreground text-sm">
Klinik ve laboratuvar arasında bağlantı taleplerini yönetin.
{isLab
? "Sizinle çalışan klinikleri yönetin. Bağlantı kodunuzu paylaşın veya bir kliniğe talep gönderin."
: "Çalıştığınız laboratuvarları yönetin. Bağlantı kodunuzu paylaşın veya bir laboratuvara talep gönderin."}
</p>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<ConnectionCodeCard memberNumber={memberNumber} />
<Card>
<CardHeader>
<CardTitle>Bağlantı talep et</CardTitle>
<CardDescription>
Karşı tarafın 6 haneli kodunu girin, onaylarsa bağlantı kurulur.
</CardDescription>
</CardHeader>
<CardContent>
<ConnectionRequestForm counterpartLabel={counterpartLabel} />
</CardContent>
</Card>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Gelen Talepler</CardTitle>
<CardDescription>
Size gönderilen, onayınızı bekleyen bağlantı talepleri.
</CardDescription>
</CardHeader>
<CardContent>
<PendingInboundTable rows={pendingInbound} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Gönderilen Talepler</CardTitle>
<CardDescription>
Sizin gönderdiğiniz, karşı tarafın yanıtını bekleyen talepler.
</CardDescription>
</CardHeader>
<CardContent>
<PendingOutboundTable rows={pendingOutbound} />
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Bağlantı kodunuz</CardTitle>
<CardDescription>Karşı taraf bu kodu girerek size bağlantı talebi gönderir.</CardDescription>
<CardTitle>Bağlantılarım</CardTitle>
<CardDescription>Onaylanmış aktif bağlantılar.</CardDescription>
</CardHeader>
<CardContent>
<div className="bg-muted/40 rounded-md border px-4 py-3 font-mono text-lg tracking-widest">
{ctx.settings?.memberNumber ?? "—"}
</div>
<ConnectionsTable rows={approved} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Yapım aşamasında</CardTitle>
<CardDescription>Bağlantı talepleri ve bağlı taraflar listesi sonraki sürümde eklenecek.</CardDescription>
</CardHeader>
<CardContent />
</Card>
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { createAdminClient } from "@/lib/appwrite/server";
import { DATABASE_ID, TABLES, type Job, type TenantSettings } from "@/lib/appwrite/schema";
import { Query } from "node-appwrite";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import {
JOB_STATUS_LABELS,
JOB_STEP_LABELS,
JOB_STEP_ORDER,
PROSTHETIC_TYPE_LABELS,
} from "@/lib/appwrite/job-types";
export const metadata = {
title: "DLS — İş Detay",
};
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
function formatMoney(amount: number, currency: string) {
try {
return new Intl.NumberFormat("tr-TR", { style: "currency", currency }).format(amount);
} catch {
return `${amount.toFixed(2)} ${currency}`;
}
}
export default async function JobDetailPage({
params,
}: {
params: Promise<{ jobId: string }>;
}) {
const { jobId } = await params;
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const { tablesDB } = createAdminClient();
let job: Job;
try {
const row = await tablesDB.getRow(DATABASE_ID, TABLES.jobs, jobId);
job = row as unknown as Job;
} catch {
notFound();
}
if (job.clinicTenantId !== ctx.tenantId && job.labTenantId !== ctx.tenantId) {
notFound();
}
const counterpartId =
job.clinicTenantId === ctx.tenantId ? job.labTenantId : job.clinicTenantId;
const counterpartLabel = job.clinicTenantId === ctx.tenantId ? "Laboratuvar" : "Klinik";
const counterpartRes = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings,
queries: [Query.equal("tenantId", counterpartId), Query.limit(1)],
});
const counterpart = counterpartRes.rows[0] as unknown as TenantSettings | undefined;
const currentStepIdx = job.currentStep ? JOB_STEP_ORDER.indexOf(job.currentStep) : -1;
return (
<div className="flex-1 space-y-6 px-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm">
{counterpartLabel}: {counterpart?.companyName ?? "—"}
</p>
<h1 className="text-2xl font-bold tracking-tight">
Hasta {job.patientCode}
</h1>
<p className="text-muted-foreground text-sm">
{PROSTHETIC_TYPE_LABELS[job.prostheticType]} · {job.memberCount} üye
</p>
</div>
<Badge variant="secondary" className="text-sm">
{JOB_STATUS_LABELS[job.status]}
</Badge>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>İş Bilgileri</CardTitle>
<CardDescription>{dateFormatter.format(new Date(job.$createdAt))}</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 text-sm md:grid-cols-2">
<Info label="Renk">{job.color || "—"}</Info>
<Info label="Termin">
{job.dueDate ? dateFormatter.format(new Date(job.dueDate)) : "—"}
</Info>
<Info label="Fiyat">
{typeof job.price === "number"
? formatMoney(job.price, job.currency || "TRY")
: "—"}
</Info>
<Info label="Mevcut Aşama">
{job.currentStep ? JOB_STEP_LABELS[job.currentStep] : "—"}
</Info>
<div className="md:col-span-2">
<p className="text-muted-foreground mb-1 text-xs font-medium uppercase tracking-wide">
Açıklama
</p>
<p className="whitespace-pre-wrap text-sm">{job.description || "—"}</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Aşamalar</CardTitle>
<CardDescription>Ölçü Alt Yapı Üst Yapı Cila/Bitim</CardDescription>
</CardHeader>
<CardContent>
<ol className="space-y-3">
{JOB_STEP_ORDER.map((step, idx) => {
const done = currentStepIdx > idx || job.status === "delivered";
const active = currentStepIdx === idx && job.status !== "delivered";
return (
<li key={step} className="flex items-center gap-3">
<span
className={
done
? "bg-primary text-primary-foreground"
: active
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground"
}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
borderRadius: "50%",
fontSize: 12,
fontWeight: 600,
}}
>
{idx + 1}
</span>
<span className={active ? "font-medium" : ""}>{JOB_STEP_LABELS[step]}</span>
</li>
);
})}
</ol>
<p className="text-muted-foreground mt-4 text-xs">
Aşama güncelleme ve dosya yükleme sonraki sürümde.
</p>
</CardContent>
</Card>
</div>
<div>
<Button asChild variant="outline">
<Link href={ctx.kind === "clinic" ? "/jobs/outbound" : "/jobs/inbound"}>
Listeye dön
</Link>
</Button>
</div>
</div>
);
}
function Info({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
{label}
</p>
<p className="mt-0.5 text-sm">{children}</p>
</div>
);
}
@@ -0,0 +1,96 @@
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
JOB_STATUS_LABELS,
PROSTHETIC_TYPE_LABELS,
} from "@/lib/appwrite/job-types";
import type { JobWithCounterpart } from "@/lib/appwrite/job-queries";
import type { JobStatus } from "@/lib/appwrite/schema";
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
function statusVariant(status: JobStatus): "default" | "secondary" | "outline" | "destructive" {
switch (status) {
case "delivered":
return "default";
case "sent":
return "secondary";
case "in_progress":
return "secondary";
case "cancelled":
return "destructive";
default:
return "outline";
}
}
export function JobsTable({
rows,
counterpartLabel,
emptyMessage,
}: {
rows: JobWithCounterpart[];
counterpartLabel: string;
emptyMessage: string;
}) {
if (rows.length === 0) {
return (
<p className="text-muted-foreground py-6 text-center text-sm">{emptyMessage}</p>
);
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>{counterpartLabel}</TableHead>
<TableHead>Hasta Kodu</TableHead>
<TableHead>Üye</TableHead>
<TableHead>Renk</TableHead>
<TableHead>Tür</TableHead>
<TableHead>Durum</TableHead>
<TableHead>Tarih</TableHead>
<TableHead className="text-right">İşlem</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((j) => (
<TableRow key={j.$id}>
<TableCell className="font-medium">{j.counterpart?.companyName ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">{j.patientCode}</TableCell>
<TableCell className="tabular-nums">{j.memberCount}</TableCell>
<TableCell className="text-muted-foreground">{j.color || "—"}</TableCell>
<TableCell className="text-muted-foreground">
{PROSTHETIC_TYPE_LABELS[j.prostheticType] ?? j.prostheticType}
</TableCell>
<TableCell>
<Badge variant={statusVariant(j.status)}>{JOB_STATUS_LABELS[j.status]}</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{dateFormatter.format(new Date(j.$createdAt))}
</TableCell>
<TableCell className="text-right">
<Button asChild size="sm" variant="outline">
<Link href={`/jobs/${j.$id}`}>Detay</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
+46 -6
View File
@@ -1,20 +1,60 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
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 { JobsTable } from "../_components/jobs-table";
export const metadata = {
title: "DLS — Gelen İşler",
};
export default async function InboundJobsPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
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) : [];
export default function InboundJobsPage() {
return (
<div className="flex-1 space-y-6 px-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Gelen İşler</h1>
<p className="text-muted-foreground text-sm">
Bağlı kliniklerden gelen protez işleri burada listelenecek.
Bağlı kliniklerden size yönlendirilmiş protez işleri.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Yapım aşamasında</CardTitle>
<CardDescription>Gelen listesi, filtreleme ve detay görünümü sonraki sürümde eklenecek.</CardDescription>
<CardTitle>Tüm Gelen İşler</CardTitle>
<CardDescription>
{ctx.kind === "lab"
? rows.length === 0
? "Henüz gelen iş yok."
: `${rows.length} kalem`
: "Bu sayfa laboratuvar hesapları içindir."}
</CardDescription>
</CardHeader>
<CardContent />
<CardContent>
{ctx.kind === "lab" ? (
<JobsTable
rows={rows}
counterpartLabel="Klinik"
emptyMessage="Henüz size gönderilmiş iş yok. Klinik tarafa Bağlantı Kodunuzu paylaşın."
/>
) : (
<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.
</p>
)}
</CardContent>
</Card>
</div>
);
@@ -0,0 +1,189 @@
"use client";
import { useActionState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Send } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { createJobAction } from "@/lib/appwrite/job-actions";
import {
PROSTHETIC_TYPE_LABELS,
initialJobFormState,
} from "@/lib/appwrite/job-types";
import type { JobCounterpart } from "@/lib/appwrite/job-queries";
import type { ProstheticType } from "@/lib/appwrite/schema";
const PROSTHETIC_TYPES: ProstheticType[] = [
"metal_porselen",
"zirkonyum",
"implant_ustu_zirkonyum",
"gecici",
"e_max",
"diger",
];
export function NewJobForm({
labs,
defaultCurrency,
}: {
labs: JobCounterpart[];
defaultCurrency: string;
}) {
const router = useRouter();
const [state, action, pending] = useActionState(createJobAction, initialJobFormState);
useEffect(() => {
if (state.ok) {
toast.success("İş yayınlandı.");
router.push("/jobs/outbound");
} else if (state.error) {
toast.error(state.error);
}
}, [state, router]);
return (
<form action={action} className="grid gap-5">
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="labTenantId">Laboratuvar *</Label>
<Select name="labTenantId" required defaultValue={labs[0]?.tenantId}>
<SelectTrigger id="labTenantId">
<SelectValue placeholder="Bir laboratuvar seçin" />
</SelectTrigger>
<SelectContent>
{labs.map((l) => (
<SelectItem key={l.tenantId} value={l.tenantId}>
{l.companyName}
</SelectItem>
))}
</SelectContent>
</Select>
{state.fieldErrors?.labTenantId && (
<p className="text-destructive text-xs">{state.fieldErrors.labTenantId}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="patientCode">Hasta Kodu *</Label>
<Input
id="patientCode"
name="patientCode"
required
maxLength={50}
placeholder="Örn. 000892"
/>
{state.fieldErrors?.patientCode && (
<p className="text-destructive text-xs">{state.fieldErrors.patientCode}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="memberCount">Üye Sayısı *</Label>
<Input
id="memberCount"
name="memberCount"
type="number"
min="1"
max="32"
required
defaultValue={1}
/>
{state.fieldErrors?.memberCount && (
<p className="text-destructive text-xs">{state.fieldErrors.memberCount}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="prostheticType">Protez Türü *</Label>
<Select name="prostheticType" required>
<SelectTrigger id="prostheticType">
<SelectValue placeholder="Tür seçin" />
</SelectTrigger>
<SelectContent>
{PROSTHETIC_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{PROSTHETIC_TYPE_LABELS[t]}
</SelectItem>
))}
</SelectContent>
</Select>
{state.fieldErrors?.prostheticType && (
<p className="text-destructive text-xs">{state.fieldErrors.prostheticType}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="color">Renk (Vita)</Label>
<Input id="color" name="color" maxLength={20} placeholder="Örn. A2" />
</div>
<div className="grid gap-2">
<Label htmlFor="dueDate">Termin Tarihi</Label>
<Input id="dueDate" name="dueDate" type="date" />
</div>
<div className="grid grid-cols-[1fr_100px] gap-2">
<div className="grid gap-2">
<Label htmlFor="price">Fiyat</Label>
<Input
id="price"
name="price"
type="number"
step="0.01"
min="0"
placeholder="0.00"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="currency">Para</Label>
<Input
id="currency"
name="currency"
defaultValue={defaultCurrency}
maxLength={8}
style={{ textTransform: "uppercase" }}
/>
</div>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="description">Açıklama</Label>
<Textarea
id="description"
name="description"
rows={4}
maxLength={2000}
placeholder="Lab'a iletmek istediğiniz notlar — hijyenik gövde, materyal tercihi, vs."
/>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={pending || labs.length === 0}>
{pending ? (
<>
<Loader2 className="size-4 animate-spin" />
Gönderiliyor...
</>
) : (
<>
<Send className="size-4" />
İşi Yayınla
</>
)}
</Button>
</div>
</form>
);
}
+39 -9
View File
@@ -1,7 +1,15 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { listApprovedLabsForClinic } from "@/lib/appwrite/job-queries";
import { requireTenant, requireTenantKind } from "@/lib/appwrite/tenant-guard";
import { NewJobForm } from "./components/new-job-form";
export const metadata = {
title: "DLS — Yeni İş Yayınla",
};
export default async function NewJobPage() {
let ctx;
@@ -12,6 +20,9 @@ export default async function NewJobPage() {
redirect("/dashboard");
}
const labs = await listApprovedLabsForClinic(ctx.tenantId);
const defaultCurrency = ctx.settings?.defaultCurrency ?? "TRY";
return (
<div className="flex-1 space-y-6 px-6">
<div className="flex flex-col gap-1">
@@ -20,15 +31,34 @@ export default async function NewJobPage() {
Bağlı laboratuvarınıza yeni bir protez işi gönderin.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Yapım aşamasında</CardTitle>
<CardDescription>
Form (lab seçimi, hasta kodu, protez türü, renk, dosya yükleme) sonraki sürümde eklenecek.
</CardDescription>
</CardHeader>
<CardContent />
</Card>
{labs.length === 0 ? (
<Card>
<CardHeader>
<CardTitle>Önce bir laboratuvarla bağlantı kurun</CardTitle>
<CardDescription>
İş gönderebilmeniz için onaylanmış bir laboratuvar bağlantınız olmalı.
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link href="/connections">Bağlantı Kur</Link>
</Button>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>İş Bilgileri</CardTitle>
<CardDescription>
Hasta kodu, protez türü ve diğer detayları girin. Dosya yüklemesi sonraki sürümde.
</CardDescription>
</CardHeader>
<CardContent>
<NewJobForm labs={labs} defaultCurrency={defaultCurrency} />
</CardContent>
</Card>
)}
</div>
);
}
+43 -6
View File
@@ -1,20 +1,57 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
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 { JobsTable } from "../_components/jobs-table";
export const metadata = {
title: "DLS — Giden İşler",
};
export default async function OutboundJobsPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const rows = ctx.kind === "clinic" ? await listOutboundJobs(ctx.tenantId) : [];
export default function OutboundJobsPage() {
return (
<div className="flex-1 space-y-6 px-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Giden İşler</h1>
<p className="text-muted-foreground text-sm">
Karşı tarafa gönderilen protez işleri burada listelenecek.
Bağlı laboratuvarlara gönderdiğiniz işler.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Yapım aşamasında</CardTitle>
<CardDescription>Giden listesi sonraki sürümde eklenecek.</CardDescription>
<CardTitle>Tüm Giden İşler</CardTitle>
<CardDescription>
{ctx.kind === "clinic"
? rows.length === 0
? "Henüz iş göndermediniz."
: `${rows.length} kalem`
: "Bu sayfa klinik hesapları içindir."}
</CardDescription>
</CardHeader>
<CardContent />
<CardContent>
{ctx.kind === "clinic" ? (
<JobsTable
rows={rows}
counterpartLabel="Laboratuvar"
emptyMessage="Henüz iş göndermediniz. 'Yeni İş Yayınla' butonundan başlayabilirsiniz."
/>
) : (
<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.
</p>
)}
</CardContent>
</Card>
</div>
);
@@ -0,0 +1,112 @@
"use client";
import { useActionState, useEffect, useRef } from "react";
import { Loader2, Plus } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { createProstheticAction } from "@/lib/appwrite/prosthetic-actions";
import {
PROSTHETIC_TYPE_OPTIONS,
initialProstheticFormState,
} from "@/lib/appwrite/prosthetic-types";
export function ProstheticForm({ defaultCurrency }: { defaultCurrency: string }) {
const [state, formAction, isPending] = useActionState(
createProstheticAction,
initialProstheticFormState,
);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (state.ok) {
toast.success("Ürün eklendi.");
formRef.current?.reset();
} else if (state.error) {
toast.error(state.error);
}
}, [state]);
return (
<form ref={formRef} action={formAction} className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="name">Protez Adı *</Label>
<Input id="name" name="name" required placeholder="Örn. Premium Zirkonyum" maxLength={255} />
{state.fieldErrors?.name && (
<p className="text-destructive text-xs">{state.fieldErrors.name}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="type">Protez Türü *</Label>
<Select name="type" required>
<SelectTrigger id="type">
<SelectValue placeholder="Tür seçin" />
</SelectTrigger>
<SelectContent>
{PROSTHETIC_TYPE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
{state.fieldErrors?.type && (
<p className="text-destructive text-xs">{state.fieldErrors.type}</p>
)}
</div>
<div className="grid gap-2 grid-cols-[1fr_100px]">
<div className="grid gap-2">
<Label htmlFor="unitPrice">Birim Fiyatı *</Label>
<Input
id="unitPrice"
name="unitPrice"
type="number"
step="0.01"
min="0"
required
placeholder="0.00"
inputMode="decimal"
/>
{state.fieldErrors?.unitPrice && (
<p className="text-destructive text-xs">{state.fieldErrors.unitPrice}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="currency">Para birimi</Label>
<Input
id="currency"
name="currency"
defaultValue={defaultCurrency}
maxLength={8}
style={{ textTransform: "uppercase" }}
/>
</div>
</div>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Ekleniyor...
</>
) : (
<>
<Plus className="size-4" />
Ürün ekle
</>
)}
</Button>
</form>
);
}
@@ -0,0 +1,328 @@
"use client";
import { useActionState, useEffect, useState, useTransition } from "react";
import { Archive, ArchiveRestore, Loader2, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
archiveProstheticAction,
deleteProstheticAction,
updateProstheticAction,
} from "@/lib/appwrite/prosthetic-actions";
import {
PROSTHETIC_TYPE_OPTIONS,
initialProstheticActionState,
initialProstheticFormState,
} from "@/lib/appwrite/prosthetic-types";
import type { Prosthetic } from "@/lib/appwrite/schema";
const TYPE_LABELS = Object.fromEntries(
PROSTHETIC_TYPE_OPTIONS.map((o) => [o.value, o.label]),
) as Record<string, string>;
function formatMoney(amount: number, currency: string) {
try {
return new Intl.NumberFormat("tr-TR", { style: "currency", currency }).format(amount);
} catch {
return `${amount.toFixed(2)} ${currency}`;
}
}
export function ProstheticsTable({
rows,
defaultCurrency,
}: {
rows: Prosthetic[];
defaultCurrency: string;
}) {
if (rows.length === 0) {
return (
<p className="text-muted-foreground py-6 text-center text-sm">
Henüz ürün eklemediniz. Sağdaki formdan ekleyebilirsiniz.
</p>
);
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Protez Adı</TableHead>
<TableHead>Tür</TableHead>
<TableHead className="text-right">Birim Fiyat</TableHead>
<TableHead>Durum</TableHead>
<TableHead className="text-right">İşlem</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<ProstheticRow key={r.$id} row={r} defaultCurrency={defaultCurrency} />
))}
</TableBody>
</Table>
);
}
function ProstheticRow({
row,
defaultCurrency,
}: {
row: Prosthetic;
defaultCurrency: string;
}) {
const [archiveState, archiveAction, archivePending] = useActionState(
archiveProstheticAction,
initialProstheticActionState,
);
const [deleteState, deleteAction, deletePending] = useActionState(
deleteProstheticAction,
initialProstheticActionState,
);
const [, startTransition] = useTransition();
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
useEffect(() => {
if (archiveState.ok) toast.success(row.archived ? "Aktifleştirildi." : "Arşivlendi.");
else if (archiveState.error) toast.error(archiveState.error);
}, [archiveState, row.archived]);
useEffect(() => {
if (deleteState.ok) {
toast.success("Ürün silindi.");
setDeleteOpen(false);
} else if (deleteState.error) {
toast.error(deleteState.error);
}
}, [deleteState]);
return (
<TableRow className={row.archived ? "opacity-60" : ""}>
<TableCell className="font-medium">{row.name}</TableCell>
<TableCell className="text-muted-foreground">
{TYPE_LABELS[row.type] ?? row.type}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatMoney(row.unitPrice, row.currency || defaultCurrency)}
</TableCell>
<TableCell>
{row.archived ? (
<Badge variant="outline">Arşiv</Badge>
) : (
<Badge variant="secondary">Aktif</Badge>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1.5">
<Button
size="sm"
variant="outline"
onClick={() => setEditOpen(true)}
disabled={archivePending || deletePending}
>
<Pencil className="size-4" />
</Button>
<form
action={(fd) => {
startTransition(() => archiveAction(fd));
}}
>
<input type="hidden" name="id" value={row.$id} />
<Button
type="submit"
size="sm"
variant="outline"
disabled={archivePending || deletePending}
>
{archivePending ? (
<Loader2 className="size-4 animate-spin" />
) : row.archived ? (
<ArchiveRestore className="size-4" />
) : (
<Archive className="size-4" />
)}
</Button>
</form>
<Button
size="sm"
variant="outline"
onClick={() => setDeleteOpen(true)}
disabled={archivePending || deletePending}
>
<Trash2 className="size-4" />
</Button>
</div>
<EditDialog
row={row}
defaultCurrency={defaultCurrency}
open={editOpen}
onOpenChange={setEditOpen}
/>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Ürün silinsin mi?</DialogTitle>
<DialogDescription>
{row.name} kalıcı olarak silinecek. Mevcut işlerden bu ürünün referansı kaldırılmaz.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Vazgeç
</Button>
</DialogClose>
<form
action={(fd) => {
startTransition(() => deleteAction(fd));
}}
>
<input type="hidden" name="id" value={row.$id} />
<Button type="submit" variant="destructive" disabled={deletePending}>
{deletePending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
Sil
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
</TableCell>
</TableRow>
);
}
function EditDialog({
row,
defaultCurrency,
open,
onOpenChange,
}: {
row: Prosthetic;
defaultCurrency: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [state, action, pending] = useActionState(
updateProstheticAction,
initialProstheticFormState,
);
useEffect(() => {
if (state.ok) {
toast.success("Ürün güncellendi.");
onOpenChange(false);
} else if (state.error) {
toast.error(state.error);
}
}, [state, onOpenChange]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Ürünü düzenle</DialogTitle>
<DialogDescription>{row.name}</DialogDescription>
</DialogHeader>
<form action={action} className="grid gap-4">
<input type="hidden" name="id" value={row.$id} />
<div className="grid gap-2">
<Label htmlFor={`edit-name-${row.$id}`}>Protez Adı *</Label>
<Input
id={`edit-name-${row.$id}`}
name="name"
defaultValue={row.name}
required
maxLength={255}
/>
{state.fieldErrors?.name && (
<p className="text-destructive text-xs">{state.fieldErrors.name}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor={`edit-type-${row.$id}`}>Protez Türü *</Label>
<Select name="type" defaultValue={row.type}>
<SelectTrigger id={`edit-type-${row.$id}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROSTHETIC_TYPE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-[1fr_100px] gap-3">
<div className="grid gap-2">
<Label htmlFor={`edit-price-${row.$id}`}>Birim Fiyatı *</Label>
<Input
id={`edit-price-${row.$id}`}
name="unitPrice"
type="number"
step="0.01"
min="0"
defaultValue={row.unitPrice}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`edit-currency-${row.$id}`}>Para</Label>
<Input
id={`edit-currency-${row.$id}`}
name="currency"
defaultValue={row.currency || defaultCurrency}
maxLength={8}
style={{ textTransform: "uppercase" }}
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Vazgeç
</Button>
</DialogClose>
<Button type="submit" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <Pencil className="size-4" />}
Kaydet
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+35 -8
View File
@@ -1,7 +1,14 @@
import { redirect } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { listProsthetics } from "@/lib/appwrite/prosthetic-queries";
import { requireTenant, requireTenantKind } from "@/lib/appwrite/tenant-guard";
import { ProstheticForm } from "./components/prosthetic-form";
import { ProstheticsTable } from "./components/prosthetics-table";
export const metadata = {
title: "DLS — Ürünler",
};
export default async function ProductsPage() {
let ctx;
@@ -12,21 +19,41 @@ export default async function ProductsPage() {
redirect("/dashboard");
}
const rows = await listProsthetics(ctx.tenantId);
const defaultCurrency = ctx.settings?.defaultCurrency ?? "TRY";
return (
<div className="flex-1 space-y-6 px-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Ürünler</h1>
<p className="text-muted-foreground text-sm">
Sunduğunuz protez türleri ve fiyatlandırma katalogu.
Sunduğunuz protez türleri ve fiyatlandırma katalogu. Klinikler yayınlarken bu listeden seçebilir.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Yapım aşamasında</CardTitle>
<CardDescription>Ürün ekleme/düzenleme sonraki sürümde eklenecek.</CardDescription>
</CardHeader>
<CardContent />
</Card>
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
<Card>
<CardHeader>
<CardTitle>Eklenen Ürünler</CardTitle>
<CardDescription>
{rows.length === 0 ? "Henüz ürün yok." : `${rows.length} kalem`}
</CardDescription>
</CardHeader>
<CardContent>
<ProstheticsTable rows={rows} defaultCurrency={defaultCurrency} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Ürün Ekle</CardTitle>
<CardDescription>Protez türü ve birim fiyatını girin.</CardDescription>
</CardHeader>
<CardContent>
<ProstheticForm defaultCurrency={defaultCurrency} />
</CardContent>
</Card>
</div>
</div>
);
}