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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user