feat(ui): değer grafiği, net varlık kartı, dashboard kartları ve ödeme diyaloğunda varlık bağı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,36 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { AssetDetailView } from "@/features/assets/components/asset-detail-view";
|
import { AssetDetailView } from "@/features/assets/components/asset-detail-view";
|
||||||
import { getAsset } from "@/features/assets/service";
|
import { PeriodReportView } from "@/features/assets/components/period-report";
|
||||||
|
import {
|
||||||
|
getAsset,
|
||||||
|
getPeriodReport,
|
||||||
|
REPORT_RANGES,
|
||||||
|
type ReportRange,
|
||||||
|
} from "@/features/assets/service";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
import { requireActiveHousehold } from "@/lib/dal";
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: tr.assets.history };
|
export const metadata: Metadata = { title: tr.assets.history };
|
||||||
|
|
||||||
/** /birikim/:id — varlık hareketleri (gizli varlık yalnızca sahibine; aksi 404). */
|
/** /birikim/:id — varlık hareketleri ve dönemsel rapor (gizli varlık yalnızca sahibine; aksi 404). */
|
||||||
export default async function AssetPage({ params }: PageProps<"/birikim/[id]">) {
|
export default async function AssetPage({ params, searchParams }: PageProps<"/birikim/[id]">) {
|
||||||
const { user, household } = await requireActiveHousehold("viewer");
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const data = await getAsset(user.id, household.id, id);
|
const donem = (await searchParams).donem;
|
||||||
if (!data) notFound();
|
const range: ReportRange = REPORT_RANGES.includes(donem as ReportRange)
|
||||||
return <AssetDetailView key={id} data={data} />;
|
? (donem as ReportRange)
|
||||||
|
: "6";
|
||||||
|
const [data, report] = await Promise.all([
|
||||||
|
getAsset(user.id, household.id, id),
|
||||||
|
getPeriodReport(user.id, household.id, range, id),
|
||||||
|
]);
|
||||||
|
if (!data || !report) notFound();
|
||||||
|
return (
|
||||||
|
<AssetDetailView
|
||||||
|
key={id}
|
||||||
|
data={data}
|
||||||
|
report={<PeriodReportView report={report} range={range} basePath={`/birikim/${id}`} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,21 +2,45 @@ import type { Metadata } from "next";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { AssetsView } from "@/features/assets/components/assets-view";
|
import { AssetsView } from "@/features/assets/components/assets-view";
|
||||||
import { listAssets } from "@/features/assets/service";
|
import { PeriodReportView } from "@/features/assets/components/period-report";
|
||||||
|
import {
|
||||||
|
getPeriodReport,
|
||||||
|
listAssets,
|
||||||
|
REPORT_RANGES,
|
||||||
|
type ReportRange,
|
||||||
|
} from "@/features/assets/service";
|
||||||
|
import { NetWorthCard } from "@/features/net-worth/components/net-worth-card";
|
||||||
|
import { getNetWorth } from "@/features/net-worth/service";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
import { requireActiveHousehold } from "@/lib/dal";
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: tr.assets.title };
|
export const metadata: Metadata = { title: tr.assets.title };
|
||||||
|
|
||||||
/** /birikim — altın, döviz ve ziynet varlıkları; ortalama maliyet ve kâr/zarar. */
|
function parseRange(value: string | string[] | undefined): ReportRange {
|
||||||
export default async function AssetsPage() {
|
return REPORT_RANGES.includes(value as ReportRange) ? (value as ReportRange) : "6";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /birikim — net varlık, varlıklar (ortalama maliyet, K/Z) ve dönemsel rapor. */
|
||||||
|
export default async function AssetsPage({ searchParams }: PageProps<"/birikim">) {
|
||||||
const { user, household } = await requireActiveHousehold("viewer");
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
const data = await listAssets(user.id, household.id);
|
const range = parseRange((await searchParams).donem);
|
||||||
if (!data) notFound();
|
const [data, report, netWorth] = await Promise.all([
|
||||||
|
listAssets(user.id, household.id),
|
||||||
|
getPeriodReport(user.id, household.id, range),
|
||||||
|
getNetWorth(user.id, household.id),
|
||||||
|
]);
|
||||||
|
if (!data || !report || !netWorth) notFound();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title={tr.assets.title} description={tr.assets.description} />
|
<PageHeader title={tr.assets.title} description={tr.assets.description} />
|
||||||
|
<section aria-labelledby="net-worth-title" className="flex flex-col gap-2">
|
||||||
|
<h2 id="net-worth-title" className="text-base font-medium">
|
||||||
|
{tr.assets.netWorth.title}
|
||||||
|
</h2>
|
||||||
|
<NetWorthCard data={netWorth} />
|
||||||
|
</section>
|
||||||
<AssetsView items={data.items} totals={data.totals} canWrite={data.canWrite} />
|
<AssetsView items={data.items} totals={data.totals} canWrite={data.canWrite} />
|
||||||
|
<PeriodReportView report={report} range={range} basePath="/birikim" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
|
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
|
||||||
import { ContactsDueBanner } from "@/features/contacts/components/due-banner";
|
import { ContactsDueBanner } from "@/features/contacts/components/due-banner";
|
||||||
|
import { getNetWorth } from "@/features/net-worth/service";
|
||||||
import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
|
import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
import { requireActiveHousehold } from "@/lib/dal";
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
@@ -16,22 +18,27 @@ type SummaryCard = {
|
|||||||
key: keyof typeof tr.dashboard.cards;
|
key: keyof typeof tr.dashboard.cards;
|
||||||
tone?: string;
|
tone?: string;
|
||||||
amountMinor: number | null;
|
amountMinor: number | null;
|
||||||
|
href?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** FAZ 14: gerçek özet verisi (gelir, gider, bakiye, kart borcu, net varlık). */
|
/**
|
||||||
async function getSummary(): Promise<SummaryCard[]> {
|
* Özet kartları. Bakiye, kart borcu ve net varlık gerçek (Faz 9); bu ayın gelir/gider/kalan
|
||||||
|
* kartları Faz 14'te dolar.
|
||||||
|
*/
|
||||||
|
async function getSummary(userId: string, householdId: string): Promise<SummaryCard[]> {
|
||||||
|
const nw = await getNetWorth(userId, householdId);
|
||||||
return [
|
return [
|
||||||
{ key: "monthIncome", tone: "text-income", amountMinor: null },
|
{ key: "monthIncome", tone: "text-income", amountMinor: null },
|
||||||
{ key: "monthExpense", tone: "text-expense", amountMinor: null },
|
{ key: "monthExpense", tone: "text-expense", amountMinor: null },
|
||||||
{ key: "monthRemaining", tone: "text-budget", amountMinor: null },
|
{ key: "monthRemaining", tone: "text-budget", amountMinor: null },
|
||||||
{ key: "totalBalance", amountMinor: null },
|
{ key: "totalBalance", amountMinor: nw?.accountsMinor ?? null, href: "/hesaplar" },
|
||||||
{ key: "cardDebt", tone: "text-expense", amountMinor: null },
|
{ key: "cardDebt", tone: "text-expense", amountMinor: nw?.cardDebtMinor ?? null },
|
||||||
{ key: "netWorth", amountMinor: null },
|
{ key: "netWorth", amountMinor: nw?.totalMinor ?? null, href: "/birikim" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function SummaryCards() {
|
async function SummaryCards({ userId, householdId }: { userId: string; householdId: string }) {
|
||||||
const cards = await getSummary();
|
const cards = await getSummary(userId, householdId);
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
{cards.map((c) => (
|
{cards.map((c) => (
|
||||||
@@ -39,7 +46,15 @@ async function SummaryCards() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardDescription>{tr.dashboard.cards[c.key]}</CardDescription>
|
<CardDescription>{tr.dashboard.cards[c.key]}</CardDescription>
|
||||||
<CardTitle className={cn("text-lg tabular-nums", c.tone)}>
|
<CardTitle className={cn("text-lg tabular-nums", c.tone)}>
|
||||||
{c.amountMinor === null ? "—" : formatMoney(c.amountMinor)}
|
{c.amountMinor === null ? (
|
||||||
|
"—"
|
||||||
|
) : c.href ? (
|
||||||
|
<Link href={c.href} className="hover:underline">
|
||||||
|
{formatMoney(c.amountMinor)}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
formatMoney(c.amountMinor)
|
||||||
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{c.amountMinor === null ? (
|
{c.amountMinor === null ? (
|
||||||
@@ -68,7 +83,7 @@ export default async function DashboardPage() {
|
|||||||
<ContactsDueBanner userId={user.id} householdId={household.id} />
|
<ContactsDueBanner userId={user.id} householdId={household.id} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<Suspense fallback={<SummaryCardsSkeleton />}>
|
<Suspense fallback={<SummaryCardsSkeleton />}>
|
||||||
<SummaryCards />
|
<SummaryCards userId={user.id} householdId={household.id} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export type NavItem = {
|
|||||||
|
|
||||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
||||||
export const CURRENT_PHASE = 9.1;
|
export const CURRENT_PHASE = 9.2;
|
||||||
|
|
||||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import * as RechartsPrimitive from "recharts";
|
||||||
|
import type { TooltipValueType } from "recharts";
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: "", dark: ".dark" } as const;
|
||||||
|
|
||||||
|
const INITIAL_DIMENSION = { width: 320, height: 200 } as const;
|
||||||
|
type TooltipNameType = number | string;
|
||||||
|
|
||||||
|
export type ChartConfig = Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
label?: React.ReactNode;
|
||||||
|
icon?: React.ComponentType;
|
||||||
|
} & (
|
||||||
|
| { color?: string; theme?: never }
|
||||||
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
|
)
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useChart must be used within a <ChartContainer />");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartContainer({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
config,
|
||||||
|
initialDimension = INITIAL_DIMENSION,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
config: ChartConfig;
|
||||||
|
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
|
||||||
|
initialDimension?: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const uniqueId = React.useId();
|
||||||
|
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-slot="chart"
|
||||||
|
data-chart={chartId}
|
||||||
|
className={cn(
|
||||||
|
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer initialDimension={initialDimension}>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(([, config]) => config.theme ?? config.color);
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ?? itemConfig.color;
|
||||||
|
return color ? ` --color-${key}: ${color};` : null;
|
||||||
|
})
|
||||||
|
.join("\n")}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||||
|
|
||||||
|
function ChartTooltipContent({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = "dot",
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
hideLabel?: boolean;
|
||||||
|
hideIndicator?: boolean;
|
||||||
|
indicator?: "line" | "dot" | "dashed";
|
||||||
|
nameKey?: string;
|
||||||
|
labelKey?: string;
|
||||||
|
} & Omit<
|
||||||
|
RechartsPrimitive.DefaultTooltipContentProps<TooltipValueType, TooltipNameType>,
|
||||||
|
"accessibilityLayer"
|
||||||
|
>) {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload;
|
||||||
|
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === "string" ? (config[label]?.label ?? label) : itemConfig?.label;
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return (
|
||||||
|
<div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||||
|
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload
|
||||||
|
.filter((item) => item.type !== "none")
|
||||||
|
.map((item, index) => {
|
||||||
|
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const indicatorColor = color ?? item.payload?.fill ?? item.color;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||||
|
indicator === "dot" && "items-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||||
|
{
|
||||||
|
"h-2.5 w-2.5": indicator === "dot",
|
||||||
|
"w-1": indicator === "line",
|
||||||
|
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||||
|
indicator === "dashed",
|
||||||
|
"my-0.5": nestLabel && indicator === "dashed",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--color-bg": indicatorColor,
|
||||||
|
"--color-border": indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 justify-between leading-none",
|
||||||
|
nestLabel ? "items-end" : "items-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{itemConfig?.label ?? item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.value != null && (
|
||||||
|
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||||
|
{typeof item.value === "number"
|
||||||
|
? item.value.toLocaleString()
|
||||||
|
: String(item.value)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend;
|
||||||
|
|
||||||
|
function ChartLegendContent({
|
||||||
|
className,
|
||||||
|
hideIcon = false,
|
||||||
|
payload,
|
||||||
|
verticalAlign = "bottom",
|
||||||
|
nameKey,
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
hideIcon?: boolean;
|
||||||
|
nameKey?: string;
|
||||||
|
} & RechartsPrimitive.DefaultLegendContentProps) {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-4",
|
||||||
|
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload
|
||||||
|
.filter((item) => item.type !== "none")
|
||||||
|
.map((item, index) => {
|
||||||
|
const key = `${nameKey ?? item.dataKey ?? "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let configLabelKey: string = key;
|
||||||
|
|
||||||
|
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||||
|
} else if (
|
||||||
|
payloadPayload &&
|
||||||
|
key in payloadPayload &&
|
||||||
|
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config ? config[configLabelKey] : config[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartStyle,
|
||||||
|
};
|
||||||
@@ -54,7 +54,14 @@ type Pending =
|
|||||||
| null;
|
| null;
|
||||||
|
|
||||||
/** /birikim/:id — özet, hareket ekleme ve hareket geçmişi. */
|
/** /birikim/:id — özet, hareket ekleme ve hareket geçmişi. */
|
||||||
export function AssetDetailView({ data }: { data: AssetDetail }) {
|
export function AssetDetailView({
|
||||||
|
data,
|
||||||
|
report,
|
||||||
|
}: {
|
||||||
|
data: AssetDetail;
|
||||||
|
/** Dönemsel rapor (sunucuda hazırlanır). */
|
||||||
|
report?: React.ReactNode;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [pending, setPending] = useState<Pending>(null);
|
const [pending, setPending] = useState<Pending>(null);
|
||||||
const close = () => setPending(null);
|
const close = () => setPending(null);
|
||||||
@@ -171,6 +178,8 @@ export function AssetDetailView({ data }: { data: AssetDetail }) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{report}
|
||||||
|
|
||||||
<section className="flex flex-col gap-2" aria-labelledby="movements-title">
|
<section className="flex flex-col gap-2" aria-labelledby="movements-title">
|
||||||
<h2 id="movements-title" className="text-base font-medium">
|
<h2 id="movements-title" className="text-base font-medium">
|
||||||
{t.history}
|
{t.history}
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from "@/components/ui/chart";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
|
import type { PeriodReport } from "@/shared/portfolio";
|
||||||
|
import type { ReportRange } from "../service";
|
||||||
|
import { ProfitLoss, Stat } from "./assets-view";
|
||||||
|
|
||||||
|
const t = tr.assets.report;
|
||||||
|
const RANGES: ReportRange[] = ["3", "6", "12", "tum"];
|
||||||
|
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||||
|
const tickFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
timeZone: "Europe/Istanbul",
|
||||||
|
});
|
||||||
|
const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 });
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
value: { label: t.value, color: "var(--budget)" },
|
||||||
|
cost: { label: t.cost, color: "var(--muted-foreground)" },
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
/** Dönem seçici + özet + aylık değer/maliyet grafiği + tablo görünümü. */
|
||||||
|
export function PeriodReportView({
|
||||||
|
report,
|
||||||
|
range,
|
||||||
|
basePath,
|
||||||
|
}: {
|
||||||
|
report: PeriodReport & { excluded: number };
|
||||||
|
range: ReportRange;
|
||||||
|
basePath: string;
|
||||||
|
}) {
|
||||||
|
const data = report.points.map((p) => ({
|
||||||
|
date: p.date,
|
||||||
|
label: tickFmt.format(new Date(`${p.date}T12:00:00+03:00`)),
|
||||||
|
// Kuruş → TL (eksen ve ipucu TL gösterir).
|
||||||
|
value: p.valueMinor === null ? null : p.valueMinor / 100,
|
||||||
|
cost: p.costMinor / 100,
|
||||||
|
}));
|
||||||
|
const hasData = report.points.some((p) => p.costMinor > 0 || (p.valueMinor ?? 0) > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">{t.title}</CardTitle>
|
||||||
|
<CardDescription>{t.period(d(report.from), d(report.to))}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<nav aria-label={t.rangeLabel} className="flex rounded-lg bg-muted p-0.5 text-sm">
|
||||||
|
{RANGES.map((r) => (
|
||||||
|
<Link
|
||||||
|
key={r}
|
||||||
|
href={`${basePath}?donem=${r}`}
|
||||||
|
scroll={false}
|
||||||
|
aria-current={r === range ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-2.5 py-1 text-muted-foreground transition-colors",
|
||||||
|
r === range && "bg-background font-medium text-foreground shadow-sm",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.ranges[r]}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<Stat label={t.startValue}>
|
||||||
|
{report.startValueMinor !== null ? formatMoney(report.startValueMinor) : t.noPrice}
|
||||||
|
</Stat>
|
||||||
|
<Stat label={t.invested}>{formatMoney(report.investedMinor)}</Stat>
|
||||||
|
<Stat label={t.endValue}>
|
||||||
|
{report.endValueMinor !== null ? formatMoney(report.endValueMinor) : t.noPrice}
|
||||||
|
</Stat>
|
||||||
|
<Stat label={t.profit}>
|
||||||
|
<ProfitLoss minor={report.profitMinor} bp={report.profitBp} />
|
||||||
|
</Stat>
|
||||||
|
{report.proceedsMinor ? (
|
||||||
|
<Stat label={t.proceeds}>{formatMoney(report.proceedsMinor)}</Stat>
|
||||||
|
) : null}
|
||||||
|
{report.giftInMinor ? (
|
||||||
|
<Stat label={t.giftIn}>{formatMoney(report.giftInMinor)}</Stat>
|
||||||
|
) : null}
|
||||||
|
{report.giftOutMinor ? (
|
||||||
|
<Stat label={t.giftOut}>{formatMoney(report.giftOutMinor)}</Stat>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t.formula}</p>
|
||||||
|
{report.excluded ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{t.excluded(report.excluded)}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasData ? (
|
||||||
|
<>
|
||||||
|
<ChartContainer
|
||||||
|
config={config}
|
||||||
|
className="aspect-auto h-56 w-full sm:h-64"
|
||||||
|
aria-label={t.chartLabel}
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
|
<LineChart data={data} margin={{ left: 4, right: 12, top: 8 }}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
minTickGap={16}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={48}
|
||||||
|
tickFormatter={(v: number) => compact.format(v)}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
cursor
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(_, payload) => {
|
||||||
|
const date = payload?.[0]?.payload?.date as string | undefined;
|
||||||
|
return date ? d(date) : "";
|
||||||
|
}}
|
||||||
|
formatter={(value, name) => (
|
||||||
|
<div className="flex w-full items-center justify-between gap-3">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{config[name as keyof typeof config]?.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-foreground tabular-nums">
|
||||||
|
{formatMoney(Math.round(Number(value) * 100))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
<Line
|
||||||
|
dataKey="cost"
|
||||||
|
type="stepAfter"
|
||||||
|
stroke="var(--color-cost)"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="value"
|
||||||
|
type="linear"
|
||||||
|
stroke="var(--color-value)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
connectNulls={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
<details className="text-sm">
|
||||||
|
<summary className="cursor-pointer text-xs text-muted-foreground">{t.table}</summary>
|
||||||
|
<table className="mt-2 w-full text-right tabular-nums">
|
||||||
|
<thead className="text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="py-1 text-left font-normal">{t.date}</th>
|
||||||
|
<th className="py-1 font-normal">{t.value}</th>
|
||||||
|
<th className="py-1 font-normal">{t.cost}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{report.points.map((p) => (
|
||||||
|
<tr key={p.date}>
|
||||||
|
<td className="py-1 text-left">{d(p.date)}</td>
|
||||||
|
<td className="py-1">
|
||||||
|
{p.valueMinor !== null ? formatMoney(p.valueMinor) : t.noPrice}
|
||||||
|
</td>
|
||||||
|
<td className="py-1">{formatMoney(p.costMinor)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.empty}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { getAssetOptionsAction, type AssetOption } from "@/features/assets/actions";
|
||||||
import { getTransactionFormOptionsAction } from "@/features/transactions/actions";
|
import { getTransactionFormOptionsAction } from "@/features/transactions/actions";
|
||||||
import { AccountSelect } from "@/features/transactions/components/transaction-form";
|
import { AccountSelect } from "@/features/transactions/components/transaction-form";
|
||||||
import type { TransactionFormOptions } from "@/features/transactions/service";
|
import type { TransactionFormOptions } from "@/features/transactions/service";
|
||||||
@@ -79,6 +81,21 @@ export function useAccounts() {
|
|||||||
return options?.accounts ?? null;
|
return options?.accounts ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Kaydın varlık türündeki birikim varlıkları (ödeme diyaloğu açıldığında). */
|
||||||
|
function useAssetOptions(assetKey: AssetKey) {
|
||||||
|
const [options, setOptions] = useState<AssetOption[] | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
getAssetOptionsAction().then((r) => {
|
||||||
|
if (!cancelled && r.ok) setOptions(r.data.filter((a) => a.assetKey === assetKey));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [assetKey]);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) {
|
function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -602,16 +619,23 @@ function PaymentForm({ item, onClose }: { item: InKindView; onClose: () => void
|
|||||||
});
|
});
|
||||||
const [errors, setErrors] = useState<Errors>({});
|
const [errors, setErrors] = useState<Errors>({});
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const assets = useAssetOptions(item.assetKey);
|
||||||
|
const [link, setLink] = useState(false);
|
||||||
|
const [assetId, setAssetId] = useState("");
|
||||||
|
const linked = method === "asset" && link;
|
||||||
|
const linkAssetId = assetId || assets?.[0]?.id || "";
|
||||||
|
|
||||||
async function submit(e: React.FormEvent) {
|
async function submit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
const amount = linked ? "" : v.amount;
|
||||||
const r = await recordPaymentAction(item.id, {
|
const r = await recordPaymentAction(item.id, {
|
||||||
method,
|
method,
|
||||||
date: v.date,
|
date: v.date,
|
||||||
quantity: method === "cash" ? "" : v.quantity,
|
quantity: method === "cash" ? "" : v.quantity,
|
||||||
amount: v.amount,
|
amount,
|
||||||
accountId: v.amount ? v.accountId || accounts?.[0]?.id || "" : "",
|
accountId: amount ? v.accountId || accounts?.[0]?.id || "" : "",
|
||||||
|
assetId: linked ? linkAssetId : "",
|
||||||
note: v.note,
|
note: v.note,
|
||||||
});
|
});
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -659,17 +683,19 @@ function PaymentForm({ item, onClose }: { item: InKindView; onClose: () => void
|
|||||||
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
||||||
</Field>
|
</Field>
|
||||||
) : null}
|
) : null}
|
||||||
<Field data-invalid={Boolean(errors.amount)}>
|
{linked || (method === "asset" && !debt) ? null : (
|
||||||
<FieldLabel htmlFor="pay-amount">
|
<Field data-invalid={Boolean(errors.amount)}>
|
||||||
{method === "cash" ? f.amount : f.assetAmount}
|
<FieldLabel htmlFor="pay-amount">
|
||||||
</FieldLabel>
|
{method === "cash" ? f.amount : f.assetAmount}
|
||||||
<MoneyInput
|
</FieldLabel>
|
||||||
id="pay-amount"
|
<MoneyInput
|
||||||
value={v.amount}
|
id="pay-amount"
|
||||||
onChange={(x) => setV((s) => ({ ...s, amount: x }))}
|
value={v.amount}
|
||||||
/>
|
onChange={(x) => setV((s) => ({ ...s, amount: x }))}
|
||||||
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
/>
|
||||||
</Field>
|
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="pay-date">{tr.transactions.fields.date}</FieldLabel>
|
<FieldLabel htmlFor="pay-date">{tr.transactions.fields.date}</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
@@ -684,7 +710,49 @@ function PaymentForm({ item, onClose }: { item: InKindView; onClose: () => void
|
|||||||
{method === "cash" ? (
|
{method === "cash" ? (
|
||||||
<FieldDescription className="-mt-2">{f.quantityHint}</FieldDescription>
|
<FieldDescription className="-mt-2">{f.quantityHint}</FieldDescription>
|
||||||
) : null}
|
) : null}
|
||||||
{method === "cash" || v.amount ? (
|
{method === "asset" ? (
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg border p-3">
|
||||||
|
<label className="flex items-start gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={link}
|
||||||
|
onCheckedChange={(x) => setLink(x === true)}
|
||||||
|
disabled={!assets?.length}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{debt ? t.link.debt : t.link.receivable}
|
||||||
|
<span className="block text-xs text-muted-foreground">
|
||||||
|
{assets && !assets.length
|
||||||
|
? t.link.none
|
||||||
|
: debt
|
||||||
|
? t.link.debtHint
|
||||||
|
: t.link.receivableHint}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{link && assets?.length ? (
|
||||||
|
<Field data-invalid={Boolean(errors.assetId)}>
|
||||||
|
<FieldLabel htmlFor="pay-asset" className="sr-only">
|
||||||
|
{t.link.asset}
|
||||||
|
</FieldLabel>
|
||||||
|
<Select value={linkAssetId} onValueChange={setAssetId}>
|
||||||
|
<SelectTrigger id="pay-asset" className="w-full">
|
||||||
|
<SelectValue>{assets.find((a) => a.id === linkAssetId)?.name}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{assets.map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>
|
||||||
|
{a.name} · {formatAssetQuantity(item.assetKey, a.quantity)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.assetId ? <FieldError>{errors.assetId}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{(method === "cash" || v.amount) && !linked ? (
|
||||||
<Field data-invalid={Boolean(errors.accountId)}>
|
<Field data-invalid={Boolean(errors.accountId)}>
|
||||||
<FieldLabel htmlFor="pay-account">{f.account}</FieldLabel>
|
<FieldLabel htmlFor="pay-account">{f.account}</FieldLabel>
|
||||||
<AccountSelect
|
<AccountSelect
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import type { NetWorth } from "../service";
|
||||||
|
|
||||||
|
const t = tr.assets.netWorth;
|
||||||
|
|
||||||
|
/** Net varlık dökümü: artılar ve eksiler, altta toplam. */
|
||||||
|
export function NetWorthCard({ data }: { data: NetWorth }) {
|
||||||
|
const rows: { label: string; minor: number; sign: 1 | -1 }[] = [
|
||||||
|
{ label: t.accounts, minor: data.accountsMinor, sign: 1 },
|
||||||
|
{ label: t.assets, minor: data.assetsMinor, sign: 1 },
|
||||||
|
{ label: t.contactReceivable, minor: data.contactReceivableMinor, sign: 1 },
|
||||||
|
{ label: t.inKindReceivable, minor: data.inKindReceivableMinor, sign: 1 },
|
||||||
|
{ label: t.cardDebt, minor: data.cardDebtMinor, sign: -1 },
|
||||||
|
{ label: t.contactPayable, minor: data.contactPayableMinor, sign: -1 },
|
||||||
|
{ label: t.inKindPayable, minor: data.inKindPayableMinor, sign: -1 },
|
||||||
|
];
|
||||||
|
const plus = rows.filter((r) => r.sign === 1);
|
||||||
|
const minus = rows.filter((r) => r.sign === -1);
|
||||||
|
const list = (items: typeof rows) => (
|
||||||
|
<dl className="flex flex-col text-sm">
|
||||||
|
{items.map((r) => (
|
||||||
|
<div key={r.label} className="flex items-center justify-between gap-2 py-1">
|
||||||
|
<dt className="text-muted-foreground">{r.label}</dt>
|
||||||
|
<dd className="tabular-nums">
|
||||||
|
{r.sign < 0 && r.minor ? "−" : ""}
|
||||||
|
{formatMoney(r.minor)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-3xl font-semibold tracking-tight tabular-nums",
|
||||||
|
data.totalMinor < 0 && "text-expense",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatMoney(data.totalMinor)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{t.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-x-8 gap-y-1 border-t pt-2 sm:grid-cols-2">
|
||||||
|
{list(plus)}
|
||||||
|
{list(minus)}
|
||||||
|
</div>
|
||||||
|
{data.missingPrice ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{t.missingPrice(data.missingPrice)}</p>
|
||||||
|
) : null}
|
||||||
|
{data.includesHidden ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{t.includesHidden}</p>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -639,6 +639,19 @@ export const tr = {
|
|||||||
price: "Alış veya satış fiyatı girin",
|
price: "Alış veya satış fiyatı girin",
|
||||||
amount: "Tutar girin",
|
amount: "Tutar girin",
|
||||||
account: "Hesap seçin",
|
account: "Hesap seçin",
|
||||||
|
assetOnlyWithAsset: "Varlık bağlantısı yalnızca aynı varlıkla ödemede kullanılır.",
|
||||||
|
noAmountWithAsset: "Varlıktan ödemede TL tutar girilmez.",
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
debt: "Birikimdeki varlıktan düş",
|
||||||
|
receivable: "Birikimdeki varlığa ekle",
|
||||||
|
debtHint: "Ödenen miktar seçilen varlıktan ortalama maliyetle çıkar.",
|
||||||
|
receivableHint:
|
||||||
|
"Tahsil edilen miktar seçilen varlığa eklenir; maliyet borç tarihindeki birim fiyattır.",
|
||||||
|
asset: "Varlık",
|
||||||
|
none: "Bu türde varlık yok; önce Birikim sayfasından ekleyin.",
|
||||||
|
movementNote: (name: string) => `Aynî kayıt: ${name}`,
|
||||||
|
assetMismatch: "Seçilen varlık bu kaydın varlık türünde değil.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
assets: {
|
assets: {
|
||||||
@@ -713,6 +726,43 @@ export const tr = {
|
|||||||
},
|
},
|
||||||
history: "Hareketler",
|
history: "Hareketler",
|
||||||
noMovements: "Henüz hareket yok.",
|
noMovements: "Henüz hareket yok.",
|
||||||
|
report: {
|
||||||
|
title: "Dönemsel rapor",
|
||||||
|
ranges: { "3": "3 ay", "6": "6 ay", "12": "12 ay", tum: "Tümü" },
|
||||||
|
rangeLabel: "Dönem",
|
||||||
|
period: (from: string, to: string) => `${from} – ${to}`,
|
||||||
|
startValue: "Dönem başı değer",
|
||||||
|
invested: "Yatırılan",
|
||||||
|
endValue: "Dönem sonu değer",
|
||||||
|
profit: "Dönem kâr/zararı",
|
||||||
|
proceeds: "Satış geliri",
|
||||||
|
giftIn: "Gelen hediye",
|
||||||
|
giftOut: "Çıkan hediye",
|
||||||
|
formula:
|
||||||
|
"Kâr/zarar = dönem sonu değer + satış + çıkan hediye − dönem başı değer − yatırılan − gelen hediye.",
|
||||||
|
value: "Güncel değer",
|
||||||
|
cost: "Maliyet",
|
||||||
|
chartLabel: "Aylık değer ve maliyet",
|
||||||
|
table: "Tablo olarak göster",
|
||||||
|
date: "Tarih",
|
||||||
|
noPrice: "Fiyat yok",
|
||||||
|
excluded: (n: number) => `${n} varlık fiyatı olmadığı için rapora katılmadı.`,
|
||||||
|
empty: "Bu dönemde gösterilecek varlık yok.",
|
||||||
|
},
|
||||||
|
netWorth: {
|
||||||
|
title: "Net varlık",
|
||||||
|
description: "Hesaplar + birikim + alacaklar − borçlar (TL ve aynî).",
|
||||||
|
accounts: "Hesap bakiyeleri",
|
||||||
|
cardDebt: "Kart ve KMH borçları",
|
||||||
|
assets: "Birikim (altın, döviz)",
|
||||||
|
contactReceivable: "Cari alacaklar",
|
||||||
|
contactPayable: "Cari borçlar",
|
||||||
|
inKindReceivable: "Aynî alacaklar",
|
||||||
|
inKindPayable: "Aynî borçlar",
|
||||||
|
total: "Net varlık",
|
||||||
|
missingPrice: (n: number) => `${n} kalemin fiyatı yok; toplama katılmadı.`,
|
||||||
|
includesHidden: "Hane ayarı gereği diğer üyelerin gizli hesap bakiyeleri dahil.",
|
||||||
|
},
|
||||||
table: {
|
table: {
|
||||||
date: "Tarih",
|
date: "Tarih",
|
||||||
kind: "Hareket",
|
kind: "Hareket",
|
||||||
|
|||||||
Reference in New Issue
Block a user