Compare commits

..

7 Commits

Author SHA1 Message Date
kovakmedya 0e4033aa3f feat(finance): clinic submits, lab confirms — payment approval flow
A payment recorded by the lab itself is self-evident (the lab knows it
got paid). One recorded by the clinic is just a claim until the lab
agrees. Added a status field to enforce that distinction so labs can
approve payments per-clinic instead of trusting whatever the clinic
typed in.

DB
  - payments.status enum (pending | confirmed | rejected, default
    confirmed). Existing rows keep the default and continue to be
    counted in balances.

Server
  - recordPaymentAction now stamps status='pending' when the caller is a
    clinic and 'confirmed' when the caller is a lab. A clinic submission
    pings the lab via createNotification so it surfaces on the
    notifications bell as well as on /finance.
  - confirmPaymentAction (lab only): flips a pending row to confirmed
    after verifying the lab is the counterpart. Notifies the clinic on
    success.
  - rejectPaymentAction (lab only): flips to rejected, notifies the
    clinic. Rejected rows stay visible for audit but never count toward
    the balance.

Queries
  - listIncomingPayments(tenantId) — payments where this tenant is the
    counterpart (the other side recorded them). Paired with listPayments
    we now see the same physical payment from either ledger.
  - computeBalancesByCounterpart upgraded to handle both shapes via an
    inflowFor() helper that normalises 'who got the money'. Only
    confirmed rows reduce the open balance.
  - filterPendingForConfirmation() returns the lab-side approval queue,
    sorted newest-first.

UI
  - /finance loads own + incoming payments, dedupes by $id, then feeds
    the merged list to balance/pending helpers.
  - New PendingPaymentsCard sits above the balances table on the lab
    side. Per-row: clinic name, amount, date, method, note, plus inline
    Onayla / Reddet buttons. Empty state hides the whole card.
  - Confirm + reject use the same router.refresh pattern as the rest of
    the action panels so the queue and the balances both update without
    a manual reload.
2026-05-22 01:47:10 +03:00
kovakmedya b1046e945a feat(finance): connection-based balances + lump-sum payment recording
Klinik-laboratuvar finance in TR is dönemsel, not job-by-job. Forcing
the lab to mark each finance_entry as paid was unrealistic — labs get a
single 50.000 TL transfer covering twelve jobs and don't want to walk a
list. Reworked the page around connection balances and free-amount
payments.

Data model
  - New 'payments' table:
      tenantId             whose ledger it lives in
      counterpartTenantId  the other side of the transaction
      direction            inflow (lab received) | outflow (clinic paid)
      amount, currency, paymentDate
      method               cash | bank | card | check | other
      notes, recordedBy
    Indexes: (tenantId, counterpartTenantId), paymentDate DESC. Permission:
    both teams can read, only owners/admins of the recording side can
    update or delete.

Server
  - recordPaymentAction: requires owner/admin, verifies an approved
    connection exists between (lab, clinic), then writes a single row.
    Direction is inferred from the caller's tenant kind so a lab can
    never accidentally book an outflow.
  - deletePaymentAction: same auth, tenant-scoped delete.
  - listPayments(tenantId) + computeBalancesByCounterpart({ kind,
    entries, payments }): one pass over both ledgers, returns
    [{ counterpartTenantId, invoiced, paid, open, lastPaymentAt }]
    sorted by open desc. invoiced pulls from finance_entries (receivable
    for lab, debt for clinic); paid pulls from the new payments table.

UI
  - /finance now leads with a Bakiye kartı: a row per connected
    counterpart showing invoiced, paid, last payment date and the open
    amount tinted green (lab alacak) or red (clinic borç), each with an
    inline 'Ödeme Al' / 'Ödeme Yap' button.
  - RecordPaymentDialog: amount (defaults to the open balance, lump
    sums obviously not pre-filled with a specific entry), date,
    currency, method (Nakit/Banka/Kart/Çek/Diğer), free-text note.
    Posts to recordPaymentAction, refresh on success.
  - Stat cards reworked: Toplam Açık Alacak/Borç and Tahsil Edilen /
    Ödenen replaced the old pending-totals so the headline numbers
    actually reflect the new flow.

Existing finance_entries (job-driven receivables/debts) remain the
single source of truth for 'how much was invoiced'; the new table tracks
'how much was actually collected'. Open balance = invoiced - paid, always
computed live — no individual entry needs to be marked 'paid' anymore.
2026-05-22 01:42:21 +03:00
kovakmedya 5dab958085 feat(jobs/new): two-step wizard — details, then files
Previously creating a job dumped the clinic on /jobs/outbound and left
them to navigate into the new job's detail page and upload scans there.
Splitting the form into a wizard keeps the whole 'publish a job' flow
on one page.

Step 1 — İş Bilgileri (existing form): lab, patient, product, teeth,
notes, due date. The submit button is now 'Devam Et — Dosyalar'. On
success createJobAction returns the new jobId, we stash it in state and
flip step → 'files' instead of router.push'ing away.

Step 2 — Dosyalar (new): a FilesStep component with a file picker that
queues each selection, kicks off a parallel XHR upload to the existing
/api/jobs/[jobId]/files endpoint, and shows per-row progress. Three
states per row: uploading (real byte progress), processing (server is
writing to Appwrite, indeterminate), done. Errors surface inline.

User exits via:
  - 'İlanı Tamamla' → /jobs/[id] (the new job detail page).
  - 'Şimdilik atla' → /jobs/outbound, as before. Disabled while any
    upload is still in flight so files don't get abandoned mid-stream.

