Compare commits
7 Commits
97a6031992
...
0e4033aa3f
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e4033aa3f | |||
| b1046e945a | |||
| 5dab958085 | |||
| 479972e9a9 | |||
| cdb2a15643 | |||
| 6fec52b98d | |||
| 12631cf9c5 |
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
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 { Loader2, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -108,17 +109,18 @@ function ApprovedRow({
|
|||||||
deleteConnectionAction,
|
deleteConnectionAction,
|
||||||
initialConnectionActionState,
|
initialConnectionActionState,
|
||||||
);
|
);
|
||||||
const [, startTransition] = useTransition();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.ok) {
|
if (state.ok) {
|
||||||
toast.success("Bağlantı silindi.");
|
toast.success("Bağlantı silindi.");
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
router.refresh();
|
||||||
} else if (state.error) {
|
} else if (state.error) {
|
||||||
toast.error(state.error);
|
toast.error(state.error);
|
||||||
}
|
}
|
||||||
}, [state]);
|
}, [state, router]);
|
||||||
|
|
||||||
const kindLabel =
|
const kindLabel =
|
||||||
row.counterpart?.kind === "lab"
|
row.counterpart?.kind === "lab"
|
||||||
@@ -190,11 +192,7 @@ function ApprovedRow({
|
|||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button type="button" variant="outline">Vazgeç</Button>
|
<Button type="button" variant="outline">Vazgeç</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<form
|
<form action={action}>
|
||||||
action={(fd) => {
|
|
||||||
startTransition(() => action(fd));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="connectionId" value={row.$id} />
|
<input type="hidden" name="connectionId" value={row.$id} />
|
||||||
<Button type="submit" disabled={pending} variant="destructive">
|
<Button type="submit" disabled={pending} variant="destructive">
|
||||||
{pending ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
|
{pending ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"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 { Check, Loader2, X } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -64,17 +65,25 @@ function InboundRow({ row }: { row: ConnectionWithCounterpart }) {
|
|||||||
rejectConnectionAction,
|
rejectConnectionAction,
|
||||||
initialConnectionActionState,
|
initialConnectionActionState,
|
||||||
);
|
);
|
||||||
const [, startTransition] = useTransition();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (approveState.ok) toast.success("Bağlantı onaylandı.");
|
if (approveState.ok) {
|
||||||
else if (approveState.error) toast.error(approveState.error);
|
toast.success("Bağlantı onaylandı.");
|
||||||
}, [approveState]);
|
router.refresh();
|
||||||
|
} else if (approveState.error) {
|
||||||
|
toast.error(approveState.error);
|
||||||
|
}
|
||||||
|
}, [approveState, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rejectState.ok) toast.success("Talep reddedildi.");
|
if (rejectState.ok) {
|
||||||
else if (rejectState.error) toast.error(rejectState.error);
|
toast.success("Talep reddedildi.");
|
||||||
}, [rejectState]);
|
router.refresh();
|
||||||
|
} else if (rejectState.error) {
|
||||||
|
toast.error(rejectState.error);
|
||||||
|
}
|
||||||
|
}, [rejectState, router]);
|
||||||
|
|
||||||
const kindLabel =
|
const kindLabel =
|
||||||
row.counterpart?.kind === "lab"
|
row.counterpart?.kind === "lab"
|
||||||
@@ -94,22 +103,14 @@ function InboundRow({ row }: { row: ConnectionWithCounterpart }) {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<form
|
<form action={approveAction}>
|
||||||
action={(fd) => {
|
|
||||||
startTransition(() => approveAction(fd));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="connectionId" value={row.$id} />
|
<input type="hidden" name="connectionId" value={row.$id} />
|
||||||
<Button type="submit" size="sm" disabled={approvePending || rejectPending}>
|
<Button type="submit" size="sm" disabled={approvePending || rejectPending}>
|
||||||
{approvePending ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
|
{approvePending ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
|
||||||
Onayla
|
Onayla
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<form
|
<form action={rejectAction}>
|
||||||
action={(fd) => {
|
|
||||||
startTransition(() => rejectAction(fd));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="connectionId" value={row.$id} />
|
<input type="hidden" name="connectionId" value={row.$id} />
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"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 { Loader2, X } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -57,12 +58,16 @@ function OutboundRow({ row }: { row: ConnectionWithCounterpart }) {
|
|||||||
cancelConnectionAction,
|
cancelConnectionAction,
|
||||||
initialConnectionActionState,
|
initialConnectionActionState,
|
||||||
);
|
);
|
||||||
const [, startTransition] = useTransition();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.ok) toast.success("Talep iptal edildi.");
|
if (state.ok) {
|
||||||
else if (state.error) toast.error(state.error);
|
toast.success("Talep iptal edildi.");
|
||||||
}, [state]);
|
router.refresh();
|
||||||
|
} else if (state.error) {
|
||||||
|
toast.error(state.error);
|
||||||
|
}
|
||||||
|
}, [state, router]);
|
||||||
|
|
||||||
const kindLabel =
|
const kindLabel =
|
||||||
row.counterpart?.kind === "lab"
|
row.counterpart?.kind === "lab"
|
||||||
@@ -81,11 +86,7 @@ function OutboundRow({ row }: { row: ConnectionWithCounterpart }) {
|
|||||||
{dateFormatter.format(new Date(row.requestedAt))}
|
{dateFormatter.format(new Date(row.requestedAt))}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<form
|
<form action={action}>
|
||||||
action={(fd) => {
|
|
||||||
startTransition(() => action(fd));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="connectionId" value={row.$id} />
|
<input type="hidden" name="connectionId" value={row.$id} />
|
||||||
<Button type="submit" size="sm" variant="outline" disabled={pending}>
|
<Button type="submit" size="sm" variant="outline" disabled={pending}>
|
||||||
{pending ? <Loader2 className="size-4 animate-spin" /> : <X className="size-4" />}
|
{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 { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
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 { 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 { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||||
|
import { BalancesCard } from "./components/balances-card";
|
||||||
import { FinanceTable } from "./components/finance-table";
|
import { FinanceTable } from "./components/finance-table";
|
||||||
|
import { PendingPaymentsCard } from "./components/pending-payments-card";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "DLS — Finans",
|
title: "DLS — Finans",
|
||||||
@@ -24,26 +33,76 @@ export default async function FinancePage() {
|
|||||||
} catch {
|
} catch {
|
||||||
redirect("/onboarding");
|
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 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 (
|
return (
|
||||||
<div className="flex-1 space-y-6 px-6">
|
<div className="flex-1 space-y-6 px-6">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Finans</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Finans</h1>
|
||||||
<p className="text-muted-foreground text-sm">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
label={isLab ? "Bekleyen Alacak" : "Bekleyen Borç"}
|
label={isLab ? "Toplam Açık Alacak" : "Toplam Açık Borç"}
|
||||||
value={formatMoney(isLab ? stats.receivablePending : stats.payablePending, stats.currency)}
|
value={formatMoney(totalOpen, defaultCurrency)}
|
||||||
tone={isLab ? "positive" : "negative"}
|
tone={isLab ? "positive" : "negative"}
|
||||||
/>
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={isLab ? "Tahsil Edilen" : "Ödenen"}
|
||||||
|
value={formatMoney(totalPaid, defaultCurrency)}
|
||||||
|
tone="neutral"
|
||||||
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Bu Ay Gelir"
|
label="Bu Ay Gelir"
|
||||||
value={formatMoney(stats.incomeThisMonth, stats.currency)}
|
value={formatMoney(stats.incomeThisMonth, stats.currency)}
|
||||||
@@ -54,18 +113,22 @@ export default async function FinancePage() {
|
|||||||
value={formatMoney(stats.expenseThisMonth, stats.currency)}
|
value={formatMoney(stats.expenseThisMonth, stats.currency)}
|
||||||
tone="negative"
|
tone="negative"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
|
||||||
label="Toplam Kayıt"
|
|
||||||
value={String(entries.length)}
|
|
||||||
tone="neutral"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PendingPaymentsCard rows={pendingForApproval} counterpartNames={counterpartNames} />
|
||||||
|
|
||||||
|
<BalancesCard
|
||||||
|
balances={balances}
|
||||||
|
counterpartNames={counterpartNames}
|
||||||
|
selfKind={kind}
|
||||||
|
defaultCurrency={defaultCurrency}
|
||||||
|
/>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Hareketler</CardTitle>
|
<CardTitle>Hareketler</CardTitle>
|
||||||
<CardDescription>
|
<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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState, useEffect, useState, useTransition } from "react";
|
import { useActionState, useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Check,
|
Check,
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
Loader2,
|
Loader2,
|
||||||
Play,
|
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
|
Play,
|
||||||
|
Send,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -26,15 +28,12 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
acceptJobAction,
|
acceptJobAction,
|
||||||
advanceStepAction,
|
approveAtClinicAction,
|
||||||
cancelJobAction,
|
cancelJobAction,
|
||||||
|
handToClinicAction,
|
||||||
markDeliveredAction,
|
markDeliveredAction,
|
||||||
} from "@/lib/appwrite/job-actions";
|
} from "@/lib/appwrite/job-actions";
|
||||||
import {
|
import { initialJobActionState } from "@/lib/appwrite/job-types";
|
||||||
JOB_STEP_LABELS,
|
|
||||||
JOB_STEP_ORDER,
|
|
||||||
initialJobActionState,
|
|
||||||
} from "@/lib/appwrite/job-types";
|
|
||||||
import type { Job, TenantKind } from "@/lib/appwrite/schema";
|
import type { Job, TenantKind } from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
type Side = "clinic" | "lab";
|
type Side = "clinic" | "lab";
|
||||||
@@ -52,12 +51,29 @@ export function JobActionsPanel({
|
|||||||
|
|
||||||
const isLab = side === "lab";
|
const isLab = side === "lab";
|
||||||
const isClinic = side === "clinic";
|
const isClinic = side === "clinic";
|
||||||
|
const location = job.location ?? "at_lab";
|
||||||
|
const isAtLab = location === "at_lab";
|
||||||
|
const isAtClinic = location === "at_clinic";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{/* Pending pickup — lab accepts */}
|
||||||
{isLab && job.status === "pending" && <AcceptButton jobId={job.$id} />}
|
{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} />}
|
{isClinic && job.status === "sent" && <DeliverButton jobId={job.$id} />}
|
||||||
|
|
||||||
|
{/* Cancel — only while the job hasn't started yet */}
|
||||||
{(isClinic || isLab) && job.status === "pending" && (
|
{(isClinic || isLab) && job.status === "pending" && (
|
||||||
<CancelButton jobId={job.$id} />
|
<CancelButton jobId={job.$id} />
|
||||||
)}
|
)}
|
||||||
@@ -66,20 +82,20 @@ export function JobActionsPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AcceptButton({ jobId }: { jobId: string }) {
|
function AcceptButton({ jobId }: { jobId: string }) {
|
||||||
|
const router = useRouter();
|
||||||
const [state, action, pending] = useActionState(acceptJobAction, initialJobActionState);
|
const [state, action, pending] = useActionState(acceptJobAction, initialJobActionState);
|
||||||
const [, startTransition] = useTransition();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.ok) toast.success("İş işleme alındı.");
|
if (state.ok) {
|
||||||
else if (state.error) toast.error(state.error);
|
toast.success("İş işleme alındı, alt yapı üretimi başladı.");
|
||||||
}, [state]);
|
router.refresh();
|
||||||
|
} else if (state.error) {
|
||||||
|
toast.error(state.error);
|
||||||
|
}
|
||||||
|
}, [state, router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form action={action}>
|
||||||
action={(fd) => {
|
|
||||||
startTransition(() => action(fd));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="jobId" value={jobId} />
|
<input type="hidden" name="jobId" value={jobId} />
|
||||||
<Button type="submit" disabled={pending}>
|
<Button type="submit" disabled={pending}>
|
||||||
{pending ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
|
{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 }) {
|
function HandToClinicButton({ job }: { job: Job }) {
|
||||||
const [state, action, pending] = useActionState(advanceStepAction, initialJobActionState);
|
const router = useRouter();
|
||||||
const [, startTransition] = useTransition();
|
const [state, action, pending] = useActionState(handToClinicAction, initialJobActionState);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.ok) {
|
if (state.ok) {
|
||||||
toast.success("Aşama ilerletildi.");
|
toast.success("Klinik tarafına gönderildi.");
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
router.refresh();
|
||||||
} else if (state.error) {
|
} else if (state.error) {
|
||||||
toast.error(state.error);
|
toast.error(state.error);
|
||||||
}
|
}
|
||||||
}, [state]);
|
}, [state, router]);
|
||||||
|
|
||||||
const currentIdx = job.currentStep ? JOB_STEP_ORDER.indexOf(job.currentStep) : -1;
|
const isFinal = job.currentStep === "cila_bitim";
|
||||||
const isFinal = currentIdx === JOB_STEP_ORDER.length - 1;
|
const stageLabel =
|
||||||
const currentLabel = job.currentStep ? JOB_STEP_LABELS[job.currentStep] : "—";
|
job.currentStep === "alt_yapi_prova"
|
||||||
const nextLabel = isFinal
|
? "alt yapı"
|
||||||
? "Gönderildi olarak işaretle"
|
: job.currentStep === "ust_yapi_prova"
|
||||||
: JOB_STEP_LABELS[JOB_STEP_ORDER[currentIdx + 1]];
|
? "üst yapı"
|
||||||
|
: "cila/bitim";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<Button onClick={() => setOpen(true)}>
|
<Button onClick={() => setOpen(true)}>
|
||||||
{isFinal ? <PackageCheck className="size-4" /> : <ArrowRight className="size-4" />}
|
{isFinal ? <PackageCheck className="size-4" /> : <Send className="size-4" />}
|
||||||
{isFinal ? "Gönderildi" : "Sonraki Aşama"}
|
{isFinal ? "Cila Bitim — Nihai Teslime Gönder" : `${stageLabel === "alt yapı" ? "Alt Yapı" : "Üst Yapı"} Provaya Gönder`}
|
||||||
</Button>
|
</Button>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{isFinal ? "İşi gönderilmiş olarak işaretle" : `${currentLabel} tamamlandı`}
|
{isFinal ? "Nihai teslime gönderilsin mi?" : "Kliniğe gönderilsin mi?"}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{isFinal
|
{isFinal
|
||||||
? "İş artık 'Gönderildi' durumuna geçecek; klinik 'Teslim Aldım' onayını verecek."
|
? "Cila ve bitim tamamlandı; iş 'Gönderildi' durumuna geçer. Klinik teslim aldığında nihai onay verecek."
|
||||||
: `Sonraki aşama: ${nextLabel}. İsterseniz bir not ekleyebilirsiniz.`}
|
: `${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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form
|
<form action={action} className="grid gap-3">
|
||||||
action={(fd) => {
|
|
||||||
startTransition(() => action(fd));
|
|
||||||
}}
|
|
||||||
className="grid gap-3"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="jobId" value={job.$id} />
|
<input type="hidden" name="jobId" value={job.$id} />
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="note">Not (opsiyonel)</Label>
|
<Label htmlFor="note">Not (opsiyonel)</Label>
|
||||||
@@ -141,7 +154,7 @@ function AdvanceButton({ job }: { job: Job }) {
|
|||||||
name="note"
|
name="note"
|
||||||
rows={3}
|
rows={3}
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
placeholder="Örn. Renk kontrolü yapıldı, hasta provası onaylandı."
|
placeholder="Örn. Renk A2, oklüzal kontak tamam"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -151,8 +164,70 @@ function AdvanceButton({ job }: { job: Job }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<Button type="submit" disabled={pending}>
|
<Button type="submit" disabled={pending}>
|
||||||
{pending ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
|
{pending ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
|
||||||
Onayla
|
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>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
@@ -162,20 +237,20 @@ function AdvanceButton({ job }: { job: Job }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DeliverButton({ jobId }: { jobId: string }) {
|
function DeliverButton({ jobId }: { jobId: string }) {
|
||||||
|
const router = useRouter();
|
||||||
const [state, action, pending] = useActionState(markDeliveredAction, initialJobActionState);
|
const [state, action, pending] = useActionState(markDeliveredAction, initialJobActionState);
|
||||||
const [, startTransition] = useTransition();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.ok) toast.success("İş teslim alındı.");
|
if (state.ok) {
|
||||||
else if (state.error) toast.error(state.error);
|
toast.success("İş teslim alındı.");
|
||||||
}, [state]);
|
router.refresh();
|
||||||
|
} else if (state.error) {
|
||||||
|
toast.error(state.error);
|
||||||
|
}
|
||||||
|
}, [state, router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form action={action}>
|
||||||
action={(fd) => {
|
|
||||||
startTransition(() => action(fd));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="jobId" value={jobId} />
|
<input type="hidden" name="jobId" value={jobId} />
|
||||||
<Button type="submit" disabled={pending}>
|
<Button type="submit" disabled={pending}>
|
||||||
{pending ? <Loader2 className="size-4 animate-spin" /> : <PackageCheck className="size-4" />}
|
{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 }) {
|
function CancelButton({ jobId }: { jobId: string }) {
|
||||||
|
const router = useRouter();
|
||||||
const [state, action, pending] = useActionState(cancelJobAction, initialJobActionState);
|
const [state, action, pending] = useActionState(cancelJobAction, initialJobActionState);
|
||||||
const [, startTransition] = useTransition();
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.ok) {
|
if (state.ok) {
|
||||||
toast.success("İş iptal edildi.");
|
toast.success("İş iptal edildi.");
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
router.refresh();
|
||||||
} else if (state.error) {
|
} else if (state.error) {
|
||||||
toast.error(state.error);
|
toast.error(state.error);
|
||||||
}
|
}
|
||||||
}, [state]);
|
}, [state, router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
@@ -215,11 +291,7 @@ function CancelButton({ jobId }: { jobId: string }) {
|
|||||||
</span>
|
</span>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form
|
<form action={action}>
|
||||||
action={(fd) => {
|
|
||||||
startTransition(() => action(fd));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="jobId" value={jobId} />
|
<input type="hidden" name="jobId" value={jobId} />
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState, useEffect, useRef, useState, useTransition } from "react";
|
import { useActionState, useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Download, FileText, ImageIcon, Layers, Loader2, Trash2, Upload } from "lucide-react";
|
import { Download, FileText, ImageIcon, Layers, Loader2, Trash2, Upload } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -240,17 +240,33 @@ function FileRow({ file }: { file: JobFileWithUrl }) {
|
|||||||
deleteJobFileAction,
|
deleteJobFileAction,
|
||||||
initialJobFileActionState,
|
initialJobFileActionState,
|
||||||
);
|
);
|
||||||
const [, startTransition] = useTransition();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [downloadOpen, setDownloadOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.ok) {
|
if (state.ok) {
|
||||||
toast.success("Dosya silindi.");
|
toast.success("Dosya silindi.");
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
router.refresh();
|
||||||
} else if (state.error) {
|
} else if (state.error) {
|
||||||
toast.error(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 (
|
return (
|
||||||
<li className="flex items-center gap-3 px-3 py-2">
|
<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">
|
<Badge variant="outline" className="hidden sm:inline-flex">
|
||||||
{JOB_FILE_KIND_LABELS[file.kind] ?? file.kind}
|
{JOB_FILE_KIND_LABELS[file.kind] ?? file.kind}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button asChild size="sm" variant="outline">
|
<Dialog open={downloadOpen} onOpenChange={setDownloadOpen}>
|
||||||
<a href={file.url} target="_blank" rel="noopener noreferrer" download={file.name}>
|
<Button size="sm" variant="outline" onClick={() => setDownloadOpen(true)}>
|
||||||
<Download className="size-4" />
|
<Download className="size-4" />
|
||||||
</a>
|
</Button>
|
||||||
</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}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
|
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
@@ -284,11 +320,7 @@ function FileRow({ file }: { file: JobFileWithUrl }) {
|
|||||||
Vazgeç
|
Vazgeç
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<form
|
<form action={action}>
|
||||||
action={(fd) => {
|
|
||||||
startTransition(() => action(fd));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="rowId" value={file.$id} />
|
<input type="hidden" name="rowId" value={file.$id} />
|
||||||
<Button type="submit" variant="destructive" disabled={pending}>
|
<Button type="submit" variant="destructive" disabled={pending}>
|
||||||
{pending ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
|
{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 { getPatient } from "@/lib/appwrite/patient-queries";
|
||||||
import { toPlain } from "@/lib/appwrite/serialize";
|
import { toPlain } from "@/lib/appwrite/serialize";
|
||||||
import {
|
import {
|
||||||
|
JOB_LOCATION_LABELS,
|
||||||
JOB_STATUS_LABELS,
|
JOB_STATUS_LABELS,
|
||||||
JOB_STEP_LABELS,
|
JOB_STEP_LABELS,
|
||||||
JOB_STEP_ORDER,
|
JOB_STEP_ORDER,
|
||||||
@@ -141,6 +142,15 @@ export default async function JobDetailPage({
|
|||||||
<Info label="Mevcut Aşama">
|
<Info label="Mevcut Aşama">
|
||||||
{job.currentStep ? JOB_STEP_LABELS[job.currentStep] : "—"}
|
{job.currentStep ? JOB_STEP_LABELS[job.currentStep] : "—"}
|
||||||
</Info>
|
</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">
|
<div className="md:col-span-2">
|
||||||
<p className="text-muted-foreground mb-1 text-xs font-medium uppercase tracking-wide">
|
<p className="text-muted-foreground mb-1 text-xs font-medium uppercase tracking-wide">
|
||||||
Dişler ({job.teeth?.length ?? job.memberCount})
|
Dişler ({job.teeth?.length ?? job.memberCount})
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState, useEffect, useMemo, useState } from "react";
|
import { useActionState, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
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 { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -78,6 +80,10 @@ export function NewJobForm({
|
|||||||
const [prostheticId, setProstheticId] = useState<string>("");
|
const [prostheticId, setProstheticId] = useState<string>("");
|
||||||
const [quote, setQuote] = useState<Quote | null>(null);
|
const [quote, setQuote] = useState<Quote | null>(null);
|
||||||
const [quoteLoading, setQuoteLoading] = useState(false);
|
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 labProsthetics = prostheticsByLab[labTenantId] ?? [];
|
||||||
const selectedProsthetic = labProsthetics.find((p) => p.id === prostheticId);
|
const selectedProsthetic = labProsthetics.find((p) => p.id === prostheticId);
|
||||||
@@ -89,13 +95,14 @@ export function NewJobForm({
|
|||||||
const selectedPatient = patientId !== NONE_PATIENT ? patientById.get(patientId) : undefined;
|
const selectedPatient = patientId !== NONE_PATIENT ? patientById.get(patientId) : undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.ok) {
|
if (state.ok && state.jobId) {
|
||||||
toast.success("İş yayınlandı.");
|
toast.success("İş kaydedildi. Dosyaları ekleyebilirsiniz.");
|
||||||
router.push("/jobs/outbound");
|
setCreatedJobId(state.jobId);
|
||||||
|
setStep("files");
|
||||||
} else if (state.error) {
|
} else if (state.error) {
|
||||||
toast.error(state.error);
|
toast.error(state.error);
|
||||||
}
|
}
|
||||||
}, [state, router]);
|
}, [state]);
|
||||||
|
|
||||||
// Reset prosthetic selection when the lab changes so we never carry the
|
// Reset prosthetic selection when the lab changes so we never carry the
|
||||||
// previous lab's catalog ID over.
|
// previous lab's catalog ID over.
|
||||||
@@ -134,8 +141,19 @@ export function NewJobForm({
|
|||||||
};
|
};
|
||||||
}, [prostheticId, teeth.length]);
|
}, [prostheticId, teeth.length]);
|
||||||
|
|
||||||
|
if (step === "files" && createdJobId) {
|
||||||
|
return (
|
||||||
|
<FilesStep
|
||||||
|
jobId={createdJobId}
|
||||||
|
onDone={() => router.push(`/jobs/${createdJobId}`)}
|
||||||
|
onSkip={() => router.push("/jobs/outbound")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={action} className="grid gap-5">
|
<form action={action} className="grid gap-5">
|
||||||
|
<StepIndicator step="details" />
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<div className="grid gap-2 md:col-span-2">
|
<div className="grid gap-2 md:col-span-2">
|
||||||
<Label htmlFor="labTenantId">Laboratuvar *</Label>
|
<Label htmlFor="labTenantId">Laboratuvar *</Label>
|
||||||
@@ -298,12 +316,12 @@ export function NewJobForm({
|
|||||||
{pending ? (
|
{pending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
Gönderiliyor...
|
Kaydediliyor...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Send className="size-4" />
|
<ArrowRight className="size-4" />
|
||||||
İşi Yayınla
|
Devam Et — Dosyalar
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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({
|
function PriceQuoteCard({
|
||||||
quote,
|
quote,
|
||||||
loading,
|
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);
|
createdRowIds.push(row.$id);
|
||||||
}
|
}
|
||||||
|
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: tenantCtx.tenantId,
|
tenantId: tenantCtx.tenantId,
|
||||||
userId: tenantCtx.user.id,
|
userId: tenantCtx.user.id,
|
||||||
action: "create",
|
action: "create",
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export async function requestConnectionAction(
|
|||||||
approvedAt: null,
|
approvedAt: null,
|
||||||
rejectedAt: null,
|
rejectedAt: null,
|
||||||
});
|
});
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "update",
|
action: "update",
|
||||||
@@ -132,7 +132,7 @@ export async function requestConnectionAction(
|
|||||||
},
|
},
|
||||||
connectionPermissions(clinicTenantId, labTenantId),
|
connectionPermissions(clinicTenantId, labTenantId),
|
||||||
);
|
);
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "create",
|
action: "create",
|
||||||
@@ -141,7 +141,7 @@ export async function requestConnectionAction(
|
|||||||
changes: { clinicTenantId, labTenantId, status: "pending" },
|
changes: { clinicTenantId, labTenantId, status: "pending" },
|
||||||
});
|
});
|
||||||
const counterpartId = counterpart.tenantId;
|
const counterpartId = counterpart.tenantId;
|
||||||
await createNotification({
|
void createNotification({
|
||||||
tenantId: counterpartId,
|
tenantId: counterpartId,
|
||||||
connectionId: created.$id,
|
connectionId: created.$id,
|
||||||
message: `${ctx.settings?.companyName ?? "Bir hesap"} bağlantı talebi gönderdi.`,
|
message: `${ctx.settings?.companyName ?? "Bir hesap"} bağlantı talebi gönderdi.`,
|
||||||
@@ -217,7 +217,7 @@ export async function approveConnectionAction(
|
|||||||
approvedAt: new Date().toISOString(),
|
approvedAt: new Date().toISOString(),
|
||||||
rejectedAt: null,
|
rejectedAt: null,
|
||||||
});
|
});
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "update",
|
action: "update",
|
||||||
@@ -227,7 +227,7 @@ export async function approveConnectionAction(
|
|||||||
});
|
});
|
||||||
const requesterTenant =
|
const requesterTenant =
|
||||||
conn.clinicTenantId === ctx.tenantId ? conn.labTenantId : conn.clinicTenantId;
|
conn.clinicTenantId === ctx.tenantId ? conn.labTenantId : conn.clinicTenantId;
|
||||||
await createNotification({
|
void createNotification({
|
||||||
tenantId: requesterTenant,
|
tenantId: requesterTenant,
|
||||||
connectionId,
|
connectionId,
|
||||||
message: `${ctx.settings?.companyName ?? "Karşı taraf"} bağlantı talebinizi onayladı.`,
|
message: `${ctx.settings?.companyName ?? "Karşı taraf"} bağlantı talebinizi onayladı.`,
|
||||||
@@ -270,7 +270,7 @@ export async function rejectConnectionAction(
|
|||||||
status: "rejected",
|
status: "rejected",
|
||||||
rejectedAt: new Date().toISOString(),
|
rejectedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "update",
|
action: "update",
|
||||||
@@ -313,7 +313,7 @@ export async function cancelConnectionAction(
|
|||||||
try {
|
try {
|
||||||
const { tablesDB } = createAdminClient();
|
const { tablesDB } = createAdminClient();
|
||||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.connections, connectionId);
|
await tablesDB.deleteRow(DATABASE_ID, TABLES.connections, connectionId);
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "delete",
|
action: "delete",
|
||||||
@@ -350,7 +350,7 @@ export async function deleteConnectionAction(
|
|||||||
try {
|
try {
|
||||||
const { tablesDB } = createAdminClient();
|
const { tablesDB } = createAdminClient();
|
||||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.connections, connectionId);
|
await tablesDB.deleteRow(DATABASE_ID, TABLES.connections, connectionId);
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "delete",
|
action: "delete",
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export async function markFinancePaidAction(
|
|||||||
await tablesDB.updateRow(DATABASE_ID, TABLES.financeEntries, id, {
|
await tablesDB.updateRow(DATABASE_ID, TABLES.financeEntries, id, {
|
||||||
status: "paid",
|
status: "paid",
|
||||||
});
|
});
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "update",
|
action: "update",
|
||||||
@@ -95,7 +95,7 @@ export async function reopenFinanceAction(
|
|||||||
await tablesDB.updateRow(DATABASE_ID, TABLES.financeEntries, id, {
|
await tablesDB.updateRow(DATABASE_ID, TABLES.financeEntries, id, {
|
||||||
status: "pending",
|
status: "pending",
|
||||||
});
|
});
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "update",
|
action: "update",
|
||||||
|
|||||||
+157
-52
@@ -201,7 +201,7 @@ export async function createJobAction(
|
|||||||
},
|
},
|
||||||
jobPermissions(ctx.tenantId, parsed.data.labTenantId),
|
jobPermissions(ctx.tenantId, parsed.data.labTenantId),
|
||||||
);
|
);
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "create",
|
action: "create",
|
||||||
@@ -209,7 +209,7 @@ export async function createJobAction(
|
|||||||
entityId: created.$id,
|
entityId: created.$id,
|
||||||
changes: { labTenantId: parsed.data.labTenantId, patientCode },
|
changes: { labTenantId: parsed.data.labTenantId, patientCode },
|
||||||
});
|
});
|
||||||
await createNotification({
|
void createNotification({
|
||||||
tenantId: parsed.data.labTenantId,
|
tenantId: parsed.data.labTenantId,
|
||||||
jobId: created.$id,
|
jobId: created.$id,
|
||||||
message: `${ctx.settings?.companyName ?? "Bir klinik"} yeni iş yayınladı (${patientCode}).`,
|
message: `${ctx.settings?.companyName ?? "Bir klinik"} yeni iş yayınladı (${patientCode}).`,
|
||||||
@@ -297,23 +297,30 @@ export async function acceptJobAction(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { tablesDB } = createAdminClient();
|
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, {
|
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||||||
status: "in_progress",
|
status: "in_progress",
|
||||||
currentStep: "olcu",
|
currentStep: "alt_yapi_prova",
|
||||||
|
location: "at_lab",
|
||||||
});
|
});
|
||||||
await appendJobHistory({ job, step: "olcu", completedBy: ctx.user.id });
|
await appendJobHistory({ job, step: "olcu", completedBy: ctx.user.id });
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "update",
|
action: "update",
|
||||||
entityType: "job",
|
entityType: "job",
|
||||||
entityId: jobId,
|
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,
|
tenantId: job.clinicTenantId,
|
||||||
jobId,
|
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) {
|
} catch (e) {
|
||||||
return { ok: false, error: appwriteError(e, "Kabul edilemedi.") };
|
return { ok: false, error: appwriteError(e, "Kabul edilemedi.") };
|
||||||
@@ -325,7 +332,14 @@ export async function acceptJobAction(
|
|||||||
return { ok: true };
|
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,
|
_prev: JobActionState,
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
): Promise<JobActionState> {
|
): Promise<JobActionState> {
|
||||||
@@ -339,7 +353,7 @@ export async function advanceStepAction(
|
|||||||
requireRole(ctx, ["owner", "admin", "member"]);
|
requireRole(ctx, ["owner", "admin", "member"]);
|
||||||
requireTenantKind(ctx, ["lab"]);
|
requireTenantKind(ctx, ["lab"]);
|
||||||
} catch {
|
} 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);
|
const job = await loadJobForTenant(jobId, ctx.tenantId);
|
||||||
@@ -347,66 +361,74 @@ export async function advanceStepAction(
|
|||||||
return { ok: false, error: "İş bulunamadı." };
|
return { ok: false, error: "İş bulunamadı." };
|
||||||
}
|
}
|
||||||
if (job.status !== "in_progress") {
|
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 isFinalStep = job.currentStep === "cila_bitim";
|
||||||
const isFinalStepComplete = currentIdx === JOB_STEP_ORDER.length - 1;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { tablesDB } = createAdminClient();
|
const { tablesDB } = createAdminClient();
|
||||||
if (isFinalStepComplete) {
|
if (isFinalStep) {
|
||||||
|
// Final delivery — production is done, status moves to sent.
|
||||||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||||||
status: "sent",
|
status: "sent",
|
||||||
});
|
location: "at_clinic",
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
await appendJobHistory({
|
await appendJobHistory({
|
||||||
job,
|
job,
|
||||||
step: job.currentStep!,
|
step: "cila_bitim",
|
||||||
completedBy: ctx.user.id,
|
completedBy: ctx.user.id,
|
||||||
note,
|
note,
|
||||||
});
|
});
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "update",
|
action: "update",
|
||||||
entityType: "job",
|
entityType: "job",
|
||||||
entityId: jobId,
|
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) {
|
} catch (e) {
|
||||||
return { ok: false, error: appwriteError(e, "İlerletilemedi.") };
|
return { ok: false, error: appwriteError(e, "Gönderilemedi.") };
|
||||||
}
|
|
||||||
|
|
||||||
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.`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath(`/jobs/${jobId}`);
|
revalidatePath(`/jobs/${jobId}`);
|
||||||
@@ -416,6 +438,89 @@ export async function advanceStepAction(
|
|||||||
return { ok: true };
|
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(
|
export async function markDeliveredAction(
|
||||||
_prev: JobActionState,
|
_prev: JobActionState,
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
@@ -445,7 +550,7 @@ export async function markDeliveredAction(
|
|||||||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||||||
status: "delivered",
|
status: "delivered",
|
||||||
});
|
});
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "update",
|
action: "update",
|
||||||
@@ -453,8 +558,8 @@ export async function markDeliveredAction(
|
|||||||
entityId: jobId,
|
entityId: jobId,
|
||||||
changes: { status: "delivered" },
|
changes: { status: "delivered" },
|
||||||
});
|
});
|
||||||
await syncFinanceForJob({ ...job, status: "delivered" });
|
void syncFinanceForJob({ ...job, status: "delivered" });
|
||||||
await createNotification({
|
void createNotification({
|
||||||
tenantId: job.labTenantId,
|
tenantId: job.labTenantId,
|
||||||
jobId,
|
jobId,
|
||||||
message: `Hasta ${job.patientCode} işi teslim alındı.`,
|
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, {
|
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||||||
status: "cancelled",
|
status: "cancelled",
|
||||||
});
|
});
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "update",
|
action: "update",
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export async function uploadJobFilesAction(
|
|||||||
createdRowIds.push(row.$id);
|
createdRowIds.push(row.$id);
|
||||||
}
|
}
|
||||||
|
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "create",
|
action: "create",
|
||||||
@@ -206,7 +206,7 @@ export async function deleteJobFileAction(
|
|||||||
// File may already be gone; row is the source of truth.
|
// File may already be gone; row is the source of truth.
|
||||||
}
|
}
|
||||||
|
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "delete",
|
action: "delete",
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import "server-only";
|
|||||||
|
|
||||||
import { Query } from "node-appwrite";
|
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 { createAdminClient } from "./server";
|
||||||
import { toPlain } from "./serialize";
|
import { toPlain } from "./serialize";
|
||||||
import { getFileViewUrl } from "./storage";
|
|
||||||
|
|
||||||
export type JobFileWithUrl = JobFile & {
|
export type JobFileWithUrl = JobFile & {
|
||||||
|
/** Server-side download proxy. Browser → our app → admin SDK → bucket. */
|
||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export async function listJobFiles(jobId: string): Promise<JobFileWithUrl[]> {
|
|||||||
return toPlain(
|
return toPlain(
|
||||||
rows.map((r) => ({
|
rows.map((r) => ({
|
||||||
...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 = {
|
export type JobFormState = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -38,6 +38,11 @@ export const JOB_STEP_ORDER: JobStep[] = [
|
|||||||
"cila_bitim",
|
"cila_bitim",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const JOB_LOCATION_LABELS: Record<JobLocation, string> = {
|
||||||
|
at_clinic: "Klinikte",
|
||||||
|
at_lab: "Laboratuvarda",
|
||||||
|
};
|
||||||
|
|
||||||
export const PROSTHETIC_TYPE_LABELS: Record<ProstheticType, string> = {
|
export const PROSTHETIC_TYPE_LABELS: Record<ProstheticType, string> = {
|
||||||
metal_porselen: "Metal Porselen",
|
metal_porselen: "Metal Porselen",
|
||||||
zirkonyum: "Zirkonyum",
|
zirkonyum: "Zirkonyum",
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export async function uploadLogoAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "update",
|
action: "update",
|
||||||
@@ -151,7 +151,7 @@ export async function removeLogoAction(): Promise<LogoActionState> {
|
|||||||
/* file already gone, fine */
|
/* file already gone, fine */
|
||||||
}
|
}
|
||||||
|
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "delete",
|
action: "delete",
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export async function createPatientAction(
|
|||||||
},
|
},
|
||||||
patientPermissions(ctx.tenantId),
|
patientPermissions(ctx.tenantId),
|
||||||
);
|
);
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "create",
|
action: "create",
|
||||||
@@ -191,7 +191,7 @@ export async function updatePatientAction(
|
|||||||
lastName: parsed.data.lastName,
|
lastName: parsed.data.lastName,
|
||||||
notes: parsed.data.notes,
|
notes: parsed.data.notes,
|
||||||
});
|
});
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "update",
|
action: "update",
|
||||||
@@ -236,7 +236,7 @@ export async function archivePatientAction(
|
|||||||
await tablesDB.updateRow(DATABASE_ID, TABLES.patients, id, {
|
await tablesDB.updateRow(DATABASE_ID, TABLES.patients, id, {
|
||||||
archived: !row.archived,
|
archived: !row.archived,
|
||||||
});
|
});
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "update",
|
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 session = await createSessionClient();
|
||||||
const user = await session.account.get();
|
const user = await session.account.get();
|
||||||
const tenantId = (await getActiveTenantId()) ?? "global";
|
const tenantId = (await getActiveTenantId()) ?? "global";
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId,
|
tenantId,
|
||||||
userId: user.$id,
|
userId: user.$id,
|
||||||
action,
|
action,
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export async function createProstheticAction(
|
|||||||
},
|
},
|
||||||
prostheticPermissions(ctx.tenantId),
|
prostheticPermissions(ctx.tenantId),
|
||||||
);
|
);
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "create",
|
action: "create",
|
||||||
@@ -134,7 +134,7 @@ export async function updateProstheticAction(
|
|||||||
unitPrice: parsed.data.unitPrice,
|
unitPrice: parsed.data.unitPrice,
|
||||||
currency: parsed.data.currency,
|
currency: parsed.data.currency,
|
||||||
});
|
});
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "update",
|
action: "update",
|
||||||
@@ -179,7 +179,7 @@ export async function archiveProstheticAction(
|
|||||||
await tablesDB.updateRow(DATABASE_ID, TABLES.prosthetics, id, {
|
await tablesDB.updateRow(DATABASE_ID, TABLES.prosthetics, id, {
|
||||||
archived: !row.archived,
|
archived: !row.archived,
|
||||||
});
|
});
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "update",
|
action: "update",
|
||||||
@@ -222,7 +222,7 @@ export async function deleteProstheticAction(
|
|||||||
return { ok: false, error: "Bu ürünü silme yetkiniz yok." };
|
return { ok: false, error: "Bu ürünü silme yetkiniz yok." };
|
||||||
}
|
}
|
||||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.prosthetics, id);
|
await tablesDB.deleteRow(DATABASE_ID, TABLES.prosthetics, id);
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "delete",
|
action: "delete",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const TABLES = {
|
|||||||
connections: "connections",
|
connections: "connections",
|
||||||
patients: "patients",
|
patients: "patients",
|
||||||
clinicPricing: "clinic_pricing",
|
clinicPricing: "clinic_pricing",
|
||||||
|
payments: "payments",
|
||||||
jobs: "jobs",
|
jobs: "jobs",
|
||||||
jobFiles: "job_files",
|
jobFiles: "job_files",
|
||||||
jobStatusHistory: "job_status_history",
|
jobStatusHistory: "job_status_history",
|
||||||
@@ -75,6 +76,7 @@ export interface Connection extends Row {
|
|||||||
|
|
||||||
export type JobStatus = "pending" | "in_progress" | "sent" | "delivered" | "cancelled";
|
export type JobStatus = "pending" | "in_progress" | "sent" | "delivered" | "cancelled";
|
||||||
export type JobStep = "olcu" | "alt_yapi_prova" | "ust_yapi_prova" | "cila_bitim";
|
export type JobStep = "olcu" | "alt_yapi_prova" | "ust_yapi_prova" | "cila_bitim";
|
||||||
|
export type JobLocation = "at_clinic" | "at_lab";
|
||||||
export type ProstheticType =
|
export type ProstheticType =
|
||||||
| "metal_porselen"
|
| "metal_porselen"
|
||||||
| "zirkonyum"
|
| "zirkonyum"
|
||||||
@@ -109,6 +111,7 @@ export interface Job extends Row {
|
|||||||
currency?: string;
|
currency?: string;
|
||||||
status: JobStatus;
|
status: JobStatus;
|
||||||
currentStep?: JobStep;
|
currentStep?: JobStep;
|
||||||
|
location?: JobLocation;
|
||||||
dueDate?: string;
|
dueDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +175,22 @@ export interface FinanceEntry extends Row {
|
|||||||
description?: string;
|
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 {
|
export interface Notification extends Row {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export async function inviteMemberAction(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "create",
|
action: "create",
|
||||||
@@ -182,7 +182,7 @@ export async function cancelInviteAction(
|
|||||||
status: "cancelled",
|
status: "cancelled",
|
||||||
});
|
});
|
||||||
|
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "update",
|
action: "update",
|
||||||
@@ -228,7 +228,7 @@ export async function removeMemberAction(
|
|||||||
|
|
||||||
await teams.deleteMembership(ctx.tenantId, membershipId);
|
await teams.deleteMembership(ctx.tenantId, membershipId);
|
||||||
|
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "delete",
|
action: "delete",
|
||||||
@@ -280,7 +280,7 @@ export async function leaveWorkspaceAction(): Promise<MemberActionState> {
|
|||||||
|
|
||||||
await admin.teams.deleteMembership(ctx.tenantId, me.$id);
|
await admin.teams.deleteMembership(ctx.tenantId, me.$id);
|
||||||
|
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "delete",
|
action: "delete",
|
||||||
@@ -334,7 +334,7 @@ export async function updateMemberRoleAction(
|
|||||||
const { teams } = createAdminClient();
|
const { teams } = createAdminClient();
|
||||||
await teams.updateMembership(ctx.tenantId, membershipId, [role]);
|
await teams.updateMembership(ctx.tenantId, membershipId, [role]);
|
||||||
|
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "update",
|
action: "update",
|
||||||
@@ -456,7 +456,7 @@ export async function acceptInviteAction(code: string): Promise<MemberActionStat
|
|||||||
acceptedBy: user.$id,
|
acceptedBy: user.$id,
|
||||||
});
|
});
|
||||||
|
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: invite.tenantId,
|
tenantId: invite.tenantId,
|
||||||
userId: user.$id,
|
userId: user.$id,
|
||||||
action: "create",
|
action: "create",
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export async function updateWorkspaceSettingsAction(
|
|||||||
|
|
||||||
if (row) {
|
if (row) {
|
||||||
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, row.$id, parsed.data);
|
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, row.$id, parsed.data);
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "update",
|
action: "update",
|
||||||
@@ -86,7 +86,7 @@ export async function updateWorkspaceSettingsAction(
|
|||||||
Permission.delete(Role.team(ctx.tenantId, "owner")),
|
Permission.delete(Role.team(ctx.tenantId, "owner")),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
await logAudit({
|
void logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: "create",
|
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