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" + > + +
+ +