The shared StepIndicator (1 İş Bilgileri → 2 Dosyalar) sits at the top
of both screens; a checkmark replaces the number once a step is done.
2026-05-22 01:36:56 +03:00
kovakmedya 479972e9a9 feat(workflow): split job step from location, model back-and-forth between lab and clinic
Real prosthetic production isn't a one-way pipeline — the work moves
between lab and clinic multiple times. After substructure is produced
the lab hands it to the clinic for a fitting, the clinic approves it
back to the lab, the lab builds the superstructure, hands it back for
a second fitting, the clinic approves again, the lab does cila/bitim,
and finally delivers it to the clinic for handover to the patient.

Previously we only had a single 'advance step' action callable by the
lab, which collapsed all of that into a linear forward push and didn't
capture who physically had the work at any given moment.

DB
  - New jobs.location enum (at_clinic | at_lab, default at_clinic).
  - Existing jobs keep working via a 'location ?? at_lab' fallback in
    code; no manual backfill required for the four test rows.

State machine
  - acceptJobAction (lab): pending → in_progress, currentStep=alt_yapi_prova,
    location=at_lab. Skips the implicit 'olcu' production step now that
    accepting the job means the lab has the impression in hand.
  - handToClinicAction (lab, NEW): at_lab → at_clinic, step stays the
    same. If step is cila_bitim, status becomes 'sent' (final delivery)
    and finance sync fires.
  - approveAtClinicAction (clinic, NEW): at_clinic → at_lab, step
    advances to the next stage so the lab knows what to produce next.
  - markDeliveredAction unchanged — clinic confirms the final handoff.
  - advanceStepAction removed; its single forward push doesn't fit the
    new bidirectional flow.

UI
  - JobActionsPanel now picks the right button from the role + status +
    location matrix:
      * Lab + pending          → 'İşleme Al'
      * Lab + in_progress + at_lab + cila_bitim → 'Cila Bitim — Nihai Teslime Gönder'
      * Lab + in_progress + at_lab + other      → '{stage} Provaya Gönder'
      * Clinic + in_progress + at_clinic → '{stage} Provası Tamam'
      * Clinic + sent          → 'Teslim Aldım'
      * Both + pending         → 'İptal Et'
  - Job detail surfaces a new 'Şu An' info row that resolves to a
    human-readable location ('Klinikte', 'Laboratuvarda', 'Hasta'ya
    teslim edildi', ...) so anyone glancing at the page can tell where
    the work physically is.
2026-05-22 01:31:49 +03:00
kovakmedya cdb2a15643 fix(ui): router.refresh after server actions so status updates show without reload
Lab side reported that after accepting a job / advancing a step the
button kept its 'Yükleniyor' state and the page didn't reflect the new
status until they hit refresh. Two issues stacked on top of each other:

1. The button forms were passing the action through an extra
   startTransition wrap — 'action={(fd) => startTransition(() => action(fd))}'.
   With React 19 + useActionState this is unnecessary; useActionState
   already manages its own transition. The double transition can leave
   the dispatch's pending flag wedged in some race orderings, which
   matches what the user saw.

2. revalidatePath() on the server invalidates the RSC cache but does not
   trigger client navigation. So even after the action returned, the
   page kept rendering the stale Job snapshot — and since the buttons
   are conditional on job.status, the now-stale 'pending' status meant
   the button stayed visible.

Fix in JobActionsPanel and the four sibling components (connections
delete row, pending inbound, pending outbound, file row delete):
  - Removed the startTransition wrap; forms point at 'action' directly.
  - Added useRouter() and call router.refresh() in the same useEffect
    branch where the success toast fires. This forces the Server
    Component tree to re-fetch, picks up the new job.status, and the
    actions panel rerenders into whatever button is next in the flow.
  - Cleaned the now-unused useTransition imports.

Net effect: tap 'İşleme Al' → spinner appears, ~400ms later the toast
hits and the row updates in place to 'Sonraki Aşama' without any
manual refresh.
2026-05-22 01:15:32 +03:00
kovakmedya 6fec52b98d feat(jobs): confirm-before-download dialog so users see what's happening
Files were grabbed via a plain anchor with the 'download' attribute, so
clicking the icon just spawned a silent browser download — nothing in
the UI moved, and users (especially labs receiving scans) couldn't tell
whether the click registered.

Wrapped the download button in a confirm dialog that mirrors the existing
delete flow: title 'Dosya indirilsin mi?', filename + size in the body,
Vazgeç / İndir buttons. The 'İndir' button programmatically clicks a
hidden anchor pointing at the /api/.../download proxy and surfaces a
'İndirme başladı.' toast with the filename so there's a clear visual ack
even before the OS download tray pops.
2026-05-22 01:08:10 +03:00
kovakmedya 12631cf9c5 perf+fix: file download proxy + drop awaits on audit/notifications/finance sync
Two problems reported by the user:

1. File downloads broken on the lab side.
   The link in JobFilesPanel pointed straight at Appwrite's
   /storage/.../view URL. Storage permissions are scoped to the job's two
   teams, but the browser only has a session cookie for our app domain,
   not for db.kovaksoft.com — so the cross-origin request hit Appwrite
   as a guest and 401'd.

   New /api/jobs/[jobId]/files/[fileId]/download route. requireTenant()
   first, then verify the caller's tenant is one of (clinicTenantId,
   labTenantId) on the parent job, then storage.getFileDownload via the
   admin SDK and stream the buffer back with Content-Disposition:
   attachment so the browser saves it under the original filename.
   listJobFiles now hands out that relative URL instead of the Appwrite
   one — same anchor in the panel, just routed through us.

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