feat: job status/step flow, file upload, finance sync, notifications
Job lifecycle
- acceptJobAction (lab): pending → in_progress + currentStep=olcu
- advanceStepAction (lab): step ilerletir, son adım sonrası status=sent
- markDeliveredAction (clinic): sent → delivered
- cancelJobAction: pending iş iptali (her iki taraf)
- job_status_history her step transition'da idempotent kayıt
- Detay sayfası interactive panel + Aşama Geçmişi kartı
Job files (Appwrite Storage job-files bucket, 30MB/file)
- uploadJobFilesAction: çoklu dosya, mimeType'tan kind sınıflandırma
(scan/image/document), her iki team'e read permission, partial-fail
rollback (storage + row temizliği)
- deleteJobFileAction: yetkilendirilmiş silme, file + row birlikte
- JobFilesPanel: client-side select + upload + liste + indir + sil
- next.config bodySizeLimit 3mb → 100mb (toplu yükleme için)
Finance sync (idempotent)
- syncFinanceForJob helper: sent/delivered transition'larında klinik
payable + lab receivable rows (jobId+tenantId+type unique kontrolü,
her tarafta tek satır garanti)
- markFinancePaidAction / reopenFinanceAction: manuel ödendi/geri al
- /finance sayfası: stat kartlar (bekleyen alacak/borç, aylık gelir/gider)
+ hareketler tablosu, role-aware kopyalar
- Memory rule [[feedback_cross_entity_sync_helpers]]: best-effort, never
re-throws
Notifications
- createNotification helper, connection (request/approve) ve job
(create/accept/sent/delivered) eventlerinde tetikleniyor
- /notifications sayfası + tek tek / hepsi okundu işaretle
- Header'a Bell ikonu + okunmamış count badge (layout SSR'de besler)
- Middleware PROTECTED_PREFIXES'e /notifications ekli
This commit is contained in:
@@ -31,11 +31,13 @@ export function DashboardShell({
|
||||
company,
|
||||
children,
|
||||
initialPrefs,
|
||||
unreadCount = 0,
|
||||
}: {
|
||||
user: ShellUser;
|
||||
company: ShellCompany;
|
||||
children: React.ReactNode;
|
||||
initialPrefs: ThemePrefs;
|
||||
unreadCount?: number;
|
||||
}) {
|
||||
const [themeCustomizerOpen, setThemeCustomizerOpen] = React.useState(false);
|
||||
const { config } = useSidebarConfig();
|
||||
@@ -63,7 +65,7 @@ export function DashboardShell({
|
||||
side={config.side}
|
||||
/>
|
||||
<SidebarInset>
|
||||
<SiteHeader company={company} />
|
||||
<SiteHeader company={company} unreadCount={unreadCount} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">{children}</div>
|
||||
@@ -75,7 +77,7 @@ export function DashboardShell({
|
||||
) : (
|
||||
<>
|
||||
<SidebarInset>
|
||||
<SiteHeader company={company} />
|
||||
<SiteHeader company={company} unreadCount={unreadCount} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">{children}</div>
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useActionState, useEffect, useTransition } from "react";
|
||||
import { CheckCircle2, Loader2, RotateCcw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
markFinancePaidAction,
|
||||
reopenFinanceAction,
|
||||
} from "@/lib/appwrite/finance-actions";
|
||||
import {
|
||||
FINANCE_STATUS_LABELS,
|
||||
FINANCE_TYPE_LABELS,
|
||||
initialFinanceActionState,
|
||||
} from "@/lib/appwrite/finance-types";
|
||||
import type { FinanceEntryWithCounterpart } from "@/lib/appwrite/finance-queries";
|
||||
import type { FinanceStatus, FinanceType } 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}`;
|
||||
}
|
||||
}
|
||||
|
||||
function typeVariant(t: FinanceType): "default" | "secondary" | "outline" | "destructive" {
|
||||
if (t === "income" || t === "receivable") return "secondary";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
function statusVariant(s: FinanceStatus): "default" | "secondary" | "outline" | "destructive" {
|
||||
if (s === "paid") return "default";
|
||||
if (s === "cancelled") return "destructive";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
export function FinanceTable({ rows }: { rows: FinanceEntryWithCounterpart[] }) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||
Henüz finansal hareket yok. İşler gönderildiğinde otomatik kayıt oluşur.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Tarih</TableHead>
|
||||
<TableHead>Tür</TableHead>
|
||||
<TableHead>Karşı Taraf</TableHead>
|
||||
<TableHead>Açıklama</TableHead>
|
||||
<TableHead className="text-right">Tutar</TableHead>
|
||||
<TableHead>Durum</TableHead>
|
||||
<TableHead className="text-right">İşlem</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((r) => (
|
||||
<FinanceRow key={r.$id} row={r} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function FinanceRow({ row }: { row: FinanceEntryWithCounterpart }) {
|
||||
const [paidState, paidAction, paidPending] = useActionState(
|
||||
markFinancePaidAction,
|
||||
initialFinanceActionState,
|
||||
);
|
||||
const [reopenState, reopenAction, reopenPending] = useActionState(
|
||||
reopenFinanceAction,
|
||||
initialFinanceActionState,
|
||||
);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
if (paidState.ok) toast.success("Ödendi olarak işaretlendi.");
|
||||
else if (paidState.error) toast.error(paidState.error);
|
||||
}, [paidState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (reopenState.ok) toast.success("Tekrar bekleyene alındı.");
|
||||
else if (reopenState.error) toast.error(reopenState.error);
|
||||
}, [reopenState]);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{dateFormatter.format(new Date(row.date))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={typeVariant(row.type)}>{FINANCE_TYPE_LABELS[row.type]}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{row.counterpart?.companyName ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[280px] truncate text-sm">
|
||||
{row.jobId ? (
|
||||
<Link href={`/jobs/${row.jobId}`} className="hover:underline">
|
||||
{row.description ?? "—"}
|
||||
</Link>
|
||||
) : (
|
||||
row.description ?? "—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatMoney(row.amount, row.currency || "TRY")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(row.status)}>{FINANCE_STATUS_LABELS[row.status]}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.status === "pending" ? (
|
||||
<form
|
||||
action={(fd) => {
|
||||
startTransition(() => paidAction(fd));
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={row.$id} />
|
||||
<Button type="submit" size="sm" disabled={paidPending}>
|
||||
{paidPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="size-4" />
|
||||
)}
|
||||
Ödendi
|
||||
</Button>
|
||||
</form>
|
||||
) : row.status === "paid" ? (
|
||||
<form
|
||||
action={(fd) => {
|
||||
startTransition(() => reopenAction(fd));
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={row.$id} />
|
||||
<Button type="submit" size="sm" variant="outline" disabled={reopenPending}>
|
||||
{reopenPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="size-4" />
|
||||
)}
|
||||
Geri al
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,106 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { listFinanceEntries, summarizeFinance } from "@/lib/appwrite/finance-queries";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { FinanceTable } from "./components/finance-table";
|
||||
|
||||
export const metadata = {
|
||||
title: "DLS — Finans",
|
||||
};
|
||||
|
||||
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 default async function FinancePage() {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
redirect("/onboarding");
|
||||
}
|
||||
|
||||
const entries = await listFinanceEntries(ctx.tenantId);
|
||||
const stats = summarizeFinance(entries);
|
||||
const isLab = ctx.kind === "lab";
|
||||
|
||||
export default function FinancePage() {
|
||||
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">
|
||||
Gelen ödemeler, ödenen hesaplar ve bekleyen tahsilatlar.
|
||||
İş bazlı tahsilat ve ödeme akışı. {isLab ? "Alacaklarınız ve gelirleriniz." : "Ödenecek ve harcamalarınız."}
|
||||
</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)}
|
||||
tone={isLab ? "positive" : "negative"}
|
||||
/>
|
||||
<StatCard
|
||||
label="Bu Ay Gelir"
|
||||
value={formatMoney(stats.incomeThisMonth, stats.currency)}
|
||||
tone="positive"
|
||||
/>
|
||||
<StatCard
|
||||
label="Bu Ay Gider"
|
||||
value={formatMoney(stats.expenseThisMonth, stats.currency)}
|
||||
tone="negative"
|
||||
/>
|
||||
<StatCard
|
||||
label="Toplam Kayıt"
|
||||
value={String(entries.length)}
|
||||
tone="neutral"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Yapım aşamasında</CardTitle>
|
||||
<CardDescription>Finans hareketleri, durum takibi ve raporlar sonraki sürümde.</CardDescription>
|
||||
<CardTitle>Hareketler</CardTitle>
|
||||
<CardDescription>
|
||||
Tamamlanan işlerden otomatik oluşturulan finansal kayıtlar. Manuel kayıt eklemek sonraki sürümde.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent />
|
||||
<CardContent>
|
||||
<FinanceTable rows={entries} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone: "positive" | "negative" | "neutral";
|
||||
}) {
|
||||
const color =
|
||||
tone === "positive"
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: tone === "negative"
|
||||
? "text-rose-600 dark:text-rose-400"
|
||||
: "text-foreground";
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription className="text-xs uppercase tracking-wide">
|
||||
{label}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className={`text-2xl font-semibold tabular-nums ${color}`}>{value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useState, useTransition } from "react";
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
CircleAlert,
|
||||
Loader2,
|
||||
Play,
|
||||
PackageCheck,
|
||||
X,
|
||||
} 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 { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
acceptJobAction,
|
||||
advanceStepAction,
|
||||
cancelJobAction,
|
||||
markDeliveredAction,
|
||||
} from "@/lib/appwrite/job-actions";
|
||||
import {
|
||||
JOB_STEP_LABELS,
|
||||
JOB_STEP_ORDER,
|
||||
initialJobActionState,
|
||||
} from "@/lib/appwrite/job-types";
|
||||
import type { Job, TenantKind } from "@/lib/appwrite/schema";
|
||||
|
||||
type Side = "clinic" | "lab";
|
||||
|
||||
export function JobActionsPanel({
|
||||
job,
|
||||
side,
|
||||
kind,
|
||||
}: {
|
||||
job: Job;
|
||||
side: Side;
|
||||
kind: TenantKind | null;
|
||||
}) {
|
||||
if (!kind) return null;
|
||||
|
||||
const isLab = side === "lab";
|
||||
const isClinic = side === "clinic";
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isLab && job.status === "pending" && <AcceptButton jobId={job.$id} />}
|
||||
{isLab && job.status === "in_progress" && <AdvanceButton job={job} />}
|
||||
{isClinic && job.status === "sent" && <DeliverButton jobId={job.$id} />}
|
||||
{(isClinic || isLab) && job.status === "pending" && (
|
||||
<CancelButton jobId={job.$id} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AcceptButton({ jobId }: { jobId: string }) {
|
||||
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]);
|
||||
|
||||
return (
|
||||
<form
|
||||
action={(fd) => {
|
||||
startTransition(() => action(fd));
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="jobId" value={jobId} />
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
|
||||
İşleme Al
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function AdvanceButton({ job }: { job: Job }) {
|
||||
const [state, action, pending] = useActionState(advanceStepAction, initialJobActionState);
|
||||
const [, startTransition] = useTransition();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success("Aşama ilerletildi.");
|
||||
setOpen(false);
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
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]];
|
||||
|
||||
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"}
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isFinal ? "İşi gönderilmiş olarak işaretle" : `${currentLabel} tamamlandı`}
|
||||
</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.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
action={(fd) => {
|
||||
startTransition(() => action(fd));
|
||||
}}
|
||||
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 kontrolü yapıldı, hasta provası onaylandı."
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Vazgeç
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
|
||||
Onayla
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DeliverButton({ jobId }: { jobId: string }) {
|
||||
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]);
|
||||
|
||||
return (
|
||||
<form
|
||||
action={(fd) => {
|
||||
startTransition(() => action(fd));
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="jobId" value={jobId} />
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? <Loader2 className="size-4 animate-spin" /> : <PackageCheck className="size-4" />}
|
||||
Teslim Aldım
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function CancelButton({ jobId }: { jobId: string }) {
|
||||
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);
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Button variant="outline" onClick={() => setOpen(true)}>
|
||||
<X className="size-4" />
|
||||
İptal Et
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>İş iptal edilsin mi?</DialogTitle>
|
||||
<DialogDescription className="flex items-start gap-2">
|
||||
<CircleAlert className="text-destructive size-4 shrink-0" />
|
||||
<span>
|
||||
İş geri alınamaz şekilde iptal duruma geçer. Karşı taraf da bu işi iptal edilmiş olarak görür.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
action={(fd) => {
|
||||
startTransition(() => action(fd));
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="jobId" value={jobId} />
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Vazgeç
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" variant="destructive" disabled={pending}>
|
||||
{pending ? <Loader2 className="size-4 animate-spin" /> : <X className="size-4" />}
|
||||
İptal Et
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useRef, useState, useTransition } from "react";
|
||||
import { Download, FileText, ImageIcon, Layers, Loader2, Trash2, Upload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
deleteJobFileAction,
|
||||
uploadJobFilesAction,
|
||||
} from "@/lib/appwrite/job-file-actions";
|
||||
import {
|
||||
initialJobFileActionState,
|
||||
initialJobFileUploadState,
|
||||
JOB_FILE_KIND_LABELS,
|
||||
} from "@/lib/appwrite/job-file-types";
|
||||
import type { JobFileWithUrl } from "@/lib/appwrite/job-file-queries";
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function kindIcon(kind: string) {
|
||||
if (kind === "image") return <ImageIcon className="size-4" />;
|
||||
if (kind === "scan") return <Layers className="size-4" />;
|
||||
return <FileText className="size-4" />;
|
||||
}
|
||||
|
||||
export function JobFilesPanel({
|
||||
jobId,
|
||||
files,
|
||||
}: {
|
||||
jobId: string;
|
||||
files: JobFileWithUrl[];
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<UploadForm jobId={jobId} />
|
||||
{files.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
Henüz dosya yok.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-md border">
|
||||
{files.map((f) => (
|
||||
<FileRow key={f.$id} file={f} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadForm({ jobId }: { jobId: string }) {
|
||||
const [state, action, pending] = useActionState(
|
||||
uploadJobFilesAction,
|
||||
initialJobFileUploadState,
|
||||
);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [selected, setSelected] = useState<File[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok && state.uploaded) {
|
||||
toast.success(`${state.uploaded} dosya yüklendi.`);
|
||||
formRef.current?.reset();
|
||||
setSelected([]);
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={formRef}
|
||||
action={action}
|
||||
className="bg-muted/30 flex flex-wrap items-center gap-3 rounded-md border p-3"
|
||||
>
|
||||
<input type="hidden" name="jobId" value={jobId} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
name="files"
|
||||
multiple
|
||||
className="hidden"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.webp,.tiff,.tif,.bmp,.heic,.heif,.stl,.obj,.ply,.3mf,.zip,.rar,.7z,.dcm,.stm"
|
||||
onChange={(e) => {
|
||||
const list = e.target.files ? Array.from(e.target.files) : [];
|
||||
setSelected(list);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
Dosya seç
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs flex-1">
|
||||
{selected.length > 0
|
||||
? `${selected.length} dosya seçildi (${formatSize(selected.reduce((s, f) => s + f.size, 0))})`
|
||||
: "Tarama (STL/OBJ), görsel veya PDF — max 30MB / dosya"}
|
||||
</span>
|
||||
<Button type="submit" size="sm" disabled={pending || selected.length === 0}>
|
||||
{pending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Yükleniyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="size-4" />
|
||||
Yükle
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function FileRow({ file }: { file: JobFileWithUrl }) {
|
||||
const [state, action, pending] = useActionState(
|
||||
deleteJobFileAction,
|
||||
initialJobFileActionState,
|
||||
);
|
||||
const [, startTransition] = useTransition();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success("Dosya silindi.");
|
||||
setOpen(false);
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-3 px-3 py-2">
|
||||
<span className="text-muted-foreground">{kindIcon(file.kind)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-sm font-medium">{file.name}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{JOB_FILE_KIND_LABELS[file.kind] ?? file.kind} · {formatSize(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
<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}>
|
||||
<Download className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Dosya silinsin mi?</DialogTitle>
|
||||
<DialogDescription>{file.name}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Vazgeç
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<form
|
||||
action={(fd) => {
|
||||
startTransition(() => action(fd));
|
||||
}}
|
||||
>
|
||||
<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" />}
|
||||
Sil
|
||||
</Button>
|
||||
</form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,23 @@
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { createAdminClient } from "@/lib/appwrite/server";
|
||||
import { DATABASE_ID, TABLES, type Job, type TenantSettings } from "@/lib/appwrite/schema";
|
||||
import { Query } from "node-appwrite";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { listJobFiles } from "@/lib/appwrite/job-file-queries";
|
||||
import { listJobHistory } from "@/lib/appwrite/job-history-queries";
|
||||
import {
|
||||
JOB_STATUS_LABELS,
|
||||
JOB_STEP_LABELS,
|
||||
JOB_STEP_ORDER,
|
||||
PROSTHETIC_TYPE_LABELS,
|
||||
} from "@/lib/appwrite/job-types";
|
||||
import { createAdminClient } from "@/lib/appwrite/server";
|
||||
import { DATABASE_ID, TABLES, type Job, type TenantSettings } from "@/lib/appwrite/schema";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { JobActionsPanel } from "./components/job-actions-panel";
|
||||
import { JobFilesPanel } from "./components/job-files-panel";
|
||||
|
||||
export const metadata = {
|
||||
title: "DLS — İş Detay",
|
||||
@@ -73,7 +77,12 @@ export default async function JobDetailPage({
|
||||
});
|
||||
const counterpart = counterpartRes.rows[0] as unknown as TenantSettings | undefined;
|
||||
|
||||
const [history, files] = await Promise.all([
|
||||
listJobHistory(jobId),
|
||||
listJobFiles(jobId),
|
||||
]);
|
||||
const currentStepIdx = job.currentStep ? JOB_STEP_ORDER.indexOf(job.currentStep) : -1;
|
||||
const side = job.clinicTenantId === ctx.tenantId ? "clinic" : "lab";
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-6 px-6">
|
||||
@@ -89,9 +98,12 @@ export default async function JobDetailPage({
|
||||
{PROSTHETIC_TYPE_LABELS[job.prostheticType]} · {job.memberCount} üye
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-sm">
|
||||
{JOB_STATUS_LABELS[job.status]}
|
||||
</Badge>
|
||||
<div className="flex flex-col items-end gap-3">
|
||||
<Badge variant="secondary" className="text-sm">
|
||||
{JOB_STATUS_LABELS[job.status]}
|
||||
</Badge>
|
||||
<JobActionsPanel job={job} side={side} kind={ctx.kind} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
@@ -167,6 +179,46 @@ export default async function JobDetailPage({
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Taranan Dosyalar ve Görseller</CardTitle>
|
||||
<CardDescription>
|
||||
Hem klinik hem laboratuvar dosya yükleyip indirebilir.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<JobFilesPanel jobId={job.$id} files={files} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{history.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Aşama Geçmişi</CardTitle>
|
||||
<CardDescription>Tamamlanan aşamaların kaydı.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ol className="space-y-3">
|
||||
{history.map((h) => (
|
||||
<li key={h.$id} className="border-l-2 border-primary/30 pl-4">
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<span className="font-medium">{JOB_STEP_LABELS[h.step]}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{dateFormatter.format(new Date(h.completedAt))}
|
||||
</span>
|
||||
</div>
|
||||
{h.note && (
|
||||
<p className="text-muted-foreground mt-1 whitespace-pre-wrap text-sm">
|
||||
{h.note}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={ctx.kind === "clinic" ? "/jobs/outbound" : "/jobs/inbound"}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getActiveContext } from "@/lib/appwrite/active-context";
|
||||
import { countUnreadNotifications } from "@/lib/appwrite/notification-helpers";
|
||||
import { getLogoUrl } from "@/lib/appwrite/storage";
|
||||
import { getUserPrefs } from "@/lib/appwrite/user-prefs-actions";
|
||||
import type { UserPrefs as ThemePrefs } from "@/lib/appwrite/user-prefs-actions";
|
||||
@@ -14,7 +15,10 @@ export default async function DashboardLayout({
|
||||
const ctx = await getActiveContext();
|
||||
if (!ctx) redirect("/onboarding");
|
||||
|
||||
const themePrefs: ThemePrefs = await getUserPrefs();
|
||||
const [themePrefs, unreadCount] = (await Promise.all([
|
||||
getUserPrefs(),
|
||||
countUnreadNotifications(ctx.tenantId),
|
||||
])) as [ThemePrefs, number];
|
||||
|
||||
const company = {
|
||||
id: ctx.tenantId,
|
||||
@@ -29,7 +33,12 @@ export default async function DashboardLayout({
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardShell user={user} company={company} initialPrefs={themePrefs}>
|
||||
<DashboardShell
|
||||
user={user}
|
||||
company={company}
|
||||
initialPrefs={themePrefs}
|
||||
unreadCount={unreadCount}
|
||||
>
|
||||
{children}
|
||||
</DashboardShell>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useActionState, useEffect, useTransition } from "react";
|
||||
import { ArrowRight, Check, CheckCheck, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
initialNotificationActionState,
|
||||
markAllNotificationsReadAction,
|
||||
markNotificationReadAction,
|
||||
} from "@/lib/appwrite/notification-actions";
|
||||
import type { Notification } from "@/lib/appwrite/schema";
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
export function NotificationsList({ rows }: { rows: Notification[] }) {
|
||||
const unread = rows.filter((r) => !r.read).length;
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||
Henüz bildirim yok.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{unread > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<MarkAllReadButton />
|
||||
</div>
|
||||
)}
|
||||
<ul className="divide-y rounded-md border">
|
||||
{rows.map((n) => (
|
||||
<NotificationRow key={n.$id} row={n} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationRow({ row }: { row: Notification }) {
|
||||
const [state, action, pending] = useActionState(
|
||||
markNotificationReadAction,
|
||||
initialNotificationActionState,
|
||||
);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
if (state.error) toast.error(state.error);
|
||||
}, [state]);
|
||||
|
||||
const link = row.jobId
|
||||
? `/jobs/${row.jobId}`
|
||||
: row.connectionId
|
||||
? "/connections"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<li className={`flex items-start gap-3 px-3 py-3 ${row.read ? "opacity-70" : ""}`}>
|
||||
<span
|
||||
className={`mt-1.5 size-2 shrink-0 rounded-full ${row.read ? "bg-muted" : "bg-primary"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm">{row.message}</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{dateFormatter.format(new Date(row.$createdAt))}
|
||||
</p>
|
||||
</div>
|
||||
{!row.read && (
|
||||
<Badge variant="secondary" className="text-[10px] uppercase">
|
||||
Yeni
|
||||
</Badge>
|
||||
)}
|
||||
{link && (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={link}>
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{!row.read && (
|
||||
<form
|
||||
action={(fd) => {
|
||||
startTransition(() => action(fd));
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={row.$id} />
|
||||
<Button type="submit" size="sm" variant="outline" disabled={pending}>
|
||||
{pending ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkAllReadButton() {
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
const res = await markAllNotificationsReadAction();
|
||||
if (res.ok) toast.success("Tümü okundu olarak işaretlendi.");
|
||||
else if (res.error) toast.error(res.error);
|
||||
})
|
||||
}
|
||||
>
|
||||
{pending ? <Loader2 className="size-4 animate-spin" /> : <CheckCheck className="size-4" />}
|
||||
Hepsini okundu işaretle
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { listNotifications } from "@/lib/appwrite/notification-helpers";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { NotificationsList } from "./components/notifications-list";
|
||||
|
||||
export const metadata = {
|
||||
title: "DLS — Bildirimler",
|
||||
};
|
||||
|
||||
export default async function NotificationsPage() {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
redirect("/onboarding");
|
||||
}
|
||||
|
||||
const rows = await listNotifications(ctx.tenantId);
|
||||
|
||||
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">Bildirimler</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Bağlantı talepleri, iş güncellemeleri ve diğer olaylar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Son Bildirimler</CardTitle>
|
||||
<CardDescription>
|
||||
{rows.length === 0 ? "Henüz bildirim yok." : `${rows.length} kayıt`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<NotificationsList rows={rows} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Building2, Plus } from "lucide-react";
|
||||
import { Bell, Building2, Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { ModeToggle } from "@/components/mode-toggle";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
|
||||
import type { ShellCompany } from "@/app/(dashboard)/dashboard-shell";
|
||||
|
||||
export function SiteHeader({ company }: { company?: ShellCompany }) {
|
||||
export function SiteHeader({
|
||||
company,
|
||||
unreadCount = 0,
|
||||
}: {
|
||||
company?: ShellCompany;
|
||||
unreadCount?: number;
|
||||
}) {
|
||||
const showNewJobCta = company?.kind === "clinic";
|
||||
|
||||
return (
|
||||
@@ -39,6 +46,19 @@ export function SiteHeader({ company }: { company?: ShellCompany }) {
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild variant="ghost" size="icon" className="relative">
|
||||
<Link href="/notifications" aria-label="Bildirimler">
|
||||
<Bell className="size-4" />
|
||||
{unreadCount > 0 && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="absolute -right-1 -top-1 size-4 min-w-4 justify-center rounded-full p-0 text-[10px]"
|
||||
>
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</Button>
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
|
||||
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
|
||||
|
||||
import { logAudit } from "./audit";
|
||||
import { createNotification } from "./notification-helpers";
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
@@ -139,6 +140,12 @@ export async function requestConnectionAction(
|
||||
entityId: created.$id,
|
||||
changes: { clinicTenantId, labTenantId, status: "pending" },
|
||||
});
|
||||
const counterpartId = counterpart.tenantId;
|
||||
await createNotification({
|
||||
tenantId: counterpartId,
|
||||
connectionId: created.$id,
|
||||
message: `${ctx.settings?.companyName ?? "Bir hesap"} bağlantı talebi gönderdi.`,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Bağlantı talebi gönderilemedi.") };
|
||||
@@ -218,6 +225,13 @@ export async function approveConnectionAction(
|
||||
entityId: connectionId,
|
||||
changes: { status: "approved" },
|
||||
});
|
||||
const requesterTenant =
|
||||
conn.clinicTenantId === ctx.tenantId ? conn.labTenantId : conn.clinicTenantId;
|
||||
await createNotification({
|
||||
tenantId: requesterTenant,
|
||||
connectionId,
|
||||
message: `${ctx.settings?.companyName ?? "Karşı taraf"} bağlantı talebinizi onayladı.`,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Onaylanamadı.") };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AppwriteException } from "node-appwrite";
|
||||
|
||||
import { logAudit } from "./audit";
|
||||
import { DATABASE_ID, TABLES, type FinanceEntry } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireRole, requireTenant } from "./tenant-guard";
|
||||
import type { FinanceActionState } from "./finance-types";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function loadEntryForTenant(
|
||||
id: string,
|
||||
tenantId: string,
|
||||
): Promise<FinanceEntry | null> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = await tablesDB.getRow(DATABASE_ID, TABLES.financeEntries, id);
|
||||
const entry = row as unknown as FinanceEntry;
|
||||
if (entry.tenantId !== tenantId) return null;
|
||||
return entry;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function markFinancePaidAction(
|
||||
_prev: FinanceActionState,
|
||||
formData: FormData,
|
||||
): Promise<FinanceActionState> {
|
||||
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: "Bu işlem için yetkiniz yok." };
|
||||
}
|
||||
|
||||
const entry = await loadEntryForTenant(id, ctx.tenantId);
|
||||
if (!entry) return { ok: false, error: "Kayıt bulunamadı." };
|
||||
if (entry.status === "paid") return { ok: false, error: "Bu kayıt zaten ödenmiş." };
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.financeEntries, id, {
|
||||
status: "paid",
|
||||
});
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "finance_entry",
|
||||
entityId: id,
|
||||
changes: { status: "paid" },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Güncellenemedi.") };
|
||||
}
|
||||
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function reopenFinanceAction(
|
||||
_prev: FinanceActionState,
|
||||
formData: FormData,
|
||||
): Promise<FinanceActionState> {
|
||||
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: "Bu işlem için yetkiniz yok." };
|
||||
}
|
||||
|
||||
const entry = await loadEntryForTenant(id, ctx.tenantId);
|
||||
if (!entry) return { ok: false, error: "Kayıt bulunamadı." };
|
||||
if (entry.status === "pending") return { ok: false, error: "Bu kayıt zaten bekliyor." };
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.financeEntries, id, {
|
||||
status: "pending",
|
||||
});
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "finance_entry",
|
||||
entityId: id,
|
||||
changes: { status: "pending" },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Güncellenemedi.") };
|
||||
}
|
||||
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type FinanceEntry,
|
||||
type TenantKind,
|
||||
type TenantSettings,
|
||||
} from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
|
||||
export type FinanceCounterpart = {
|
||||
tenantId: string;
|
||||
companyName: string;
|
||||
kind: TenantKind;
|
||||
};
|
||||
|
||||
export type FinanceEntryWithCounterpart = FinanceEntry & {
|
||||
counterpart: FinanceCounterpart | null;
|
||||
};
|
||||
|
||||
export async function listFinanceEntries(
|
||||
tenantId: string,
|
||||
): Promise<FinanceEntryWithCounterpart[]> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.financeEntries,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.orderDesc("date"),
|
||||
Query.limit(200),
|
||||
],
|
||||
});
|
||||
const rows = result.rows as unknown as FinanceEntry[];
|
||||
|
||||
const counterpartIds = Array.from(
|
||||
new Set(rows.map((r) => r.counterpartTenantId).filter((v): v is string => Boolean(v))),
|
||||
);
|
||||
if (counterpartIds.length === 0) {
|
||||
return rows.map((r) => ({ ...r, counterpart: null }));
|
||||
}
|
||||
|
||||
const counterpartsRes = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.tenantSettings,
|
||||
queries: [Query.equal("tenantId", counterpartIds), Query.limit(200)],
|
||||
});
|
||||
const map = new Map<string, FinanceCounterpart>();
|
||||
for (const s of counterpartsRes.rows as unknown as TenantSettings[]) {
|
||||
map.set(s.tenantId, {
|
||||
tenantId: s.tenantId,
|
||||
companyName: s.companyName,
|
||||
kind: s.kind,
|
||||
});
|
||||
}
|
||||
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
counterpart: r.counterpartTenantId ? map.get(r.counterpartTenantId) ?? null : null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function summarizeFinance(
|
||||
entries: FinanceEntryWithCounterpart[],
|
||||
): {
|
||||
receivablePending: number;
|
||||
payablePending: number;
|
||||
incomeThisMonth: number;
|
||||
expenseThisMonth: number;
|
||||
currency: string;
|
||||
} {
|
||||
const now = new Date();
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
|
||||
let receivablePending = 0;
|
||||
let payablePending = 0;
|
||||
let incomeThisMonth = 0;
|
||||
let expenseThisMonth = 0;
|
||||
const currency = entries.find((e) => e.currency)?.currency || "TRY";
|
||||
|
||||
for (const e of entries) {
|
||||
if (e.status === "cancelled") continue;
|
||||
if (e.type === "receivable" && e.status === "pending") receivablePending += e.amount;
|
||||
if (e.type === "payable" && e.status === "pending") payablePending += e.amount;
|
||||
if (e.date >= monthStart) {
|
||||
if ((e.type === "income" || e.type === "receivable") && e.status === "paid") {
|
||||
incomeThisMonth += e.amount;
|
||||
}
|
||||
if ((e.type === "expense" || e.type === "payable") && e.status === "paid") {
|
||||
expenseThisMonth += e.amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { receivablePending, payablePending, incomeThisMonth, expenseThisMonth, currency };
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import "server-only";
|
||||
|
||||
import { ID, Permission, Query, Role } from "node-appwrite";
|
||||
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type FinanceType,
|
||||
type Job,
|
||||
} from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
|
||||
/**
|
||||
* Idempotent finance reconciliation for a job. Safe to call from every job
|
||||
* mutation that might land the job in `sent` or `delivered`. Memory rule
|
||||
* [[feedback_cross_entity_sync_helpers]]: never re-throw, best-effort, single
|
||||
* named function called from every write path.
|
||||
*
|
||||
* Two rows are created the first time a job becomes sent/delivered:
|
||||
* - clinic side → payable (status: pending)
|
||||
* - lab side → receivable (status: pending)
|
||||
*
|
||||
* On `delivered`, pending rows can stay pending — actually settling them is
|
||||
* the user's job through the /finance UI (mark as paid). We just ensure both
|
||||
* rows exist.
|
||||
*/
|
||||
export async function syncFinanceForJob(job: Job): Promise<void> {
|
||||
try {
|
||||
if (!job.price || job.price <= 0) return;
|
||||
if (job.status !== "sent" && job.status !== "delivered") return;
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
const currency = job.currency || "TRY";
|
||||
|
||||
const sides: Array<{
|
||||
tenantId: string;
|
||||
counterpartTenantId: string;
|
||||
type: FinanceType;
|
||||
}> = [
|
||||
{ tenantId: job.clinicTenantId, counterpartTenantId: job.labTenantId, type: "payable" },
|
||||
{ tenantId: job.labTenantId, counterpartTenantId: job.clinicTenantId, type: "receivable" },
|
||||
];
|
||||
|
||||
for (const side of sides) {
|
||||
const existing = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.financeEntries,
|
||||
queries: [
|
||||
Query.equal("jobId", job.$id),
|
||||
Query.equal("tenantId", side.tenantId),
|
||||
Query.equal("type", side.type),
|
||||
Query.limit(1),
|
||||
],
|
||||
});
|
||||
if (existing.total > 0) continue;
|
||||
|
||||
await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.financeEntries,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: side.tenantId,
|
||||
createdBy: job.createdBy,
|
||||
jobId: job.$id,
|
||||
counterpartTenantId: side.counterpartTenantId,
|
||||
type: side.type,
|
||||
amount: job.price,
|
||||
currency,
|
||||
status: "pending",
|
||||
date: new Date().toISOString(),
|
||||
description: `Hasta ${job.patientCode} — ${job.prostheticType.replace(/_/g, " ")}`.slice(0, 1000),
|
||||
},
|
||||
[
|
||||
Permission.read(Role.team(side.tenantId)),
|
||||
Permission.update(Role.team(side.tenantId, "owner")),
|
||||
Permission.update(Role.team(side.tenantId, "admin")),
|
||||
Permission.delete(Role.team(side.tenantId, "owner")),
|
||||
],
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Best-effort: never block the parent job mutation.
|
||||
console.error("[syncFinanceForJob]", err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { FinanceType, FinanceStatus } from "./schema";
|
||||
|
||||
export type FinanceActionState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const initialFinanceActionState: FinanceActionState = { ok: false };
|
||||
|
||||
export const FINANCE_TYPE_LABELS: Record<FinanceType, string> = {
|
||||
income: "Gelir",
|
||||
expense: "Gider",
|
||||
receivable: "Alacak",
|
||||
payable: "Borç",
|
||||
};
|
||||
|
||||
export const FINANCE_STATUS_LABELS: Record<FinanceStatus, string> = {
|
||||
pending: "Bekliyor",
|
||||
paid: "Ödendi",
|
||||
cancelled: "İptal",
|
||||
};
|
||||
@@ -5,14 +5,19 @@ import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
|
||||
import { z } from "zod";
|
||||
|
||||
import { logAudit } from "./audit";
|
||||
import { syncFinanceForJob } from "./finance-sync";
|
||||
import { createNotification } from "./notification-helpers";
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type Connection,
|
||||
type Job,
|
||||
type JobStep,
|
||||
} from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireRole, requireTenant, requireTenantKind } from "./tenant-guard";
|
||||
import type { JobFormState } from "./job-types";
|
||||
import { JOB_STEP_ORDER } from "./job-types";
|
||||
import type { JobActionState, JobFormState } from "./job-types";
|
||||
import { createJobSchema } from "@/lib/validation/job";
|
||||
|
||||
function appwriteError(e: unknown, fallback = "Beklenmeyen bir hata oluştu."): string {
|
||||
@@ -133,6 +138,11 @@ export async function createJobAction(
|
||||
entityId: created.$id,
|
||||
changes: { labTenantId: parsed.data.labTenantId, patientCode: parsed.data.patientCode },
|
||||
});
|
||||
await createNotification({
|
||||
tenantId: parsed.data.labTenantId,
|
||||
jobId: created.$id,
|
||||
message: `${ctx.settings?.companyName ?? "Bir klinik"} yeni iş yayınladı (${parsed.data.patientCode}).`,
|
||||
});
|
||||
revalidatePath("/jobs/outbound");
|
||||
revalidatePath("/dashboard");
|
||||
return { ok: true, jobId: created.$id };
|
||||
@@ -140,3 +150,301 @@ export async function createJobAction(
|
||||
return { ok: false, error: appwriteError(e, "İş oluşturulamadı.") };
|
||||
}
|
||||
}
|
||||
|
||||
function jobHistoryPermissions(clinicTenantId: string, labTenantId: string): string[] {
|
||||
return [
|
||||
Permission.read(Role.team(clinicTenantId)),
|
||||
Permission.read(Role.team(labTenantId)),
|
||||
];
|
||||
}
|
||||
|
||||
async function loadJobForTenant(jobId: string, tenantId: string): Promise<Job | null> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = await tablesDB.getRow(DATABASE_ID, TABLES.jobs, jobId);
|
||||
const job = row as unknown as Job;
|
||||
if (job.clinicTenantId !== tenantId && job.labTenantId !== tenantId) {
|
||||
return null;
|
||||
}
|
||||
return job;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function appendJobHistory(args: {
|
||||
job: Job;
|
||||
step: JobStep;
|
||||
completedBy: string;
|
||||
note?: string;
|
||||
}): Promise<void> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
try {
|
||||
await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.jobStatusHistory,
|
||||
ID.unique(),
|
||||
{
|
||||
jobId: args.job.$id,
|
||||
clinicTenantId: args.job.clinicTenantId,
|
||||
labTenantId: args.job.labTenantId,
|
||||
step: args.step,
|
||||
completedBy: args.completedBy,
|
||||
completedAt: new Date().toISOString(),
|
||||
note: args.note,
|
||||
},
|
||||
jobHistoryPermissions(args.job.clinicTenantId, args.job.labTenantId),
|
||||
);
|
||||
} catch {
|
||||
// history failures must never block the main mutation
|
||||
}
|
||||
}
|
||||
|
||||
export async function acceptJobAction(
|
||||
_prev: JobActionState,
|
||||
formData: FormData,
|
||||
): Promise<JobActionState> {
|
||||
const jobId = String(formData.get("jobId") ?? "").trim();
|
||||
if (!jobId) return { ok: false, error: "İş bulunamadı." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin", "member"]);
|
||||
requireTenantKind(ctx, ["lab"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Sadece laboratuvar bu işi kabul edebilir." };
|
||||
}
|
||||
|
||||
const job = await loadJobForTenant(jobId, ctx.tenantId);
|
||||
if (!job || job.labTenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "İş bulunamadı." };
|
||||
}
|
||||
if (job.status !== "pending") {
|
||||
return { ok: false, error: "Bu iş zaten işleme alınmış." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||||
status: "in_progress",
|
||||
currentStep: "olcu",
|
||||
});
|
||||
await appendJobHistory({ job, step: "olcu", completedBy: ctx.user.id });
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "job",
|
||||
entityId: jobId,
|
||||
changes: { status: "in_progress", currentStep: "olcu" },
|
||||
});
|
||||
await createNotification({
|
||||
tenantId: job.clinicTenantId,
|
||||
jobId,
|
||||
message: `${ctx.settings?.companyName ?? "Lab"} hasta ${job.patientCode} işini işleme aldı.`,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Kabul edilemedi.") };
|
||||
}
|
||||
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
revalidatePath("/jobs/inbound");
|
||||
revalidatePath("/jobs/outbound");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function advanceStepAction(
|
||||
_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, ["lab"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Sadece laboratuvar aşama ilerletebilir." };
|
||||
}
|
||||
|
||||
const job = await loadJobForTenant(jobId, ctx.tenantId);
|
||||
if (!job || job.labTenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "İş bulunamadı." };
|
||||
}
|
||||
if (job.status !== "in_progress") {
|
||||
return { ok: false, error: "Yalnızca işleme alınmış işler ilerletilebilir." };
|
||||
}
|
||||
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;
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
if (isFinalStepComplete) {
|
||||
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,
|
||||
});
|
||||
await appendJobHistory({
|
||||
job,
|
||||
step: job.currentStep!,
|
||||
completedBy: ctx.user.id,
|
||||
note,
|
||||
});
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "job",
|
||||
entityId: jobId,
|
||||
changes: { currentStep: nextStep, completedStep: job.currentStep },
|
||||
});
|
||||
}
|
||||
} 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.`,
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
revalidatePath("/jobs/inbound");
|
||||
revalidatePath("/jobs/outbound");
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function markDeliveredAction(
|
||||
_prev: JobActionState,
|
||||
formData: FormData,
|
||||
): Promise<JobActionState> {
|
||||
const jobId = String(formData.get("jobId") ?? "").trim();
|
||||
if (!jobId) return { ok: false, error: "İş bulunamadı." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin", "member"]);
|
||||
requireTenantKind(ctx, ["clinic"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Teslim almayı yalnızca klinik yapabilir." };
|
||||
}
|
||||
|
||||
const job = await loadJobForTenant(jobId, ctx.tenantId);
|
||||
if (!job || job.clinicTenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "İş bulunamadı." };
|
||||
}
|
||||
if (job.status !== "sent") {
|
||||
return { ok: false, error: "Sadece gönderilmiş işler teslim alınabilir." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||||
status: "delivered",
|
||||
});
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "job",
|
||||
entityId: jobId,
|
||||
changes: { status: "delivered" },
|
||||
});
|
||||
await syncFinanceForJob({ ...job, status: "delivered" });
|
||||
await createNotification({
|
||||
tenantId: job.labTenantId,
|
||||
jobId,
|
||||
message: `Hasta ${job.patientCode} işi teslim alındı.`,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Teslim alınamadı.") };
|
||||
}
|
||||
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
revalidatePath("/jobs/outbound");
|
||||
revalidatePath("/jobs/inbound");
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function cancelJobAction(
|
||||
_prev: JobActionState,
|
||||
formData: FormData,
|
||||
): Promise<JobActionState> {
|
||||
const jobId = String(formData.get("jobId") ?? "").trim();
|
||||
if (!jobId) return { ok: false, error: "İş bulunamadı." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Bu işlem için yetkiniz yok." };
|
||||
}
|
||||
|
||||
const job = await loadJobForTenant(jobId, ctx.tenantId);
|
||||
if (!job) return { ok: false, error: "İş bulunamadı." };
|
||||
if (job.status !== "pending") {
|
||||
return { ok: false, error: "Yalnızca bekleyen işler iptal edilebilir." };
|
||||
}
|
||||
if (ctx.kind === "clinic" && job.clinicTenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
if (ctx.kind === "lab" && job.labTenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||||
status: "cancelled",
|
||||
});
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "job",
|
||||
entityId: jobId,
|
||||
changes: { status: "cancelled" },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "İptal edilemedi.") };
|
||||
}
|
||||
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
revalidatePath("/jobs/inbound");
|
||||
revalidatePath("/jobs/outbound");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AppwriteException, ID, Permission, Role } from "node-appwrite";
|
||||
|
||||
import { logAudit } from "./audit";
|
||||
import {
|
||||
BUCKETS,
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type Job,
|
||||
type JobFile,
|
||||
type JobFileKind,
|
||||
} from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireRole, requireTenant } from "./tenant-guard";
|
||||
import type {
|
||||
JobFileActionState,
|
||||
JobFileUploadState,
|
||||
} from "./job-file-types";
|
||||
|
||||
const MAX_FILE_BYTES = 30 * 1024 * 1024; // 30MB — bucket limit
|
||||
|
||||
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 classifyFile(mimeType: string | undefined, name: string): JobFileKind {
|
||||
const lower = (mimeType || name).toLowerCase();
|
||||
if (/\.(stl|obj|ply|3mf|dcm)$/i.test(name)) return "scan";
|
||||
if (lower.startsWith("image/") || /\.(png|jpe?g|webp|tiff?|heic|heif|bmp)$/i.test(name)) {
|
||||
return "image";
|
||||
}
|
||||
return "document";
|
||||
}
|
||||
|
||||
function filePermissions(clinicTenantId: string, labTenantId: string): string[] {
|
||||
return [
|
||||
Permission.read(Role.team(clinicTenantId)),
|
||||
Permission.read(Role.team(labTenantId)),
|
||||
Permission.delete(Role.team(clinicTenantId, "owner")),
|
||||
Permission.delete(Role.team(clinicTenantId, "admin")),
|
||||
Permission.delete(Role.team(labTenantId, "owner")),
|
||||
Permission.delete(Role.team(labTenantId, "admin")),
|
||||
];
|
||||
}
|
||||
|
||||
async function loadJobForTenant(jobId: string, tenantId: string): Promise<Job | null> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = await tablesDB.getRow(DATABASE_ID, TABLES.jobs, jobId);
|
||||
const job = row as unknown as Job;
|
||||
if (job.clinicTenantId !== tenantId && job.labTenantId !== tenantId) {
|
||||
return null;
|
||||
}
|
||||
return job;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadJobFilesAction(
|
||||
_prev: JobFileUploadState,
|
||||
formData: FormData,
|
||||
): Promise<JobFileUploadState> {
|
||||
const jobId = String(formData.get("jobId") ?? "").trim();
|
||||
if (!jobId) return { ok: false, error: "İş bulunamadı." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin", "member"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Yüklemek için yetkiniz yok." };
|
||||
}
|
||||
|
||||
const job = await loadJobForTenant(jobId, ctx.tenantId);
|
||||
if (!job) return { ok: false, error: "İş bulunamadı." };
|
||||
|
||||
const files = formData.getAll("files").filter((v): v is File => v instanceof File && v.size > 0);
|
||||
if (files.length === 0) {
|
||||
return { ok: false, error: "Dosya seçin." };
|
||||
}
|
||||
|
||||
for (const f of files) {
|
||||
if (f.size > MAX_FILE_BYTES) {
|
||||
return { ok: false, error: `${f.name} 30MB sınırını aşıyor.` };
|
||||
}
|
||||
}
|
||||
|
||||
const { storage, tablesDB } = createAdminClient();
|
||||
const uploadedFileIds: string[] = [];
|
||||
const createdRowIds: string[] = [];
|
||||
|
||||
try {
|
||||
for (const f of files) {
|
||||
const fileId = ID.unique();
|
||||
await storage.createFile({
|
||||
bucketId: BUCKETS.jobFiles,
|
||||
fileId,
|
||||
file: f,
|
||||
permissions: filePermissions(job.clinicTenantId, job.labTenantId),
|
||||
});
|
||||
uploadedFileIds.push(fileId);
|
||||
|
||||
const kind = classifyFile(f.type, f.name);
|
||||
const row = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.jobFiles,
|
||||
ID.unique(),
|
||||
{
|
||||
jobId: job.$id,
|
||||
clinicTenantId: job.clinicTenantId,
|
||||
labTenantId: job.labTenantId,
|
||||
uploadedBy: ctx.user.id,
|
||||
kind,
|
||||
fileId,
|
||||
name: f.name.slice(0, 255),
|
||||
size: f.size,
|
||||
mimeType: f.type ? f.type.slice(0, 100) : undefined,
|
||||
},
|
||||
[
|
||||
Permission.read(Role.team(job.clinicTenantId)),
|
||||
Permission.read(Role.team(job.labTenantId)),
|
||||
Permission.delete(Role.team(job.clinicTenantId, "owner")),
|
||||
Permission.delete(Role.team(job.clinicTenantId, "admin")),
|
||||
Permission.delete(Role.team(job.labTenantId, "owner")),
|
||||
Permission.delete(Role.team(job.labTenantId, "admin")),
|
||||
],
|
||||
);
|
||||
createdRowIds.push(row.$id);
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "job_files",
|
||||
entityId: jobId,
|
||||
changes: { count: files.length },
|
||||
});
|
||||
} catch (e) {
|
||||
// Rollback: best-effort cleanup of partially uploaded files and rows.
|
||||
for (const id of createdRowIds) {
|
||||
try {
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.jobFiles, id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
for (const id of uploadedFileIds) {
|
||||
try {
|
||||
await storage.deleteFile({ bucketId: BUCKETS.jobFiles, fileId: id });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return { ok: false, error: appwriteError(e, "Dosya yüklenemedi.") };
|
||||
}
|
||||
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { ok: true, uploaded: files.length };
|
||||
}
|
||||
|
||||
export async function deleteJobFileAction(
|
||||
_prev: JobFileActionState,
|
||||
formData: FormData,
|
||||
): Promise<JobFileActionState> {
|
||||
const rowId = String(formData.get("rowId") ?? "").trim();
|
||||
if (!rowId) return { ok: false, error: "Dosya bulunamadı." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Bu işlem için yetkiniz yok." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { storage, tablesDB } = createAdminClient();
|
||||
const row = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.jobFiles,
|
||||
rowId,
|
||||
)) as unknown as JobFile;
|
||||
if (
|
||||
row.clinicTenantId !== ctx.tenantId &&
|
||||
row.labTenantId !== ctx.tenantId
|
||||
) {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.jobFiles, rowId);
|
||||
try {
|
||||
await storage.deleteFile({ bucketId: BUCKETS.jobFiles, fileId: row.fileId });
|
||||
} catch {
|
||||
// File may already be gone; row is the source of truth.
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
entityType: "job_file",
|
||||
entityId: rowId,
|
||||
changes: { fileId: row.fileId, name: row.name },
|
||||
});
|
||||
|
||||
revalidatePath(`/jobs/${row.jobId}`);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Silinemedi.") };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { BUCKETS, DATABASE_ID, TABLES, type JobFile } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { getFileViewUrl } from "./storage";
|
||||
|
||||
export type JobFileWithUrl = JobFile & {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export async function listJobFiles(jobId: string): Promise<JobFileWithUrl[]> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.jobFiles,
|
||||
queries: [
|
||||
Query.equal("jobId", jobId),
|
||||
Query.orderDesc("$createdAt"),
|
||||
Query.limit(200),
|
||||
],
|
||||
});
|
||||
const rows = result.rows as unknown as JobFile[];
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
url: getFileViewUrl(BUCKETS.jobFiles, r.fileId),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export type JobFileUploadState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
uploaded?: number;
|
||||
};
|
||||
|
||||
export const initialJobFileUploadState: JobFileUploadState = { ok: false };
|
||||
|
||||
export type JobFileActionState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const initialJobFileActionState: JobFileActionState = { ok: false };
|
||||
|
||||
export const JOB_FILE_KIND_LABELS: Record<string, string> = {
|
||||
scan: "Tarama",
|
||||
image: "Görsel",
|
||||
document: "Belge",
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { DATABASE_ID, TABLES, type JobStatusHistory } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
|
||||
export async function listJobHistory(jobId: string): Promise<JobStatusHistory[]> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.jobStatusHistory,
|
||||
queries: [
|
||||
Query.equal("jobId", jobId),
|
||||
Query.orderAsc("completedAt"),
|
||||
Query.limit(100),
|
||||
],
|
||||
});
|
||||
return result.rows as unknown as JobStatusHistory[];
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AppwriteException, Query } from "node-appwrite";
|
||||
|
||||
import { DATABASE_ID, TABLES, type Notification } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireTenant } from "./tenant-guard";
|
||||
|
||||
export type NotificationActionState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const initialNotificationActionState: NotificationActionState = { ok: false };
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function markNotificationReadAction(
|
||||
_prev: NotificationActionState,
|
||||
formData: FormData,
|
||||
): Promise<NotificationActionState> {
|
||||
const id = String(formData.get("id") ?? "").trim();
|
||||
if (!id) return { ok: false, error: "Bildirim bulunamadı." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Oturum yok." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.notifications,
|
||||
id,
|
||||
)) as unknown as Notification;
|
||||
if (row.tenantId !== ctx.tenantId) return { ok: false, error: "Yetki yok." };
|
||||
if (row.read) return { ok: true };
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.notifications, id, { read: true });
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "İşaretlenemedi.") };
|
||||
}
|
||||
|
||||
revalidatePath("/notifications");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function markAllNotificationsReadAction(): Promise<NotificationActionState> {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Oturum yok." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.notifications,
|
||||
queries: [
|
||||
Query.equal("tenantId", ctx.tenantId),
|
||||
Query.equal("read", false),
|
||||
Query.limit(100),
|
||||
],
|
||||
});
|
||||
await Promise.allSettled(
|
||||
(result.rows as unknown as Notification[]).map((row) =>
|
||||
tablesDB.updateRow(DATABASE_ID, TABLES.notifications, row.$id, { read: true }),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "İşaretlenemedi.") };
|
||||
}
|
||||
|
||||
revalidatePath("/notifications");
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import "server-only";
|
||||
|
||||
import { ID, Permission, Query, Role } from "node-appwrite";
|
||||
|
||||
import { DATABASE_ID, TABLES, type Notification } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
|
||||
type CreateNotificationInput = {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
jobId?: string;
|
||||
connectionId?: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Idempotent notification creator. Best-effort: never re-throws so callers
|
||||
* (server actions in the hot path) stay reliable.
|
||||
*/
|
||||
export async function createNotification(input: CreateNotificationInput): Promise<void> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.notifications,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: input.tenantId,
|
||||
userId: input.userId,
|
||||
jobId: input.jobId,
|
||||
connectionId: input.connectionId,
|
||||
message: input.message.slice(0, 500),
|
||||
read: false,
|
||||
},
|
||||
[
|
||||
Permission.read(Role.team(input.tenantId)),
|
||||
Permission.update(Role.team(input.tenantId)),
|
||||
Permission.delete(Role.team(input.tenantId, "owner")),
|
||||
Permission.delete(Role.team(input.tenantId, "admin")),
|
||||
],
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[createNotification]", err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function countUnreadNotifications(tenantId: string): Promise<number> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.notifications,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.equal("read", false),
|
||||
Query.limit(1),
|
||||
],
|
||||
});
|
||||
return result.total;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listNotifications(
|
||||
tenantId: string,
|
||||
limit = 50,
|
||||
): Promise<Notification[]> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.notifications,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.orderDesc("$createdAt"),
|
||||
Query.limit(limit),
|
||||
],
|
||||
});
|
||||
return result.rows as unknown as Notification[];
|
||||
}
|
||||
@@ -24,6 +24,7 @@ const PROTECTED_PREFIXES = [
|
||||
"/products",
|
||||
"/finance",
|
||||
"/connections",
|
||||
"/notifications",
|
||||
];
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
|
||||
Reference in New Issue
Block a user