From 2c6c074a061d261bae4b7694c3f0faeba5418a86 Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Thu, 21 May 2026 20:17:33 +0300 Subject: [PATCH] feat: job status/step flow, file upload, finance sync, notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- next.config.ts | 4 +- src/app/(dashboard)/dashboard-shell.tsx | 6 +- .../finance/components/finance-table.tsx | 171 ++++++++++ src/app/(dashboard)/finance/page.tsx | 97 +++++- .../[jobId]/components/job-actions-panel.tsx | 239 ++++++++++++++ .../[jobId]/components/job-files-panel.tsx | 200 +++++++++++ src/app/(dashboard)/jobs/[jobId]/page.tsx | 66 +++- src/app/(dashboard)/layout.tsx | 13 +- .../components/notifications-list.tsx | 129 ++++++++ src/app/(dashboard)/notifications/page.tsx | 44 +++ src/components/site-header.tsx | 24 +- src/lib/appwrite/connection-actions.ts | 14 + src/lib/appwrite/finance-actions.ts | 112 +++++++ src/lib/appwrite/finance-queries.ts | 98 ++++++ src/lib/appwrite/finance-sync.ts | 85 +++++ src/lib/appwrite/finance-types.ts | 21 ++ src/lib/appwrite/job-actions.ts | 310 +++++++++++++++++- src/lib/appwrite/job-file-actions.ts | 218 ++++++++++++ src/lib/appwrite/job-file-queries.ts | 29 ++ src/lib/appwrite/job-file-types.ts | 20 ++ src/lib/appwrite/job-history-queries.ts | 20 ++ src/lib/appwrite/notification-actions.ts | 86 +++++ src/lib/appwrite/notification-helpers.ts | 80 +++++ src/middleware.ts | 1 + 24 files changed, 2066 insertions(+), 21 deletions(-) create mode 100644 src/app/(dashboard)/finance/components/finance-table.tsx create mode 100644 src/app/(dashboard)/jobs/[jobId]/components/job-actions-panel.tsx create mode 100644 src/app/(dashboard)/jobs/[jobId]/components/job-files-panel.tsx create mode 100644 src/app/(dashboard)/notifications/components/notifications-list.tsx create mode 100644 src/app/(dashboard)/notifications/page.tsx create mode 100644 src/lib/appwrite/finance-actions.ts create mode 100644 src/lib/appwrite/finance-queries.ts create mode 100644 src/lib/appwrite/finance-sync.ts create mode 100644 src/lib/appwrite/finance-types.ts create mode 100644 src/lib/appwrite/job-file-actions.ts create mode 100644 src/lib/appwrite/job-file-queries.ts create mode 100644 src/lib/appwrite/job-file-types.ts create mode 100644 src/lib/appwrite/job-history-queries.ts create mode 100644 src/lib/appwrite/notification-actions.ts create mode 100644 src/lib/appwrite/notification-helpers.ts diff --git a/next.config.ts b/next.config.ts index d752a04..2d92f7e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,7 +4,9 @@ const nextConfig: NextConfig = { experimental: { optimizePackageImports: ["lucide-react", "@radix-ui/react-icons"], serverActions: { - bodySizeLimit: "3mb", + // Job files bucket caps individual files at 30MB; raise overall body + // limit to allow batch uploads. + bodySizeLimit: "100mb", }, }, turbopack: {}, diff --git a/src/app/(dashboard)/dashboard-shell.tsx b/src/app/(dashboard)/dashboard-shell.tsx index 2c48f44..d8c4b45 100644 --- a/src/app/(dashboard)/dashboard-shell.tsx +++ b/src/app/(dashboard)/dashboard-shell.tsx @@ -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} /> - +
{children}
@@ -75,7 +77,7 @@ export function DashboardShell({ ) : ( <> - +
{children}
diff --git a/src/app/(dashboard)/finance/components/finance-table.tsx b/src/app/(dashboard)/finance/components/finance-table.tsx new file mode 100644 index 0000000..e12c0fe --- /dev/null +++ b/src/app/(dashboard)/finance/components/finance-table.tsx @@ -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 ( +

+ Henüz finansal hareket yok. İşler gönderildiğinde otomatik kayıt oluşur. +

+ ); + } + return ( + + + + Tarih + Tür + Karşı Taraf + Açıklama + Tutar + Durum + İşlem + + + + {rows.map((r) => ( + + ))} + +
+ ); +} + +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 ( + + + {dateFormatter.format(new Date(row.date))} + + + {FINANCE_TYPE_LABELS[row.type]} + + + {row.counterpart?.companyName ?? "—"} + + + {row.jobId ? ( + + {row.description ?? "—"} + + ) : ( + row.description ?? "—" + )} + + + {formatMoney(row.amount, row.currency || "TRY")} + + + {FINANCE_STATUS_LABELS[row.status]} + + + {row.status === "pending" ? ( +
{ + startTransition(() => paidAction(fd)); + }} + > + + +
+ ) : row.status === "paid" ? ( +
{ + startTransition(() => reopenAction(fd)); + }} + > + + +
+ ) : ( + + )} +
+
+ ); +} diff --git a/src/app/(dashboard)/finance/page.tsx b/src/app/(dashboard)/finance/page.tsx index b9adaf9..84b7f29 100644 --- a/src/app/(dashboard)/finance/page.tsx +++ b/src/app/(dashboard)/finance/page.tsx @@ -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 (

Finans

- Gelen ödemeler, ödenen hesaplar ve bekleyen tahsilatlar. + İş bazlı tahsilat ve ödeme akışı. {isLab ? "Alacaklarınız ve gelirleriniz." : "Ödenecek ve harcamalarınız."}

+ +
+ + + + +
+ - Yapım aşamasında - Finans hareketleri, durum takibi ve raporlar sonraki sürümde. + Hareketler + + Tamamlanan işlerden otomatik oluşturulan finansal kayıtlar. Manuel kayıt eklemek sonraki sürümde. + - + + +
); } + +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 ( + + + + {label} + + + +

{value}

+
+
+ ); +} diff --git a/src/app/(dashboard)/jobs/[jobId]/components/job-actions-panel.tsx b/src/app/(dashboard)/jobs/[jobId]/components/job-actions-panel.tsx new file mode 100644 index 0000000..1ab53fa --- /dev/null +++ b/src/app/(dashboard)/jobs/[jobId]/components/job-actions-panel.tsx @@ -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 ( +
+ {isLab && job.status === "pending" && } + {isLab && job.status === "in_progress" && } + {isClinic && job.status === "sent" && } + {(isClinic || isLab) && job.status === "pending" && ( + + )} +
+ ); +} + +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 ( +
{ + startTransition(() => action(fd)); + }} + > + + +
+ ); +} + +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 ( + + + + + + {isFinal ? "İşi gönderilmiş olarak işaretle" : `${currentLabel} tamamlandı`} + + + {isFinal + ? "İş artık 'Gönderildi' durumuna geçecek; klinik 'Teslim Aldım' onayını verecek." + : `Sonraki aşama: ${nextLabel}. İsterseniz bir not ekleyebilirsiniz.`} + + +
{ + startTransition(() => action(fd)); + }} + className="grid gap-3" + > + +
+ +