Compare commits
7 Commits
97a6031992
...
0e4033aa3f
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e4033aa3f | |||
| b1046e945a | |||
| 5dab958085 | |||
| 479972e9a9 | |||
| cdb2a15643 | |||
| 6fec52b98d | |||
| 12631cf9c5 |
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useActionState, useEffect, useState, useTransition } from "react";
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -108,17 +109,18 @@ function ApprovedRow({
|
||||
deleteConnectionAction,
|
||||
initialConnectionActionState,
|
||||
);
|
||||
const [, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success("Bağlantı silindi.");
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state]);
|
||||
}, [state, router]);
|
||||
|
||||
const kindLabel =
|
||||
row.counterpart?.kind === "lab"
|
||||
@@ -190,11 +192,7 @@ function ApprovedRow({
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">Vazgeç</Button>
|
||||
</DialogClose>
|
||||
<form
|
||||
action={(fd) => {
|
||||
startTransition(() => action(fd));
|
||||
}}
|
||||
>
|
||||
<form action={action}>
|
||||
<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" />}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useTransition } from "react";
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, Loader2, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -64,17 +65,25 @@ function InboundRow({ row }: { row: ConnectionWithCounterpart }) {
|
||||
rejectConnectionAction,
|
||||
initialConnectionActionState,
|
||||
);
|
||||
const [, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (approveState.ok) toast.success("Bağlantı onaylandı.");
|
||||
else if (approveState.error) toast.error(approveState.error);
|
||||
}, [approveState]);
|
||||
if (approveState.ok) {
|
||||
toast.success("Bağlantı onaylandı.");
|
||||
router.refresh();
|
||||
} else if (approveState.error) {
|
||||
toast.error(approveState.error);
|
||||
}
|
||||
}, [approveState, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rejectState.ok) toast.success("Talep reddedildi.");
|
||||
else if (rejectState.error) toast.error(rejectState.error);
|
||||
}, [rejectState]);
|
||||
if (rejectState.ok) {
|
||||
toast.success("Talep reddedildi.");
|
||||
router.refresh();
|
||||
} else if (rejectState.error) {
|
||||
toast.error(rejectState.error);
|
||||
}
|
||||
}, [rejectState, router]);
|
||||
|
||||
const kindLabel =
|
||||
row.counterpart?.kind === "lab"
|
||||
@@ -94,22 +103,14 @@ function InboundRow({ row }: { row: ConnectionWithCounterpart }) {
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<form
|
||||
action={(fd) => {
|
||||
startTransition(() => approveAction(fd));
|
||||
}}
|
||||
>
|
||||
<form action={approveAction}>
|
||||
<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));
|
||||
}}
|
||||
>
|
||||
<form action={rejectAction}>
|
||||
<input type="hidden" name="connectionId" value={row.$id} />
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useTransition } from "react";
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -57,12 +58,16 @@ function OutboundRow({ row }: { row: ConnectionWithCounterpart }) {
|
||||
cancelConnectionAction,
|
||||
initialConnectionActionState,
|
||||
);
|
||||
const [, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) toast.success("Talep iptal edildi.");
|
||||
else if (state.error) toast.error(state.error);
|
||||
}, [state]);
|
||||
if (state.ok) {
|
||||
toast.success("Talep iptal edildi.");
|
||||
router.refresh();
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state, router]);
|
||||
|
||||
const kindLabel =
|
||||
row.counterpart?.kind === "lab"
|
||||
@@ -81,11 +86,7 @@ function OutboundRow({ row }: { row: ConnectionWithCounterpart }) {
|
||||
{dateFormatter.format(new Date(row.requestedAt))}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<form
|
||||
action={(fd) => {
|
||||
startTransition(() => action(fd));
|
||||
}}
|
||||
>
|
||||
<form action={action}>
|
||||
<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" />}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { CounterpartBalance } from "@/lib/appwrite/payment-queries";
|
||||
import { RecordPaymentDialog } from "./record-payment-dialog";
|
||||
|
||||
function formatMoney(amount: number, currency: string): string {
|
||||
try {
|
||||
return new Intl.NumberFormat("tr-TR", { style: "currency", currency }).format(amount);
|
||||
} catch {
|
||||
return `${amount.toFixed(2)} ${currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
export function BalancesCard({
|
||||
balances,
|
||||
counterpartNames,
|
||||
selfKind,
|
||||
defaultCurrency,
|
||||
}: {
|
||||
balances: CounterpartBalance[];
|
||||
counterpartNames: Record<string, string>;
|
||||
selfKind: "lab" | "clinic";
|
||||
defaultCurrency: string;
|
||||
}) {
|
||||
const isLab = selfKind === "lab";
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{isLab ? "Klinik Bakiyeleri" : "Laboratuvar Bakiyeleri"}</CardTitle>
|
||||
<CardDescription>
|
||||
{isLab
|
||||
? "Her klinik için açık bakiye ve son tahsilatlar. Toplu ödemeleri buradan girebilirsiniz."
|
||||
: "Çalıştığınız her laboratuvar için açık bakiye ve son ödemeleriniz."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{balances.length === 0 ? (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||
Bağlantılarınız için henüz finansal hareket yok.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-md border">
|
||||
{balances.map((b) => {
|
||||
const name = counterpartNames[b.counterpartTenantId] ?? "—";
|
||||
const settled = b.open <= 0.01;
|
||||
return (
|
||||
<li
|
||||
key={b.counterpartTenantId}
|
||||
className="flex flex-wrap items-center gap-3 px-3 py-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{name}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Fatura: {formatMoney(b.invoiced, b.currency)} · Ödenen:{" "}
|
||||
{formatMoney(b.paid, b.currency)}
|
||||
{b.lastPaymentAt && (
|
||||
<>
|
||||
{" "}· Son ödeme:{" "}
|
||||
{dateFormatter.format(new Date(b.lastPaymentAt))}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p
|
||||
className={`text-base font-semibold tabular-nums ${
|
||||
settled
|
||||
? "text-muted-foreground"
|
||||
: isLab
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-rose-600 dark:text-rose-400"
|
||||
}`}
|
||||
>
|
||||
{formatMoney(b.open, b.currency)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{settled ? "Kapalı" : isLab ? "Açık alacak" : "Açık borç"}
|
||||
</p>
|
||||
</div>
|
||||
<RecordPaymentDialog
|
||||
counterpartTenantId={b.counterpartTenantId}
|
||||
counterpartName={name}
|
||||
selfKind={selfKind}
|
||||
defaultCurrency={b.currency || defaultCurrency}
|
||||
openAmount={b.open > 0 ? b.open : undefined}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, Loader2, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
confirmPaymentAction,
|
||||
rejectPaymentAction,
|
||||
} from "@/lib/appwrite/payment-actions";
|
||||
import {
|
||||
PAYMENT_METHOD_LABELS,
|
||||
initialPaymentActionState,
|
||||
} from "@/lib/appwrite/payment-types";
|
||||
import type { Payment } from "@/lib/appwrite/schema";
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
function formatMoney(amount: number, currency: string): string {
|
||||
try {
|
||||
return new Intl.NumberFormat("tr-TR", { style: "currency", currency }).format(amount);
|
||||
} catch {
|
||||
return `${amount.toFixed(2)} ${currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function PendingPaymentsCard({
|
||||
rows,
|
||||
counterpartNames,
|
||||
}: {
|
||||
rows: Payment[];
|
||||
counterpartNames: Record<string, string>;
|
||||
}) {
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<Card className="border-amber-300/50 dark:border-amber-500/30">
|
||||
<CardHeader>
|
||||
<CardTitle>Onay Bekleyen Ödemeler</CardTitle>
|
||||
<CardDescription>
|
||||
Klinikler aşağıdaki ödemeleri yaptıklarını bildirdi. Onayladığınızda
|
||||
açık bakiyeden düşülür.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-y rounded-md border">
|
||||
{rows.map((p) => (
|
||||
<PendingRow
|
||||
key={p.$id}
|
||||
payment={p}
|
||||
counterpartName={counterpartNames[p.tenantId] ?? "—"}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingRow({
|
||||
payment,
|
||||
counterpartName,
|
||||
}: {
|
||||
payment: Payment;
|
||||
counterpartName: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [confirmState, confirmAction, confirmPending] = useActionState(
|
||||
confirmPaymentAction,
|
||||
initialPaymentActionState,
|
||||
);
|
||||
const [rejectState, rejectAction, rejectPending] = useActionState(
|
||||
rejectPaymentAction,
|
||||
initialPaymentActionState,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (confirmState.ok) {
|
||||
toast.success("Ödeme onaylandı.");
|
||||
router.refresh();
|
||||
} else if (confirmState.error) {
|
||||
toast.error(confirmState.error);
|
||||
}
|
||||
}, [confirmState, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rejectState.ok) {
|
||||
toast.success("Ödeme reddedildi.");
|
||||
router.refresh();
|
||||
} else if (rejectState.error) {
|
||||
toast.error(rejectState.error);
|
||||
}
|
||||
}, [rejectState, router]);
|
||||
|
||||
const busy = confirmPending || rejectPending;
|
||||
|
||||
return (
|
||||
<li className="flex flex-wrap items-center gap-3 px-3 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{counterpartName}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{dateFormatter.format(new Date(payment.paymentDate))}
|
||||
{payment.method && (
|
||||
<> · {PAYMENT_METHOD_LABELS[payment.method] ?? payment.method}</>
|
||||
)}
|
||||
{payment.notes && <> · {payment.notes}</>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-base font-semibold tabular-nums">
|
||||
{formatMoney(payment.amount, payment.currency)}
|
||||
</p>
|
||||
<Badge variant="outline" className="text-amber-600 dark:text-amber-400">
|
||||
Onay bekliyor
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<form action={confirmAction}>
|
||||
<input type="hidden" name="id" value={payment.$id} />
|
||||
<Button type="submit" size="sm" disabled={busy}>
|
||||
{confirmPending ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
|
||||
Onayla
|
||||
</Button>
|
||||
</form>
|
||||
<form action={rejectAction}>
|
||||
<input type="hidden" name="id" value={payment.$id} />
|
||||
<Button type="submit" size="sm" variant="outline" disabled={busy}>
|
||||
{rejectPending ? <Loader2 className="size-4 animate-spin" /> : <X className="size-4" />}
|
||||
Reddet
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Banknote, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
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 { Textarea } from "@/components/ui/textarea";
|
||||
import { recordPaymentAction } from "@/lib/appwrite/payment-actions";
|
||||
import {
|
||||
PAYMENT_METHOD_OPTIONS,
|
||||
initialPaymentFormState,
|
||||
} from "@/lib/appwrite/payment-types";
|
||||
|
||||
export function RecordPaymentDialog({
|
||||
counterpartTenantId,
|
||||
counterpartName,
|
||||
selfKind,
|
||||
defaultCurrency,
|
||||
openAmount,
|
||||
triggerLabel,
|
||||
}: {
|
||||
counterpartTenantId: string;
|
||||
counterpartName: string;
|
||||
selfKind: "lab" | "clinic";
|
||||
defaultCurrency: string;
|
||||
openAmount?: number;
|
||||
triggerLabel?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [state, action, pending] = useActionState(
|
||||
recordPaymentAction,
|
||||
initialPaymentFormState,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success("Ödeme kaydedildi.");
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state, router]);
|
||||
|
||||
const label = triggerLabel ?? (selfKind === "lab" ? "Ödeme Al" : "Ödeme Yap");
|
||||
const title = selfKind === "lab" ? "Tahsilat Kaydı" : "Ödeme Kaydı";
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Button size="sm" onClick={() => setOpen(true)}>
|
||||
<Banknote className="size-4" />
|
||||
{label}
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title} — {counterpartName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selfKind === "lab"
|
||||
? "Bu kliniğin yaptığı toplu ödemeyi kaydedin. Açık bakiyeden otomatik düşülür."
|
||||
: "Bu laboratuvara yaptığınız ödemeyi kaydedin."}
|
||||
{typeof openAmount === "number" && (
|
||||
<>
|
||||
{" "}Açık bakiye:{" "}
|
||||
<strong className="tabular-nums">
|
||||
{openAmount.toLocaleString("tr-TR")} {defaultCurrency}
|
||||
</strong>
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={action} className="grid gap-3">
|
||||
<input type="hidden" name="counterpartTenantId" value={counterpartTenantId} />
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_100px] gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="amount">Tutar *</Label>
|
||||
<Input
|
||||
id="amount"
|
||||
name="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
required
|
||||
defaultValue={openAmount ?? undefined}
|
||||
/>
|
||||
{state.fieldErrors?.amount && (
|
||||
<p className="text-destructive text-xs">{state.fieldErrors.amount}</p>
|
||||
)}
|
||||
</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 grid-cols-2 gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="paymentDate">Tarih</Label>
|
||||
<Input
|
||||
id="paymentDate"
|
||||
name="paymentDate"
|
||||
type="date"
|
||||
defaultValue={today}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="method">Yöntem</Label>
|
||||
<Select name="method" defaultValue="bank">
|
||||
<SelectTrigger id="method">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAYMENT_METHOD_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="notes">Not (opsiyonel)</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
rows={2}
|
||||
maxLength={1000}
|
||||
placeholder="Örn. Ağustos toplu, dekont no 12345"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Vazgeç
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? <Loader2 className="size-4 animate-spin" /> : <Banknote className="size-4" />}
|
||||
Kaydet
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { listApprovedConnections } from "@/lib/appwrite/connection-queries";
|
||||
import { listFinanceEntries, summarizeFinance } from "@/lib/appwrite/finance-queries";
|
||||
import {
|
||||
computeBalancesByCounterpart,
|
||||
filterPendingForConfirmation,
|
||||
listIncomingPayments,
|
||||
listPayments,
|
||||
} from "@/lib/appwrite/payment-queries";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { BalancesCard } from "./components/balances-card";
|
||||
import { FinanceTable } from "./components/finance-table";
|
||||
import { PendingPaymentsCard } from "./components/pending-payments-card";
|
||||
|
||||
export const metadata = {
|
||||
title: "DLS — Finans",
|
||||
@@ -24,26 +33,76 @@ export default async function FinancePage() {
|
||||
} catch {
|
||||
redirect("/onboarding");
|
||||
}
|
||||
if (!ctx.kind) redirect("/onboarding");
|
||||
const kind = ctx.kind;
|
||||
|
||||
const [entries, ownPayments, incomingPayments, connections] = await Promise.all([
|
||||
listFinanceEntries(ctx.tenantId),
|
||||
listPayments(ctx.tenantId),
|
||||
listIncomingPayments(ctx.tenantId),
|
||||
listApprovedConnections(ctx.tenantId),
|
||||
]);
|
||||
|
||||
// Same physical payment can show up in both lists for the same tenant in
|
||||
// pathological cases; dedupe by $id to be safe.
|
||||
const seenIds = new Set<string>();
|
||||
const payments = [...ownPayments, ...incomingPayments].filter((p) =>
|
||||
seenIds.has(p.$id) ? false : (seenIds.add(p.$id), true),
|
||||
);
|
||||
|
||||
const entries = await listFinanceEntries(ctx.tenantId);
|
||||
const stats = summarizeFinance(entries);
|
||||
const isLab = ctx.kind === "lab";
|
||||
const isLab = kind === "lab";
|
||||
const defaultCurrency = ctx.settings?.defaultCurrency ?? "TRY";
|
||||
|
||||
const balances = computeBalancesByCounterpart({
|
||||
kind,
|
||||
selfTenantId: ctx.tenantId,
|
||||
entries,
|
||||
payments,
|
||||
});
|
||||
const pendingForApproval = filterPendingForConfirmation(payments, ctx.tenantId, kind);
|
||||
|
||||
const counterpartNames: Record<string, string> = {};
|
||||
for (const c of connections) {
|
||||
const id = isLab ? c.clinicTenantId : c.labTenantId;
|
||||
counterpartNames[id] = c.counterpart?.companyName ?? "—";
|
||||
}
|
||||
|
||||
const totalPaid = payments.reduce((sum, p) => {
|
||||
if (p.status && p.status !== "confirmed") return sum;
|
||||
const ownInflow = p.tenantId === ctx.tenantId && p.direction === (isLab ? "inflow" : "outflow");
|
||||
const incoming =
|
||||
p.counterpartTenantId === ctx.tenantId &&
|
||||
p.direction === (isLab ? "outflow" : "inflow");
|
||||
return sum + (ownInflow || incoming ? p.amount : 0);
|
||||
}, 0);
|
||||
const totalOpen = balances.reduce(
|
||||
(sum, b) => sum + (b.open > 0 ? b.open : 0),
|
||||
0,
|
||||
);
|
||||
|
||||
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">Finans</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
İş bazlı tahsilat ve ödeme akışı. {isLab ? "Alacaklarınız ve gelirleriniz." : "Ödenecek ve harcamalarınız."}
|
||||
{isLab
|
||||
? "Klinik bazlı açık bakiye ve toplu tahsilat."
|
||||
: "Laboratuvar bazlı borç ve ödeme akışı."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
label={isLab ? "Bekleyen Alacak" : "Bekleyen Borç"}
|
||||
value={formatMoney(isLab ? stats.receivablePending : stats.payablePending, stats.currency)}
|
||||
label={isLab ? "Toplam Açık Alacak" : "Toplam Açık Borç"}
|
||||
value={formatMoney(totalOpen, defaultCurrency)}
|
||||
tone={isLab ? "positive" : "negative"}
|
||||
/>
|
||||
<StatCard
|
||||
label={isLab ? "Tahsil Edilen" : "Ödenen"}
|
||||
value={formatMoney(totalPaid, defaultCurrency)}
|
||||
tone="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
label="Bu Ay Gelir"
|
||||
value={formatMoney(stats.incomeThisMonth, stats.currency)}
|
||||
@@ -54,18 +113,22 @@ export default async function FinancePage() {
|
||||
value={formatMoney(stats.expenseThisMonth, stats.currency)}
|
||||
tone="negative"
|
||||
/>
|
||||
<StatCard
|
||||
label="Toplam Kayıt"
|
||||
value={String(entries.length)}
|
||||
tone="neutral"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PendingPaymentsCard rows={pendingForApproval} counterpartNames={counterpartNames} />
|
||||
|
||||
<BalancesCard
|
||||
balances={balances}
|
||||
counterpartNames={counterpartNames}
|
||||
selfKind={kind}
|
||||
defaultCurrency={defaultCurrency}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Hareketler</CardTitle>
|
||||
<CardDescription>
|
||||
Tamamlanan işlerden otomatik oluşturulan finansal kayıtlar. Manuel kayıt eklemek sonraki sürümde.
|
||||
Tamamlanan işlerden otomatik oluşturulan finansal kayıtlar.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useState, useTransition } from "react";
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
CircleAlert,
|
||||
Loader2,
|
||||
Play,
|
||||
PackageCheck,
|
||||
Play,
|
||||
Send,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -26,15 +28,12 @@ import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
acceptJobAction,
|
||||
advanceStepAction,
|
||||
approveAtClinicAction,
|
||||
cancelJobAction,
|
||||
handToClinicAction,
|
||||
markDeliveredAction,
|
||||
} from "@/lib/appwrite/job-actions";
|
||||
import {
|
||||
JOB_STEP_LABELS,
|
||||
JOB_STEP_ORDER,
|
||||
initialJobActionState,
|
||||
} from "@/lib/appwrite/job-types";
|
||||
import { initialJobActionState } from "@/lib/appwrite/job-types";
|
||||
import type { Job, TenantKind } from "@/lib/appwrite/schema";
|
||||
|
||||
type Side = "clinic" | "lab";
|
||||
@@ -52,12 +51,29 @@ export function JobActionsPanel({
|
||||
|
||||
const isLab = side === "lab";
|
||||
const isClinic = side === "clinic";
|
||||
const location = job.location ?? "at_lab";
|
||||
const isAtLab = location === "at_lab";
|
||||
const isAtClinic = location === "at_clinic";
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Pending pickup — lab accepts */}
|
||||
{isLab && job.status === "pending" && <AcceptButton jobId={job.$id} />}
|
||||
{isLab && job.status === "in_progress" && <AdvanceButton job={job} />}
|
||||
|
||||
{/* Lab is producing — push to clinic for prova / final delivery */}
|
||||
{isLab && job.status === "in_progress" && isAtLab && (
|
||||
<HandToClinicButton job={job} />
|
||||
)}
|
||||
|
||||
{/* Clinic finished the prova — approve and send back to lab */}
|
||||
{isClinic && job.status === "in_progress" && isAtClinic && (
|
||||
<ApproveAtClinicButton job={job} />
|
||||
)}
|
||||
|
||||
{/* Final delivery — clinic took it from the lab */}
|
||||
{isClinic && job.status === "sent" && <DeliverButton jobId={job.$id} />}
|
||||
|
||||
{/* Cancel — only while the job hasn't started yet */}
|
||||
{(isClinic || isLab) && job.status === "pending" && (
|
||||
<CancelButton jobId={job.$id} />
|
||||
)}
|
||||
@@ -66,20 +82,20 @@ export function JobActionsPanel({
|
||||
}
|
||||
|
||||
function AcceptButton({ jobId }: { jobId: string }) {
|
||||
const router = useRouter();
|
||||
const [state, action, pending] = useActionState(acceptJobAction, initialJobActionState);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) toast.success("İş işleme alındı.");
|
||||
else if (state.error) toast.error(state.error);
|
||||
}, [state]);
|
||||
if (state.ok) {
|
||||
toast.success("İş işleme alındı, alt yapı üretimi başladı.");
|
||||
router.refresh();
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state, router]);
|
||||
|
||||
return (
|
||||
<form
|
||||
action={(fd) => {
|
||||
startTransition(() => action(fd));
|
||||
}}
|
||||
>
|
||||
<form action={action}>
|
||||
<input type="hidden" name="jobId" value={jobId} />
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
|
||||
@@ -89,50 +105,47 @@ function AcceptButton({ jobId }: { jobId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AdvanceButton({ job }: { job: Job }) {
|
||||
const [state, action, pending] = useActionState(advanceStepAction, initialJobActionState);
|
||||
const [, startTransition] = useTransition();
|
||||
function HandToClinicButton({ job }: { job: Job }) {
|
||||
const router = useRouter();
|
||||
const [state, action, pending] = useActionState(handToClinicAction, initialJobActionState);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success("Aşama ilerletildi.");
|
||||
toast.success("Klinik tarafına gönderildi.");
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state]);
|
||||
}, [state, router]);
|
||||
|
||||
const currentIdx = job.currentStep ? JOB_STEP_ORDER.indexOf(job.currentStep) : -1;
|
||||
const isFinal = currentIdx === JOB_STEP_ORDER.length - 1;
|
||||
const currentLabel = job.currentStep ? JOB_STEP_LABELS[job.currentStep] : "—";
|
||||
const nextLabel = isFinal
|
||||
? "Gönderildi olarak işaretle"
|
||||
: JOB_STEP_LABELS[JOB_STEP_ORDER[currentIdx + 1]];
|
||||
const isFinal = job.currentStep === "cila_bitim";
|
||||
const stageLabel =
|
||||
job.currentStep === "alt_yapi_prova"
|
||||
? "alt yapı"
|
||||
: job.currentStep === "ust_yapi_prova"
|
||||
? "üst yapı"
|
||||
: "cila/bitim";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
{isFinal ? <PackageCheck className="size-4" /> : <ArrowRight className="size-4" />}
|
||||
{isFinal ? "Gönderildi" : "Sonraki Aşama"}
|
||||
{isFinal ? <PackageCheck className="size-4" /> : <Send className="size-4" />}
|
||||
{isFinal ? "Cila Bitim — Nihai Teslime Gönder" : `${stageLabel === "alt yapı" ? "Alt Yapı" : "Üst Yapı"} Provaya Gönder`}
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isFinal ? "İşi gönderilmiş olarak işaretle" : `${currentLabel} tamamlandı`}
|
||||
{isFinal ? "Nihai teslime gönderilsin mi?" : "Kliniğe gönderilsin mi?"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isFinal
|
||||
? "İş artık 'Gönderildi' durumuna geçecek; klinik 'Teslim Aldım' onayını verecek."
|
||||
: `Sonraki aşama: ${nextLabel}. İsterseniz bir not ekleyebilirsiniz.`}
|
||||
? "Cila ve bitim tamamlandı; iş 'Gönderildi' durumuna geçer. Klinik teslim aldığında nihai onay verecek."
|
||||
: `${stageLabel === "alt yapı" ? "Alt yapı" : "Üst yapı"} provası için iş klinik tarafına geçer. Klinik provayı onayladığında size geri dönecek.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
action={(fd) => {
|
||||
startTransition(() => action(fd));
|
||||
}}
|
||||
className="grid gap-3"
|
||||
>
|
||||
<form action={action} className="grid gap-3">
|
||||
<input type="hidden" name="jobId" value={job.$id} />
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="note">Not (opsiyonel)</Label>
|
||||
@@ -141,7 +154,7 @@ function AdvanceButton({ job }: { job: Job }) {
|
||||
name="note"
|
||||
rows={3}
|
||||
maxLength={1000}
|
||||
placeholder="Örn. Renk kontrolü yapıldı, hasta provası onaylandı."
|
||||
placeholder="Örn. Renk A2, oklüzal kontak tamam"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
@@ -151,8 +164,70 @@ function AdvanceButton({ job }: { job: Job }) {
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
|
||||
Onayla
|
||||
{pending ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
|
||||
Gönder
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ApproveAtClinicButton({ job }: { job: Job }) {
|
||||
const router = useRouter();
|
||||
const [state, action, pending] = useActionState(approveAtClinicAction, initialJobActionState);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success("Prova onaylandı, lab tarafına gönderildi.");
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state, router]);
|
||||
|
||||
const stageLabel = job.currentStep === "alt_yapi_prova" ? "alt yapı" : "üst yapı";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
<Check className="size-4" />
|
||||
{stageLabel === "alt yapı" ? "Alt Yapı Provası Tamam" : "Üst Yapı Provası Tamam"}
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{`${stageLabel === "alt yapı" ? "Alt yapı" : "Üst yapı"} provası onaylansın mı?`}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Prova başarılı işaretlendiğinde iş bir sonraki aşamaya geçer ve
|
||||
laboratuvara geri döner.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={action} className="grid gap-3">
|
||||
<input type="hidden" name="jobId" value={job.$id} />
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="note">Not (opsiyonel)</Label>
|
||||
<Textarea
|
||||
id="note"
|
||||
name="note"
|
||||
rows={3}
|
||||
maxLength={1000}
|
||||
placeholder="Örn. Renk uyumlu, oklüzyon tamam"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Vazgeç
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? <Loader2 className="size-4 animate-spin" /> : <ArrowRight className="size-4" />}
|
||||
Onayla ve gönder
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -162,20 +237,20 @@ function AdvanceButton({ job }: { job: Job }) {
|
||||
}
|
||||
|
||||
function DeliverButton({ jobId }: { jobId: string }) {
|
||||
const router = useRouter();
|
||||
const [state, action, pending] = useActionState(markDeliveredAction, initialJobActionState);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) toast.success("İş teslim alındı.");
|
||||
else if (state.error) toast.error(state.error);
|
||||
}, [state]);
|
||||
if (state.ok) {
|
||||
toast.success("İş teslim alındı.");
|
||||
router.refresh();
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state, router]);
|
||||
|
||||
return (
|
||||
<form
|
||||
action={(fd) => {
|
||||
startTransition(() => action(fd));
|
||||
}}
|
||||
>
|
||||
<form action={action}>
|
||||
<input type="hidden" name="jobId" value={jobId} />
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? <Loader2 className="size-4 animate-spin" /> : <PackageCheck className="size-4" />}
|
||||
@@ -186,18 +261,19 @@ function DeliverButton({ jobId }: { jobId: string }) {
|
||||
}
|
||||
|
||||
function CancelButton({ jobId }: { jobId: string }) {
|
||||
const router = useRouter();
|
||||
const [state, action, pending] = useActionState(cancelJobAction, initialJobActionState);
|
||||
const [, startTransition] = useTransition();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success("İş iptal edildi.");
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state]);
|
||||
}, [state, router]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -215,11 +291,7 @@ function CancelButton({ jobId }: { jobId: string }) {
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
action={(fd) => {
|
||||
startTransition(() => action(fd));
|
||||
}}
|
||||
>
|
||||
<form action={action}>
|
||||
<input type="hidden" name="jobId" value={jobId} />
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useRef, useState, useTransition } from "react";
|
||||
import { useActionState, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Download, FileText, ImageIcon, Layers, Loader2, Trash2, Upload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -240,17 +240,33 @@ function FileRow({ file }: { file: JobFileWithUrl }) {
|
||||
deleteJobFileAction,
|
||||
initialJobFileActionState,
|
||||
);
|
||||
const [, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [downloadOpen, setDownloadOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success("Dosya silindi.");
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state]);
|
||||
}, [state, router]);
|
||||
|
||||
function triggerDownload() {
|
||||
// Use a programmatic anchor click — the server route streams the file
|
||||
// with Content-Disposition: attachment, so the browser hands it straight
|
||||
// to the download manager. Toast confirms it left our side.
|
||||
const a = document.createElement("a");
|
||||
a.href = file.url;
|
||||
a.download = file.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setDownloadOpen(false);
|
||||
toast.success("İndirme başladı.", { description: file.name });
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-3 px-3 py-2">
|
||||
@@ -264,11 +280,31 @@ function FileRow({ file }: { file: JobFileWithUrl }) {
|
||||
<Badge variant="outline" className="hidden sm:inline-flex">
|
||||
{JOB_FILE_KIND_LABELS[file.kind] ?? file.kind}
|
||||
</Badge>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={file.url} target="_blank" rel="noopener noreferrer" download={file.name}>
|
||||
<Dialog open={downloadOpen} onOpenChange={setDownloadOpen}>
|
||||
<Button size="sm" variant="outline" onClick={() => setDownloadOpen(true)}>
|
||||
<Download className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Dosya indirilsin mi?</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-medium">{file.name}</span>
|
||||
<span className="text-muted-foreground"> · {formatSize(file.size)}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Vazgeç
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="button" onClick={triggerDownload}>
|
||||
<Download className="size-4" />
|
||||
İndir
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
|
||||
<Trash2 className="size-4" />
|
||||
@@ -284,11 +320,7 @@ function FileRow({ file }: { file: JobFileWithUrl }) {
|
||||
Vazgeç
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<form
|
||||
action={(fd) => {
|
||||
startTransition(() => action(fd));
|
||||
}}
|
||||
>
|
||||
<form action={action}>
|
||||
<input type="hidden" name="rowId" value={file.$id} />
|
||||
<Button type="submit" variant="destructive" disabled={pending}>
|
||||
{pending ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { listJobHistory } from "@/lib/appwrite/job-history-queries";
|
||||
import { getPatient } from "@/lib/appwrite/patient-queries";
|
||||
import { toPlain } from "@/lib/appwrite/serialize";
|
||||
import {
|
||||
JOB_LOCATION_LABELS,
|
||||
JOB_STATUS_LABELS,
|
||||
JOB_STEP_LABELS,
|
||||
JOB_STEP_ORDER,
|
||||
@@ -141,6 +142,15 @@ export default async function JobDetailPage({
|
||||
<Info label="Mevcut Aşama">
|
||||
{job.currentStep ? JOB_STEP_LABELS[job.currentStep] : "—"}
|
||||
</Info>
|
||||
<Info label="Şu An">
|
||||
{job.status === "pending"
|
||||
? "Klinikte (lab teslim alacak)"
|
||||
: job.status === "delivered"
|
||||
? "Hasta'ya teslim edildi"
|
||||
: job.status === "cancelled"
|
||||
? "İptal"
|
||||
: JOB_LOCATION_LABELS[job.location ?? "at_lab"]}
|
||||
</Info>
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-muted-foreground mb-1 text-xs font-medium uppercase tracking-wide">
|
||||
Dişler ({job.teeth?.length ?? job.memberCount})
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useMemo, useState } from "react";
|
||||
import { useActionState, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, Send, Sparkles, TrendingDown } from "lucide-react";
|
||||
import { ArrowRight, CheckCircle2, FileUp, Loader2, Send, Sparkles, TrendingDown, Upload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -78,6 +80,10 @@ export function NewJobForm({
|
||||
const [prostheticId, setProstheticId] = useState<string>("");
|
||||
const [quote, setQuote] = useState<Quote | null>(null);
|
||||
const [quoteLoading, setQuoteLoading] = useState(false);
|
||||
// Wizard step — once the job is created we switch to the file upload step
|
||||
// and keep the user on this page until they finish or skip.
|
||||
const [step, setStep] = useState<"details" | "files">("details");
|
||||
const [createdJobId, setCreatedJobId] = useState<string | null>(null);
|
||||
|
||||
const labProsthetics = prostheticsByLab[labTenantId] ?? [];
|
||||
const selectedProsthetic = labProsthetics.find((p) => p.id === prostheticId);
|
||||
@@ -89,13 +95,14 @@ export function NewJobForm({
|
||||
const selectedPatient = patientId !== NONE_PATIENT ? patientById.get(patientId) : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success("İş yayınlandı.");
|
||||
router.push("/jobs/outbound");
|
||||
if (state.ok && state.jobId) {
|
||||
toast.success("İş kaydedildi. Dosyaları ekleyebilirsiniz.");
|
||||
setCreatedJobId(state.jobId);
|
||||
setStep("files");
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state, router]);
|
||||
}, [state]);
|
||||
|
||||
// Reset prosthetic selection when the lab changes so we never carry the
|
||||
// previous lab's catalog ID over.
|
||||
@@ -134,8 +141,19 @@ export function NewJobForm({
|
||||
};
|
||||
}, [prostheticId, teeth.length]);
|
||||
|
||||
if (step === "files" && createdJobId) {
|
||||
return (
|
||||
<FilesStep
|
||||
jobId={createdJobId}
|
||||
onDone={() => router.push(`/jobs/${createdJobId}`)}
|
||||
onSkip={() => router.push("/jobs/outbound")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={action} className="grid gap-5">
|
||||
<StepIndicator step="details" />
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label htmlFor="labTenantId">Laboratuvar *</Label>
|
||||
@@ -298,12 +316,12 @@ export function NewJobForm({
|
||||
{pending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Gönderiliyor...
|
||||
Kaydediliyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="size-4" />
|
||||
İşi Yayınla
|
||||
<ArrowRight className="size-4" />
|
||||
Devam Et — Dosyalar
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -312,6 +330,246 @@ export function NewJobForm({
|
||||
);
|
||||
}
|
||||
|
||||
function StepIndicator({ step }: { step: "details" | "files" }) {
|
||||
const items: { id: "details" | "files"; label: string }[] = [
|
||||
{ id: "details", label: "İş Bilgileri" },
|
||||
{ id: "files", label: "Dosyalar" },
|
||||
];
|
||||
return (
|
||||
<ol className="flex items-center gap-3 text-xs">
|
||||
{items.map((it, idx) => {
|
||||
const active = it.id === step;
|
||||
const done = items.findIndex((x) => x.id === step) > idx;
|
||||
return (
|
||||
<li key={it.id} className="flex items-center gap-3">
|
||||
<span
|
||||
className={`flex size-6 items-center justify-center rounded-full text-[11px] font-semibold ${
|
||||
active
|
||||
? "bg-primary text-primary-foreground"
|
||||
: done
|
||||
? "bg-emerald-600 text-white"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{done ? <CheckCircle2 className="size-3.5" /> : idx + 1}
|
||||
</span>
|
||||
<span className={active ? "font-medium" : "text-muted-foreground"}>{it.label}</span>
|
||||
{idx < items.length - 1 && (
|
||||
<span className="bg-border h-px w-6" aria-hidden />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
type PendingUpload = {
|
||||
id: string;
|
||||
file: File;
|
||||
kind: "scan" | "image" | "document";
|
||||
status: "queued" | "uploading" | "processing" | "done" | "error";
|
||||
progress: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function inferKind(file: File): PendingUpload["kind"] {
|
||||
const lower = file.name.toLowerCase();
|
||||
if (
|
||||
lower.endsWith(".stl") ||
|
||||
lower.endsWith(".ply") ||
|
||||
lower.endsWith(".obj") ||
|
||||
lower.endsWith(".dcm")
|
||||
)
|
||||
return "scan";
|
||||
if (file.type.startsWith("image/")) return "image";
|
||||
return "document";
|
||||
}
|
||||
|
||||
function FilesStep({
|
||||
jobId,
|
||||
onDone,
|
||||
onSkip,
|
||||
}: {
|
||||
jobId: string;
|
||||
onDone: () => void;
|
||||
onSkip: () => void;
|
||||
}) {
|
||||
const [items, setItems] = useState<PendingUpload[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const allDone = items.length > 0 && items.every((i) => i.status === "done");
|
||||
const anyBusy = items.some(
|
||||
(i) => i.status === "uploading" || i.status === "processing" || i.status === "queued",
|
||||
);
|
||||
|
||||
function addFiles(files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
const additions: PendingUpload[] = Array.from(files).map((file) => ({
|
||||
id: `${file.name}-${file.size}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
file,
|
||||
kind: inferKind(file),
|
||||
status: "queued",
|
||||
progress: 0,
|
||||
}));
|
||||
setItems((prev) => [...prev, ...additions]);
|
||||
additions.forEach(uploadOne);
|
||||
}
|
||||
|
||||
function uploadOne(item: PendingUpload) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", item.file);
|
||||
fd.append("kind", item.kind);
|
||||
|
||||
setItems((prev) =>
|
||||
prev.map((i) => (i.id === item.id ? { ...i, status: "uploading" } : i)),
|
||||
);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", `/api/jobs/${jobId}/files`);
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (!e.lengthComputable) return;
|
||||
const pct = Math.round((e.loaded / e.total) * 100);
|
||||
setItems((prev) =>
|
||||
prev.map((i) => (i.id === item.id ? { ...i, progress: pct } : i)),
|
||||
);
|
||||
};
|
||||
xhr.upload.onload = () => {
|
||||
// Bytes are up — server is now writing to Appwrite (can take a while
|
||||
// for big STL scans). Switch the row to a 'processing' state so the
|
||||
// user doesn't think we hung.
|
||||
setItems((prev) =>
|
||||
prev.map((i) =>
|
||||
i.id === item.id ? { ...i, status: "processing", progress: 100 } : i,
|
||||
),
|
||||
);
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
setItems((prev) =>
|
||||
prev.map((i) =>
|
||||
i.id === item.id
|
||||
? { ...i, status: "error", error: "Ağ hatası" }
|
||||
: i,
|
||||
),
|
||||
);
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
setItems((prev) =>
|
||||
prev.map((i) => (i.id === item.id ? { ...i, status: "done" } : i)),
|
||||
);
|
||||
} else {
|
||||
let msg = `HTTP ${xhr.status}`;
|
||||
try {
|
||||
const d = JSON.parse(xhr.responseText);
|
||||
if (d?.error) msg = d.error;
|
||||
} catch {}
|
||||
setItems((prev) =>
|
||||
prev.map((i) =>
|
||||
i.id === item.id ? { ...i, status: "error", error: msg } : i,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
xhr.send(fd);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
<StepIndicator step="files" />
|
||||
|
||||
<div className="bg-muted/30 grid gap-2 rounded-md border p-4 text-sm">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<FileUp className="size-4" />
|
||||
Tarama, görsel veya doküman ekleyin
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
STL/PLY/OBJ/DCM dosyaları tarama olarak; JPG/PNG görsel olarak;
|
||||
diğerleri doküman olarak kaydedilir. Her dosya 200 MB'a kadar olabilir.
|
||||
</p>
|
||||
<div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
addFiles(e.target.files);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={anyBusy}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
Dosya seç
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<ul className="divide-y rounded-md border">
|
||||
{items.map((i) => (
|
||||
<li key={i.id} className="grid gap-1.5 px-3 py-2.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="truncate text-sm">{i.file.name}</span>
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{(i.file.size / (1024 * 1024)).toFixed(1)} MB
|
||||
</span>
|
||||
</div>
|
||||
{i.status === "done" && (
|
||||
<p className="flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400">
|
||||
<CheckCircle2 className="size-3.5" />
|
||||
Yüklendi
|
||||
</p>
|
||||
)}
|
||||
{i.status === "error" && (
|
||||
<p className="text-destructive text-xs">Hata: {i.error}</p>
|
||||
)}
|
||||
{(i.status === "uploading" || i.status === "processing") && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress value={i.progress} className="flex-1" />
|
||||
<span className="text-muted-foreground w-20 text-right text-xs">
|
||||
{i.status === "processing"
|
||||
? "İşleniyor..."
|
||||
: `${i.progress}%`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip}
|
||||
className="text-muted-foreground hover:text-foreground text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
Şimdilik atla
|
||||
</button>
|
||||
<Button onClick={onDone} disabled={anyBusy || (items.length > 0 && !allDone)}>
|
||||
{anyBusy ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Bekleyin...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="size-4" />
|
||||
İlanı Tamamla
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PriceQuoteCard({
|
||||
quote,
|
||||
loading,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { BUCKETS, DATABASE_ID, TABLES, type Job, type JobFile } from "@/lib/appwrite/schema";
|
||||
import { createAdminClient } from "@/lib/appwrite/server";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
|
||||
/**
|
||||
* Server-side download proxy. The Appwrite bucket files are scoped to the
|
||||
* job's two teams (clinic + lab) and the lab's frontend domain doesn't carry
|
||||
* an Appwrite session cookie, so a direct browser → Appwrite link 401s. We
|
||||
* authenticate the caller via the lab session, verify they actually have
|
||||
* access to the job, then stream the file out with a forced attachment
|
||||
* disposition.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ jobId: string; fileId: string }> },
|
||||
) {
|
||||
const { jobId, fileId } = await params;
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { tablesDB, storage } = createAdminClient();
|
||||
|
||||
let job: Job;
|
||||
let file: JobFile;
|
||||
try {
|
||||
const [j, f] = await Promise.all([
|
||||
tablesDB.getRow(DATABASE_ID, TABLES.jobs, jobId) as Promise<unknown>,
|
||||
tablesDB.getRow(DATABASE_ID, TABLES.jobFiles, fileId) as Promise<unknown>,
|
||||
]);
|
||||
job = j as Job;
|
||||
file = f as JobFile;
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (file.jobId !== jobId) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
if (ctx.tenantId !== job.clinicTenantId && ctx.tenantId !== job.labTenantId) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const buf = (await storage.getFileDownload(BUCKETS.jobFiles, file.fileId)) as
|
||||
| ArrayBuffer
|
||||
| Buffer;
|
||||
const body =
|
||||
buf instanceof ArrayBuffer ? new Uint8Array(buf) : new Uint8Array(buf);
|
||||
|
||||
// Quote the filename so spaces / non-ASCII don't break the header.
|
||||
const safeName = file.name.replace(/["\\]/g, "_");
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(file.name)}`,
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -144,7 +144,7 @@ export async function POST(
|
||||
createdRowIds.push(row.$id);
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: tenantCtx.tenantId,
|
||||
userId: tenantCtx.user.id,
|
||||
action: "create",
|
||||
|
||||
@@ -110,7 +110,7 @@ export async function requestConnectionAction(
|
||||
approvedAt: null,
|
||||
rejectedAt: null,
|
||||
});
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
@@ -132,7 +132,7 @@ export async function requestConnectionAction(
|
||||
},
|
||||
connectionPermissions(clinicTenantId, labTenantId),
|
||||
);
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
@@ -141,7 +141,7 @@ export async function requestConnectionAction(
|
||||
changes: { clinicTenantId, labTenantId, status: "pending" },
|
||||
});
|
||||
const counterpartId = counterpart.tenantId;
|
||||
await createNotification({
|
||||
void createNotification({
|
||||
tenantId: counterpartId,
|
||||
connectionId: created.$id,
|
||||
message: `${ctx.settings?.companyName ?? "Bir hesap"} bağlantı talebi gönderdi.`,
|
||||
@@ -217,7 +217,7 @@ export async function approveConnectionAction(
|
||||
approvedAt: new Date().toISOString(),
|
||||
rejectedAt: null,
|
||||
});
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
@@ -227,7 +227,7 @@ export async function approveConnectionAction(
|
||||
});
|
||||
const requesterTenant =
|
||||
conn.clinicTenantId === ctx.tenantId ? conn.labTenantId : conn.clinicTenantId;
|
||||
await createNotification({
|
||||
void createNotification({
|
||||
tenantId: requesterTenant,
|
||||
connectionId,
|
||||
message: `${ctx.settings?.companyName ?? "Karşı taraf"} bağlantı talebinizi onayladı.`,
|
||||
@@ -270,7 +270,7 @@ export async function rejectConnectionAction(
|
||||
status: "rejected",
|
||||
rejectedAt: new Date().toISOString(),
|
||||
});
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
@@ -313,7 +313,7 @@ export async function cancelConnectionAction(
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.connections, connectionId);
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
@@ -350,7 +350,7 @@ export async function deleteConnectionAction(
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.connections, connectionId);
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
|
||||
@@ -55,7 +55,7 @@ export async function markFinancePaidAction(
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.financeEntries, id, {
|
||||
status: "paid",
|
||||
});
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
@@ -95,7 +95,7 @@ export async function reopenFinanceAction(
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.financeEntries, id, {
|
||||
status: "pending",
|
||||
});
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
|
||||
+157
-52
@@ -201,7 +201,7 @@ export async function createJobAction(
|
||||
},
|
||||
jobPermissions(ctx.tenantId, parsed.data.labTenantId),
|
||||
);
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
@@ -209,7 +209,7 @@ export async function createJobAction(
|
||||
entityId: created.$id,
|
||||
changes: { labTenantId: parsed.data.labTenantId, patientCode },
|
||||
});
|
||||
await createNotification({
|
||||
void createNotification({
|
||||
tenantId: parsed.data.labTenantId,
|
||||
jobId: created.$id,
|
||||
message: `${ctx.settings?.companyName ?? "Bir klinik"} yeni iş yayınladı (${patientCode}).`,
|
||||
@@ -297,23 +297,30 @@ export async function acceptJobAction(
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
// Accepting the job = lab took the impression, started substructure work.
|
||||
// Step jumps straight to alt_yapi_prova; location flips to at_lab.
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||||
status: "in_progress",
|
||||
currentStep: "olcu",
|
||||
currentStep: "alt_yapi_prova",
|
||||
location: "at_lab",
|
||||
});
|
||||
await appendJobHistory({ job, step: "olcu", completedBy: ctx.user.id });
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "job",
|
||||
entityId: jobId,
|
||||
changes: { status: "in_progress", currentStep: "olcu" },
|
||||
changes: {
|
||||
status: "in_progress",
|
||||
currentStep: "alt_yapi_prova",
|
||||
location: "at_lab",
|
||||
},
|
||||
});
|
||||
await createNotification({
|
||||
void createNotification({
|
||||
tenantId: job.clinicTenantId,
|
||||
jobId,
|
||||
message: `${ctx.settings?.companyName ?? "Lab"} hasta ${job.patientCode} işini işleme aldı.`,
|
||||
message: `${ctx.settings?.companyName ?? "Lab"} hasta ${job.patientCode} işini işleme aldı, alt yapı üretiminde.`,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Kabul edilemedi.") };
|
||||
@@ -325,7 +332,14 @@ export async function acceptJobAction(
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function advanceStepAction(
|
||||
/**
|
||||
* Lab hands the work back to the clinic for the next physical step
|
||||
* (prova or final delivery). The current step stays the same — only the
|
||||
* location flips at_lab → at_clinic. If the lab is finishing the last
|
||||
* production step (cila_bitim), that's the final delivery and the job
|
||||
* status becomes "sent".
|
||||
*/
|
||||
export async function handToClinicAction(
|
||||
_prev: JobActionState,
|
||||
formData: FormData,
|
||||
): Promise<JobActionState> {
|
||||
@@ -339,7 +353,7 @@ export async function advanceStepAction(
|
||||
requireRole(ctx, ["owner", "admin", "member"]);
|
||||
requireTenantKind(ctx, ["lab"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Sadece laboratuvar aşama ilerletebilir." };
|
||||
return { ok: false, error: "Sadece laboratuvar kliniğe gönderebilir." };
|
||||
}
|
||||
|
||||
const job = await loadJobForTenant(jobId, ctx.tenantId);
|
||||
@@ -347,66 +361,74 @@ export async function advanceStepAction(
|
||||
return { ok: false, error: "İş bulunamadı." };
|
||||
}
|
||||
if (job.status !== "in_progress") {
|
||||
return { ok: false, error: "Yalnızca işleme alınmış işler ilerletilebilir." };
|
||||
return { ok: false, error: "Sadece işlemdeki işler kliniğe gönderilebilir." };
|
||||
}
|
||||
if (job.location !== "at_lab") {
|
||||
return { ok: false, error: "İş zaten kliniğe gönderilmiş." };
|
||||
}
|
||||
if (!job.currentStep) {
|
||||
return { ok: false, error: "Mevcut aşama bilinmiyor." };
|
||||
}
|
||||
const currentIdx = job.currentStep ? JOB_STEP_ORDER.indexOf(job.currentStep) : -1;
|
||||
if (currentIdx < 0) return { ok: false, error: "Mevcut aşama bilinmiyor." };
|
||||
|
||||
const nextIdx = currentIdx + 1;
|
||||
const isFinalStepComplete = currentIdx === JOB_STEP_ORDER.length - 1;
|
||||
const isFinalStep = job.currentStep === "cila_bitim";
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
if (isFinalStepComplete) {
|
||||
if (isFinalStep) {
|
||||
// Final delivery — production is done, status moves to sent.
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||||
status: "sent",
|
||||
});
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "job",
|
||||
entityId: jobId,
|
||||
changes: { status: "sent" },
|
||||
});
|
||||
} else {
|
||||
const nextStep = JOB_STEP_ORDER[nextIdx];
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||||
currentStep: nextStep,
|
||||
location: "at_clinic",
|
||||
});
|
||||
await appendJobHistory({
|
||||
job,
|
||||
step: job.currentStep!,
|
||||
step: "cila_bitim",
|
||||
completedBy: ctx.user.id,
|
||||
note,
|
||||
});
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "job",
|
||||
entityId: jobId,
|
||||
changes: { currentStep: nextStep, completedStep: job.currentStep },
|
||||
changes: { status: "sent", location: "at_clinic" },
|
||||
});
|
||||
void syncFinanceForJob({ ...job, status: "sent" });
|
||||
void createNotification({
|
||||
tenantId: job.clinicTenantId,
|
||||
jobId,
|
||||
message: `Hasta ${job.patientCode} cila/bitim tamamlandı, nihai teslime gönderildi.`,
|
||||
});
|
||||
} else {
|
||||
// Prova için klinike geçici teslim — step aynı, location değişti.
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||||
location: "at_clinic",
|
||||
});
|
||||
await appendJobHistory({
|
||||
job,
|
||||
step: job.currentStep,
|
||||
completedBy: ctx.user.id,
|
||||
note,
|
||||
});
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "job",
|
||||
entityId: jobId,
|
||||
changes: { location: "at_clinic", handedOffStep: job.currentStep },
|
||||
});
|
||||
const stepLabel =
|
||||
job.currentStep === "alt_yapi_prova" ? "alt yapı" : "üst yapı";
|
||||
void createNotification({
|
||||
tenantId: job.clinicTenantId,
|
||||
jobId,
|
||||
message: `Hasta ${job.patientCode} ${stepLabel} provasına hazır, kliniğe gönderildi.`,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "İlerletilemedi.") };
|
||||
}
|
||||
|
||||
if (isFinalStepComplete) {
|
||||
// Record completion of the last step too, then mark sent.
|
||||
await appendJobHistory({
|
||||
job,
|
||||
step: job.currentStep!,
|
||||
completedBy: ctx.user.id,
|
||||
note,
|
||||
});
|
||||
await syncFinanceForJob({ ...job, status: "sent" });
|
||||
await createNotification({
|
||||
tenantId: job.clinicTenantId,
|
||||
jobId,
|
||||
message: `Hasta ${job.patientCode} işi gönderildi. Teslim alındığında onaylayın.`,
|
||||
});
|
||||
return { ok: false, error: appwriteError(e, "Gönderilemedi.") };
|
||||
}
|
||||
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
@@ -416,6 +438,89 @@ export async function advanceStepAction(
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clinic confirms the prova was successful. Step advances to the next
|
||||
* production stage and location flips back at_clinic → at_lab so the
|
||||
* lab can pick the work back up.
|
||||
*/
|
||||
export async function approveAtClinicAction(
|
||||
_prev: JobActionState,
|
||||
formData: FormData,
|
||||
): Promise<JobActionState> {
|
||||
const jobId = String(formData.get("jobId") ?? "").trim();
|
||||
if (!jobId) return { ok: false, error: "İş bulunamadı." };
|
||||
const note = String(formData.get("note") ?? "").trim() || undefined;
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin", "member"]);
|
||||
requireTenantKind(ctx, ["clinic"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Sadece klinik provayı onaylayabilir." };
|
||||
}
|
||||
|
||||
const job = await loadJobForTenant(jobId, ctx.tenantId);
|
||||
if (!job || job.clinicTenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "İş bulunamadı." };
|
||||
}
|
||||
if (job.status !== "in_progress") {
|
||||
return { ok: false, error: "Yalnızca işlemdeki provalar onaylanabilir." };
|
||||
}
|
||||
if (job.location !== "at_clinic") {
|
||||
return { ok: false, error: "İş şu an klinikte değil." };
|
||||
}
|
||||
if (!job.currentStep) {
|
||||
return { ok: false, error: "Mevcut aşama bilinmiyor." };
|
||||
}
|
||||
|
||||
const currentIdx = JOB_STEP_ORDER.indexOf(job.currentStep);
|
||||
const nextStep = JOB_STEP_ORDER[currentIdx + 1];
|
||||
if (!nextStep) {
|
||||
return { ok: false, error: "Bu aşamadan ileri gidilemez." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||||
currentStep: nextStep,
|
||||
location: "at_lab",
|
||||
});
|
||||
await appendJobHistory({
|
||||
job,
|
||||
step: job.currentStep,
|
||||
completedBy: ctx.user.id,
|
||||
note,
|
||||
});
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "job",
|
||||
entityId: jobId,
|
||||
changes: {
|
||||
currentStep: nextStep,
|
||||
location: "at_lab",
|
||||
completedStep: job.currentStep,
|
||||
},
|
||||
});
|
||||
const stepLabel =
|
||||
job.currentStep === "alt_yapi_prova" ? "alt yapı" : "üst yapı";
|
||||
void createNotification({
|
||||
tenantId: job.labTenantId,
|
||||
jobId,
|
||||
message: `Hasta ${job.patientCode} ${stepLabel} provası onaylandı, lab tarafına geri döndü.`,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Onaylanamadı.") };
|
||||
}
|
||||
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
revalidatePath("/jobs/inbound");
|
||||
revalidatePath("/jobs/outbound");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function markDeliveredAction(
|
||||
_prev: JobActionState,
|
||||
formData: FormData,
|
||||
@@ -445,7 +550,7 @@ export async function markDeliveredAction(
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||||
status: "delivered",
|
||||
});
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
@@ -453,8 +558,8 @@ export async function markDeliveredAction(
|
||||
entityId: jobId,
|
||||
changes: { status: "delivered" },
|
||||
});
|
||||
await syncFinanceForJob({ ...job, status: "delivered" });
|
||||
await createNotification({
|
||||
void syncFinanceForJob({ ...job, status: "delivered" });
|
||||
void createNotification({
|
||||
tenantId: job.labTenantId,
|
||||
jobId,
|
||||
message: `Hasta ${job.patientCode} işi teslim alındı.`,
|
||||
@@ -502,7 +607,7 @@ export async function cancelJobAction(
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||||
status: "cancelled",
|
||||
});
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
|
||||
@@ -138,7 +138,7 @@ export async function uploadJobFilesAction(
|
||||
createdRowIds.push(row.$id);
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
@@ -206,7 +206,7 @@ export async function deleteJobFileAction(
|
||||
// File may already be gone; row is the source of truth.
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
|
||||
@@ -2,12 +2,12 @@ import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { BUCKETS, DATABASE_ID, TABLES, type JobFile } from "./schema";
|
||||
import { DATABASE_ID, TABLES, type JobFile } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { toPlain } from "./serialize";
|
||||
import { getFileViewUrl } from "./storage";
|
||||
|
||||
export type JobFileWithUrl = JobFile & {
|
||||
/** Server-side download proxy. Browser → our app → admin SDK → bucket. */
|
||||
url: string;
|
||||
};
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function listJobFiles(jobId: string): Promise<JobFileWithUrl[]> {
|
||||
return toPlain(
|
||||
rows.map((r) => ({
|
||||
...r,
|
||||
url: getFileViewUrl(BUCKETS.jobFiles, r.fileId),
|
||||
url: `/api/jobs/${jobId}/files/${r.$id}/download`,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { JobStatus, JobStep, ProstheticType } from "./schema";
|
||||
import type { JobLocation, JobStatus, JobStep, ProstheticType } from "./schema";
|
||||
|
||||
export type JobFormState = {
|
||||
ok: boolean;
|
||||
@@ -38,6 +38,11 @@ export const JOB_STEP_ORDER: JobStep[] = [
|
||||
"cila_bitim",
|
||||
];
|
||||
|
||||
export const JOB_LOCATION_LABELS: Record<JobLocation, string> = {
|
||||
at_clinic: "Klinikte",
|
||||
at_lab: "Laboratuvarda",
|
||||
};
|
||||
|
||||
export const PROSTHETIC_TYPE_LABELS: Record<ProstheticType, string> = {
|
||||
metal_porselen: "Metal Porselen",
|
||||
zirkonyum: "Zirkonyum",
|
||||
|
||||
@@ -87,7 +87,7 @@ export async function uploadLogoAction(
|
||||
}
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
@@ -151,7 +151,7 @@ export async function removeLogoAction(): Promise<LogoActionState> {
|
||||
/* file already gone, fine */
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
|
||||
@@ -137,7 +137,7 @@ export async function createPatientAction(
|
||||
},
|
||||
patientPermissions(ctx.tenantId),
|
||||
);
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
@@ -191,7 +191,7 @@ export async function updatePatientAction(
|
||||
lastName: parsed.data.lastName,
|
||||
notes: parsed.data.notes,
|
||||
});
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
@@ -236,7 +236,7 @@ export async function archivePatientAction(
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.patients, id, {
|
||||
archived: !row.archived,
|
||||
});
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
|
||||
import { z } from "zod";
|
||||
|
||||
import { logAudit } from "./audit";
|
||||
import { createNotification } from "./notification-helpers";
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type Connection,
|
||||
type Payment,
|
||||
} from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireRole, requireTenant } from "./tenant-guard";
|
||||
import type {
|
||||
PaymentActionState,
|
||||
PaymentFormState,
|
||||
} from "./payment-types";
|
||||
import { paymentSchema } from "@/lib/validation/payment";
|
||||
|
||||
function appwriteError(e: unknown, fallback = "Beklenmeyen bir hata oluştu."): string {
|
||||
if (e instanceof AppwriteException) return e.message || fallback;
|
||||
return process.env.NODE_ENV !== "production" && e instanceof Error
|
||||
? `${fallback} (${e.message})`
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function flattenErrors(err: z.ZodError): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const issue of err.issues) {
|
||||
const key = issue.path.join(".");
|
||||
if (key && !out[key]) out[key] = issue.message;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function paymentPermissions(tenantId: string, counterpartTenantId: string): string[] {
|
||||
return [
|
||||
Permission.read(Role.team(tenantId)),
|
||||
Permission.read(Role.team(counterpartTenantId)),
|
||||
Permission.update(Role.team(tenantId, "owner")),
|
||||
Permission.update(Role.team(tenantId, "admin")),
|
||||
Permission.delete(Role.team(tenantId, "owner")),
|
||||
Permission.delete(Role.team(tenantId, "admin")),
|
||||
];
|
||||
}
|
||||
|
||||
function pickFields(formData: FormData) {
|
||||
return {
|
||||
counterpartTenantId: String(formData.get("counterpartTenantId") ?? "").trim(),
|
||||
amount: String(formData.get("amount") ?? "").trim(),
|
||||
currency: String(formData.get("currency") ?? "").trim(),
|
||||
paymentDate: String(formData.get("paymentDate") ?? "").trim(),
|
||||
method: String(formData.get("method") ?? "").trim(),
|
||||
notes: String(formData.get("notes") ?? "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a payment between this tenant and a counterpart. Direction is
|
||||
* determined by the caller's tenant kind:
|
||||
* - lab → inflow (the lab received money from a clinic)
|
||||
* - clinic → outflow (the clinic paid a lab)
|
||||
*
|
||||
* A single payment can cover many invoices at once — we do NOT walk back
|
||||
* into finance_entries to settle individual rows. The open balance per
|
||||
* connection is always computed live as (sum of receivables for that
|
||||
* counterpart) - (sum of payments inflow from that counterpart) for the
|
||||
* lab side, and the symmetric formula for the clinic side.
|
||||
*/
|
||||
export async function recordPaymentAction(
|
||||
_prev: PaymentFormState,
|
||||
formData: FormData,
|
||||
): Promise<PaymentFormState> {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Ödeme kaydı için yetkiniz yok." };
|
||||
}
|
||||
if (!ctx.kind) {
|
||||
return { ok: false, error: "Tenant türü bilinmiyor." };
|
||||
}
|
||||
const direction = ctx.kind === "lab" ? "inflow" : "outflow";
|
||||
|
||||
const parsed = paymentSchema.safeParse(pickFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
// Counterpart must be an approved connection — we never let a tenant
|
||||
// record payments against an unconnected workspace.
|
||||
const labId = ctx.kind === "lab" ? ctx.tenantId : parsed.data.counterpartTenantId;
|
||||
const clinicId = ctx.kind === "lab" ? parsed.data.counterpartTenantId : ctx.tenantId;
|
||||
const connRes = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.connections,
|
||||
queries: [
|
||||
Query.equal("labTenantId", labId),
|
||||
Query.equal("clinicTenantId", clinicId),
|
||||
Query.equal("status", "approved"),
|
||||
Query.limit(1),
|
||||
],
|
||||
});
|
||||
if (!(connRes.rows[0] as unknown as Connection | undefined)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Onaylı bir bağlantı bulunamadı.",
|
||||
fieldErrors: { counterpartTenantId: "Bağlantı yok." },
|
||||
};
|
||||
}
|
||||
|
||||
// Lab-recorded payments are self-confirmed (the lab knows it received
|
||||
// the money). Clinic-recorded payments enter as pending and require the
|
||||
// lab to confirm before they're counted in the open balance.
|
||||
const status = ctx.kind === "lab" ? "confirmed" : "pending";
|
||||
|
||||
try {
|
||||
const created = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.payments,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
counterpartTenantId: parsed.data.counterpartTenantId,
|
||||
direction,
|
||||
amount: parsed.data.amount,
|
||||
currency: parsed.data.currency,
|
||||
paymentDate: parsed.data.paymentDate,
|
||||
method: parsed.data.method,
|
||||
notes: parsed.data.notes,
|
||||
status,
|
||||
recordedBy: ctx.user.id,
|
||||
},
|
||||
paymentPermissions(ctx.tenantId, parsed.data.counterpartTenantId),
|
||||
);
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "payment",
|
||||
entityId: created.$id,
|
||||
changes: {
|
||||
direction,
|
||||
amount: parsed.data.amount,
|
||||
counterpartTenantId: parsed.data.counterpartTenantId,
|
||||
status,
|
||||
},
|
||||
});
|
||||
// Notify the lab when a clinic submits a payment for approval.
|
||||
if (status === "pending") {
|
||||
void createNotification({
|
||||
tenantId: parsed.data.counterpartTenantId,
|
||||
message: `Yeni ödeme bildirimi: ${parsed.data.amount.toLocaleString("tr-TR")} ${parsed.data.currency} — onayınızı bekliyor.`,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Ödeme kaydedilemedi.") };
|
||||
}
|
||||
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lab confirms a payment the clinic claimed to have made. The row stays
|
||||
* with the clinic as the recorder (tenantId), only its status flips so
|
||||
* the balance computation starts counting it.
|
||||
*/
|
||||
export async function confirmPaymentAction(
|
||||
_prev: PaymentActionState,
|
||||
formData: FormData,
|
||||
): Promise<PaymentActionState> {
|
||||
const id = String(formData.get("id") ?? "").trim();
|
||||
if (!id) return { ok: false, error: "Ödeme kaydı bulunamadı." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
if (ctx.kind !== "lab") {
|
||||
return { ok: false, error: "Onayı yalnızca laboratuvar verebilir." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.payments,
|
||||
id,
|
||||
)) as unknown as Payment;
|
||||
// Lab can only confirm payments where IT is the counterpart and the
|
||||
// clinic was the recorder. Anything else is a permission error.
|
||||
if (row.counterpartTenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Bu ödeme size ait değil." };
|
||||
}
|
||||
if (row.status === "confirmed") {
|
||||
return { ok: true };
|
||||
}
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.payments, id, {
|
||||
status: "confirmed",
|
||||
});
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "payment",
|
||||
entityId: id,
|
||||
changes: { status: "confirmed" },
|
||||
});
|
||||
void createNotification({
|
||||
tenantId: row.tenantId,
|
||||
message: `Ödemeniz onaylandı: ${row.amount.toLocaleString("tr-TR")} ${row.currency}.`,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Onaylanamadı.") };
|
||||
}
|
||||
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function rejectPaymentAction(
|
||||
_prev: PaymentActionState,
|
||||
formData: FormData,
|
||||
): Promise<PaymentActionState> {
|
||||
const id = String(formData.get("id") ?? "").trim();
|
||||
if (!id) return { ok: false, error: "Ödeme kaydı bulunamadı." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
if (ctx.kind !== "lab") {
|
||||
return { ok: false, error: "Reddi yalnızca laboratuvar verebilir." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.payments,
|
||||
id,
|
||||
)) as unknown as Payment;
|
||||
if (row.counterpartTenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Bu ödeme size ait değil." };
|
||||
}
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.payments, id, {
|
||||
status: "rejected",
|
||||
});
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "payment",
|
||||
entityId: id,
|
||||
changes: { status: "rejected" },
|
||||
});
|
||||
void createNotification({
|
||||
tenantId: row.tenantId,
|
||||
message: `Ödeme bildiriminiz reddedildi: ${row.amount.toLocaleString("tr-TR")} ${row.currency}.`,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Reddedilemedi.") };
|
||||
}
|
||||
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deletePaymentAction(
|
||||
_prev: PaymentActionState,
|
||||
formData: FormData,
|
||||
): Promise<PaymentActionState> {
|
||||
const id = String(formData.get("id") ?? "").trim();
|
||||
if (!id) return { ok: false, error: "Kayıt bulunamadı." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.payments,
|
||||
id,
|
||||
)) as unknown as Payment;
|
||||
if (row.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.payments, id);
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
entityType: "payment",
|
||||
entityId: id,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Silinemedi.") };
|
||||
}
|
||||
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type FinanceEntry,
|
||||
type Payment,
|
||||
type TenantKind,
|
||||
} from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { toPlain } from "./serialize";
|
||||
|
||||
/** Payments this tenant recorded itself. */
|
||||
export async function listPayments(tenantId: string): Promise<Payment[]> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.payments,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.orderDesc("paymentDate"),
|
||||
Query.limit(500),
|
||||
],
|
||||
});
|
||||
return toPlain(result.rows as unknown as Payment[]);
|
||||
}
|
||||
|
||||
/** Payments the counterpart recorded that involve this tenant. */
|
||||
export async function listIncomingPayments(tenantId: string): Promise<Payment[]> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.payments,
|
||||
queries: [
|
||||
Query.equal("counterpartTenantId", tenantId),
|
||||
Query.orderDesc("paymentDate"),
|
||||
Query.limit(500),
|
||||
],
|
||||
});
|
||||
return toPlain(result.rows as unknown as Payment[]);
|
||||
}
|
||||
|
||||
export type CounterpartBalance = {
|
||||
counterpartTenantId: string;
|
||||
currency: string;
|
||||
/** sum of receivables (lab) or debts (clinic) from finance_entries */
|
||||
invoiced: number;
|
||||
/** sum of *confirmed* payments — whoever recorded them */
|
||||
paid: number;
|
||||
/** invoiced - paid; positive means money is still owed to this tenant */
|
||||
open: number;
|
||||
/** most recent confirmed payment date if any, useful for sorting */
|
||||
lastPaymentAt?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* For a given payment row, figure out whether it represents money flowing
|
||||
* *toward* `selfTenantId` from a given counterpart. Same physical payment
|
||||
* looks like inflow from one side and outflow from the other — we
|
||||
* normalise both shapes into 'who is the counterpart?' here.
|
||||
*/
|
||||
function inflowFor(
|
||||
p: Payment,
|
||||
selfTenantId: string,
|
||||
kind: TenantKind,
|
||||
): { counterpartTenantId: string } | null {
|
||||
const inflowDir = kind === "lab" ? "inflow" : "outflow";
|
||||
const outflowDir = kind === "lab" ? "outflow" : "inflow";
|
||||
if (p.tenantId === selfTenantId && p.direction === inflowDir) {
|
||||
return { counterpartTenantId: p.counterpartTenantId };
|
||||
}
|
||||
if (p.counterpartTenantId === selfTenantId && p.direction === outflowDir) {
|
||||
return { counterpartTenantId: p.tenantId };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function computeBalancesByCounterpart(args: {
|
||||
kind: TenantKind;
|
||||
selfTenantId: string;
|
||||
entries: FinanceEntry[];
|
||||
payments: Payment[];
|
||||
}): CounterpartBalance[] {
|
||||
const isLab = args.kind === "lab";
|
||||
const invoiceType = isLab ? "receivable" : "debt";
|
||||
|
||||
const acc = new Map<string, CounterpartBalance>();
|
||||
const ensure = (id: string, currency: string): CounterpartBalance => {
|
||||
const existing = acc.get(id);
|
||||
if (existing) return existing;
|
||||
const fresh: CounterpartBalance = {
|
||||
counterpartTenantId: id,
|
||||
currency,
|
||||
invoiced: 0,
|
||||
paid: 0,
|
||||
open: 0,
|
||||
};
|
||||
acc.set(id, fresh);
|
||||
return fresh;
|
||||
};
|
||||
|
||||
for (const e of args.entries) {
|
||||
if (e.type !== invoiceType) continue;
|
||||
if (!e.counterpartTenantId) continue;
|
||||
const row = ensure(e.counterpartTenantId, e.currency ?? "TRY");
|
||||
row.invoiced += e.amount;
|
||||
}
|
||||
for (const p of args.payments) {
|
||||
// Only confirmed payments count toward the open balance.
|
||||
if (p.status && p.status !== "confirmed") continue;
|
||||
const mapped = inflowFor(p, args.selfTenantId, args.kind);
|
||||
if (!mapped) continue;
|
||||
const row = ensure(mapped.counterpartTenantId, p.currency);
|
||||
row.paid += p.amount;
|
||||
if (!row.lastPaymentAt || p.paymentDate > row.lastPaymentAt) {
|
||||
row.lastPaymentAt = p.paymentDate;
|
||||
}
|
||||
}
|
||||
for (const row of acc.values()) {
|
||||
row.open = row.invoiced - row.paid;
|
||||
}
|
||||
return Array.from(acc.values()).sort((a, b) => b.open - a.open);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending payments that the *other* side recorded and are waiting on this
|
||||
* tenant's confirmation. Only applicable on the lab side in our current
|
||||
* model (clinics record, labs confirm), but the query is symmetrical.
|
||||
*/
|
||||
export function filterPendingForConfirmation(
|
||||
payments: Payment[],
|
||||
selfTenantId: string,
|
||||
kind: TenantKind,
|
||||
): Payment[] {
|
||||
const expectedDirection = kind === "lab" ? "outflow" : "inflow";
|
||||
return payments
|
||||
.filter(
|
||||
(p) =>
|
||||
p.status === "pending" &&
|
||||
p.counterpartTenantId === selfTenantId &&
|
||||
p.direction === expectedDirection,
|
||||
)
|
||||
.sort((a, b) => (a.paymentDate < b.paymentDate ? 1 : -1));
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export type PaymentFormState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string>;
|
||||
};
|
||||
|
||||
export const initialPaymentFormState: PaymentFormState = { ok: false };
|
||||
|
||||
export type PaymentActionState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const initialPaymentActionState: PaymentActionState = { ok: false };
|
||||
|
||||
export const PAYMENT_METHOD_OPTIONS = [
|
||||
{ value: "cash", label: "Nakit" },
|
||||
{ value: "bank", label: "Banka / Havale" },
|
||||
{ value: "card", label: "Kart" },
|
||||
{ value: "check", label: "Çek / Senet" },
|
||||
{ value: "other", label: "Diğer" },
|
||||
] as const;
|
||||
|
||||
export const PAYMENT_METHOD_LABELS: Record<string, string> = Object.fromEntries(
|
||||
PAYMENT_METHOD_OPTIONS.map((o) => [o.value, o.label]),
|
||||
);
|
||||
@@ -28,7 +28,7 @@ async function audit(action: "update", entityType: string, changes: Record<strin
|
||||
const session = await createSessionClient();
|
||||
const user = await session.account.get();
|
||||
const tenantId = (await getActiveTenantId()) ?? "global";
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId,
|
||||
userId: user.$id,
|
||||
action,
|
||||
|
||||
@@ -81,7 +81,7 @@ export async function createProstheticAction(
|
||||
},
|
||||
prostheticPermissions(ctx.tenantId),
|
||||
);
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
@@ -134,7 +134,7 @@ export async function updateProstheticAction(
|
||||
unitPrice: parsed.data.unitPrice,
|
||||
currency: parsed.data.currency,
|
||||
});
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
@@ -179,7 +179,7 @@ export async function archiveProstheticAction(
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.prosthetics, id, {
|
||||
archived: !row.archived,
|
||||
});
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
@@ -222,7 +222,7 @@ export async function deleteProstheticAction(
|
||||
return { ok: false, error: "Bu ürünü silme yetkiniz yok." };
|
||||
}
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.prosthetics, id);
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
|
||||
@@ -11,6 +11,7 @@ export const TABLES = {
|
||||
connections: "connections",
|
||||
patients: "patients",
|
||||
clinicPricing: "clinic_pricing",
|
||||
payments: "payments",
|
||||
jobs: "jobs",
|
||||
jobFiles: "job_files",
|
||||
jobStatusHistory: "job_status_history",
|
||||
@@ -75,6 +76,7 @@ export interface Connection extends Row {
|
||||
|
||||
export type JobStatus = "pending" | "in_progress" | "sent" | "delivered" | "cancelled";
|
||||
export type JobStep = "olcu" | "alt_yapi_prova" | "ust_yapi_prova" | "cila_bitim";
|
||||
export type JobLocation = "at_clinic" | "at_lab";
|
||||
export type ProstheticType =
|
||||
| "metal_porselen"
|
||||
| "zirkonyum"
|
||||
@@ -109,6 +111,7 @@ export interface Job extends Row {
|
||||
currency?: string;
|
||||
status: JobStatus;
|
||||
currentStep?: JobStep;
|
||||
location?: JobLocation;
|
||||
dueDate?: string;
|
||||
}
|
||||
|
||||
@@ -172,6 +175,22 @@ export interface FinanceEntry extends Row {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type PaymentDirection = "inflow" | "outflow";
|
||||
export type PaymentStatus = "pending" | "confirmed" | "rejected";
|
||||
|
||||
export interface Payment extends Row {
|
||||
tenantId: string;
|
||||
counterpartTenantId: string;
|
||||
direction: PaymentDirection;
|
||||
amount: number;
|
||||
currency: string;
|
||||
paymentDate: string;
|
||||
method?: string;
|
||||
notes?: string;
|
||||
status?: PaymentStatus;
|
||||
recordedBy: string;
|
||||
}
|
||||
|
||||
export interface Notification extends Row {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
|
||||
@@ -139,7 +139,7 @@ export async function inviteMemberAction(
|
||||
],
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
@@ -182,7 +182,7 @@ export async function cancelInviteAction(
|
||||
status: "cancelled",
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
@@ -228,7 +228,7 @@ export async function removeMemberAction(
|
||||
|
||||
await teams.deleteMembership(ctx.tenantId, membershipId);
|
||||
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
@@ -280,7 +280,7 @@ export async function leaveWorkspaceAction(): Promise<MemberActionState> {
|
||||
|
||||
await admin.teams.deleteMembership(ctx.tenantId, me.$id);
|
||||
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
@@ -334,7 +334,7 @@ export async function updateMemberRoleAction(
|
||||
const { teams } = createAdminClient();
|
||||
await teams.updateMembership(ctx.tenantId, membershipId, [role]);
|
||||
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
@@ -456,7 +456,7 @@ export async function acceptInviteAction(code: string): Promise<MemberActionStat
|
||||
acceptedBy: user.$id,
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: invite.tenantId,
|
||||
userId: user.$id,
|
||||
action: "create",
|
||||
|
||||
@@ -65,7 +65,7 @@ export async function updateWorkspaceSettingsAction(
|
||||
|
||||
if (row) {
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, row.$id, parsed.data);
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
@@ -86,7 +86,7 @@ export async function updateWorkspaceSettingsAction(
|
||||
Permission.delete(Role.team(ctx.tenantId, "owner")),
|
||||
],
|
||||
);
|
||||
await logAudit({
|
||||
void logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from "zod";
|
||||
|
||||
function toNumber(v: unknown): number {
|
||||
if (typeof v === "number") return v;
|
||||
const n = Number(String(v ?? "").replace(",", "."));
|
||||
return Number.isFinite(n) ? n : NaN;
|
||||
}
|
||||
|
||||
export const paymentSchema = z.object({
|
||||
counterpartTenantId: z.string().min(1, "Karşı taraf seçin."),
|
||||
amount: z
|
||||
.union([z.string(), z.number()])
|
||||
.transform(toNumber)
|
||||
.pipe(z.number().positive("Tutar 0'dan büyük olmalı.")),
|
||||
currency: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(8)
|
||||
.optional()
|
||||
.transform((v) => (v ? v.toUpperCase() : "TRY")),
|
||||
paymentDate: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((v) => (v ? new Date(v).toISOString() : new Date().toISOString())),
|
||||
method: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(30)
|
||||
.optional()
|
||||
.transform((v) => (v ? v : undefined)),
|
||||
notes: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(1000)
|
||||
.optional()
|
||||
.transform((v) => (v ? v : undefined)),
|
||||
});
|
||||
|
||||
export type PaymentInput = z.infer<typeof paymentSchema>;
|
||||
Reference in New Issue
Block a user