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:
kovakmedya
2026-05-21 20:17:33 +03:00
parent 76e02754b8
commit 2c6c074a06
24 changed files with 2066 additions and 21 deletions
@@ -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>
);
}
+91 -6
View File
@@ -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>
);
}