feat(ui): aynî kayıt kartları, ödeme ve birikim diyalogları, fiyat tablosu sayfası
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ContactStatementView } from "@/features/contacts/components/contact-statement-view";
|
||||
import { getContactStatement } from "@/features/contacts/service";
|
||||
import { listInKind } from "@/features/in-kind/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
@@ -11,7 +12,10 @@ export const metadata: Metadata = { title: tr.contacts.statement };
|
||||
export default async function ContactPage({ params }: PageProps<"/cari/[id]">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const { id } = await params;
|
||||
const data = await getContactStatement(user.id, household.id, id);
|
||||
const [data, inKind] = await Promise.all([
|
||||
getContactStatement(user.id, household.id, id),
|
||||
listInKind(user.id, household.id, id),
|
||||
]);
|
||||
if (!data) notFound();
|
||||
return <ContactStatementView key={id} data={data} />;
|
||||
return <ContactStatementView key={id} data={data} inKind={inKind ?? []} />;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { ContactsView } from "@/features/contacts/components/contacts-view";
|
||||
import { listContacts } from "@/features/contacts/service";
|
||||
import { openInKindByContact } from "@/features/in-kind/service";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
@@ -11,12 +12,19 @@ export const metadata: Metadata = { title: tr.contacts.title };
|
||||
|
||||
export default async function ContactsPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const data = await listContacts(user.id, household.id);
|
||||
const [data, inKind] = await Promise.all([
|
||||
listContacts(user.id, household.id),
|
||||
openInKindByContact(user.id, household.id),
|
||||
]);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.contacts.title} description={tr.contacts.description} />
|
||||
<ContactsView data={data} canWrite={hasHouseholdRole(household.role, "member")} />
|
||||
<ContactsView
|
||||
data={data}
|
||||
inKind={Object.fromEntries(inKind)}
|
||||
canWrite={hasHouseholdRole(household.role, "member")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { PricesView } from "@/features/in-kind/components/prices-view";
|
||||
import { listPrices } from "@/features/in-kind/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.prices.title };
|
||||
|
||||
/** /fiyatlar — altın ve döviz fiyat tablosu (elle; Faz 9 varlık takibi de kullanır). */
|
||||
export default async function PricesPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const data = await listPrices(user.id, household.id);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.prices.title} description={tr.prices.description} />
|
||||
<PricesView rows={data.rows} canWrite={data.canWrite} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export type NavItem = {
|
||||
|
||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
||||
export const CURRENT_PHASE = 8.1;
|
||||
export const CURRENT_PHASE = 8.2;
|
||||
|
||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDate, formatMoney } from "@/shared/format";
|
||||
import { InKindSection } from "@/features/in-kind/components/in-kind-section";
|
||||
import type { InKindView } from "@/features/in-kind/service";
|
||||
import type { ContactStatement } from "../service";
|
||||
import { ContactEntryDialog, ContactFormDialog, type EntryMode } from "./contact-dialogs";
|
||||
import { ContactBalance, DueBadge } from "./contacts-view";
|
||||
@@ -31,7 +33,13 @@ function Delta({ minor }: { minor: number }) {
|
||||
}
|
||||
|
||||
/** Cari ekstresi: hareketler (yeniden eskiye) ve yürüyen bakiye (+ bana borçlu, − ben borçluyum). */
|
||||
export function ContactStatementView({ data }: { data: ContactStatement }) {
|
||||
export function ContactStatementView({
|
||||
data,
|
||||
inKind,
|
||||
}: {
|
||||
data: ContactStatement;
|
||||
inKind: InKindView[];
|
||||
}) {
|
||||
const c = data.contact;
|
||||
const [entry, setEntry] = useState<EntryMode | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -90,6 +98,8 @@ export function ContactStatementView({ data }: { data: ContactStatement }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<InKindSection contactId={c.id} items={inKind} canWrite={canWrite} />
|
||||
|
||||
<h2 className="text-base font-medium">{t.statement}</h2>
|
||||
{data.rows.length === 0 && c.openingBalanceMinor === 0 ? (
|
||||
<Card>
|
||||
|
||||
@@ -60,7 +60,16 @@ export function DueBadge({ contact }: { contact: ContactView }) {
|
||||
return <Badge variant="outline">{t.due.on(d(contact.dueDate))}</Badge>;
|
||||
}
|
||||
|
||||
export function ContactsView({ data, canWrite }: { data: ContactList; canWrite: boolean }) {
|
||||
export function ContactsView({
|
||||
data,
|
||||
inKind,
|
||||
canWrite,
|
||||
}: {
|
||||
data: ContactList;
|
||||
/** Açık aynî kayıtlar (cari kimliği → yön ve kalan miktar). */
|
||||
inKind: Record<string, { direction: "debt" | "receivable"; label: string }[]>;
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const [form, setForm] = useState<{ contact: ContactView | null } | null>(null);
|
||||
const [entry, setEntry] = useState<EntryMode | null>(null);
|
||||
const [archivedOpen, setArchivedOpen] = useState(false);
|
||||
@@ -94,6 +103,11 @@ export function ContactsView({ data, canWrite }: { data: ContactList; canWrite:
|
||||
<span>{t.relations[c.relation]}</span>
|
||||
{c.lastActivity ? <span>· {t.lastActivity(d(c.lastActivity))}</span> : null}
|
||||
<DueBadge contact={c} />
|
||||
{(inKind[c.id] ?? []).map((x, i) => (
|
||||
<Badge key={i} variant={x.direction === "debt" ? "destructive" : "secondary"}>
|
||||
{x.label}
|
||||
</Badge>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<ContactBalance minor={c.balanceMinor} />
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
"use client";
|
||||
|
||||
import { Coins, EllipsisVertical, HandCoins, Pencil, PiggyBank, Plus, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { MoneyInput } from "@/components/money-input";
|
||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { getTransactionFormOptionsAction } from "@/features/transactions/actions";
|
||||
import { AccountSelect } from "@/features/transactions/components/transaction-form";
|
||||
import type { TransactionFormOptions } from "@/features/transactions/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ASSET_KEYS, ASSETS, formatAssetQuantity, type AssetKey } from "@/shared/assets";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { formatDate, formatMoney } from "@/shared/format";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
import { QUANTITY_SCALE } from "@/shared/quantity";
|
||||
import {
|
||||
addContributionAction,
|
||||
deleteInKindAction,
|
||||
recordPaymentAction,
|
||||
saveInKindAction,
|
||||
} from "../actions";
|
||||
import type { InKindView } from "../service";
|
||||
|
||||
const t = tr.inKind;
|
||||
type Errors = Record<string, string | undefined>;
|
||||
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||
const monthFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "Europe/Istanbul",
|
||||
});
|
||||
const qtyInput = (q: number) =>
|
||||
(q / QUANTITY_SCALE).toLocaleString("tr-TR", { maximumFractionDigits: 3, useGrouping: false });
|
||||
|
||||
/** Hesap seçenekleri (diyaloglar açıldığında). */
|
||||
function useAccounts() {
|
||||
const [options, setOptions] = useState<TransactionFormOptions | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getTransactionFormOptionsAction().then((r) => {
|
||||
if (!cancelled && r.ok) setOptions(r.data);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
return options?.accounts ?? null;
|
||||
}
|
||||
|
||||
function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className={cn("font-semibold tabular-nums", tone)}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Pending =
|
||||
| { kind: "form"; item: InKindView | null }
|
||||
| { kind: "contribute"; item: InKindView }
|
||||
| { kind: "pay"; item: InKindView }
|
||||
| { kind: "delete"; item: InKindView }
|
||||
| null;
|
||||
|
||||
/** Cari sayfasında aynî borç/alacak bölümü. */
|
||||
export function InKindSection({
|
||||
contactId,
|
||||
items,
|
||||
canWrite,
|
||||
}: {
|
||||
contactId: string;
|
||||
items: InKindView[];
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const [pending, setPending] = useState<Pending>(null);
|
||||
const close = () => setPending(null);
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3" aria-labelledby="in-kind-title">
|
||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||
<div>
|
||||
<h2 id="in-kind-title" className="text-base font-medium">
|
||||
{t.title}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">{t.description}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/fiyatlar">
|
||||
<Coins />
|
||||
{t.prices}
|
||||
</Link>
|
||||
</Button>
|
||||
{canWrite ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setPending({ kind: "form", item: null })}
|
||||
>
|
||||
<Plus />
|
||||
{t.new}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.map((item) => (
|
||||
<InKindCard key={item.id} item={item} canWrite={canWrite} onAction={setPending} />
|
||||
))}
|
||||
|
||||
{pending?.kind === "form" ? (
|
||||
<InKindForm contactId={contactId} item={pending.item} onClose={close} />
|
||||
) : null}
|
||||
{pending?.kind === "contribute" ? (
|
||||
<ContributionForm item={pending.item} onClose={close} />
|
||||
) : null}
|
||||
{pending?.kind === "pay" ? <PaymentForm item={pending.item} onClose={close} /> : null}
|
||||
<AlertDialog open={pending?.kind === "delete"} onOpenChange={(o) => !o && close()}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t.deleteTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t.deleteDescription}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
if (pending?.kind !== "delete") return;
|
||||
const r = await deleteInKindAction(pending.item.id);
|
||||
close();
|
||||
if (r.ok) toast.success(t.toast.deleted);
|
||||
else toast.error(r.error);
|
||||
}}
|
||||
>
|
||||
{t.delete}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function InKindCard({
|
||||
item,
|
||||
canWrite,
|
||||
onAction,
|
||||
}: {
|
||||
item: InKindView;
|
||||
canWrite: boolean;
|
||||
onAction: (p: Pending) => void;
|
||||
}) {
|
||||
const p = item.progress;
|
||||
const debt = item.direction === "debt";
|
||||
const open = !item.closedAt;
|
||||
return (
|
||||
<Card size="sm" className={cn(!open && "opacity-70")}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="flex flex-wrap items-center gap-2 text-base">
|
||||
{item.label}
|
||||
<Badge variant={debt ? "destructive" : "secondary"}>
|
||||
{t.directions[item.direction]}
|
||||
</Badge>
|
||||
{item.closedAt ? (
|
||||
<Badge variant="outline">{t.stats.closed(d(item.closedAt))}</Badge>
|
||||
) : null}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t.stats.dateValue(d(item.date))}:{" "}
|
||||
{item.valueMinor !== null ? formatMoney(item.valueMinor) : "—"}
|
||||
{item.unitPriceMinor !== null
|
||||
? ` · ${t.stats.priceAt(formatMoney(item.unitPriceMinor), d(item.date))}`
|
||||
: ""}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{canWrite ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon-sm" variant="ghost" aria-label={tr.recurring.actions.more}>
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => onAction({ kind: "form", item })}>
|
||||
<Pencil />
|
||||
{tr.common.edit}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => onAction({ kind: "delete", item })}
|
||||
>
|
||||
<Trash2 />
|
||||
{t.delete}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Stat
|
||||
label={t.stats.remaining}
|
||||
value={formatAssetQuantity(item.assetKey, p.remainingQuantity)}
|
||||
/>
|
||||
<Stat
|
||||
label={t.stats.currentValue}
|
||||
value={
|
||||
p.currentValueMinor !== null ? formatMoney(p.currentValueMinor) : t.stats.noPrice
|
||||
}
|
||||
tone={debt ? "text-expense" : "text-income"}
|
||||
/>
|
||||
{item.savingsAccount ? (
|
||||
<>
|
||||
<Stat
|
||||
label={`${t.stats.accumulated} · ${item.savingsAccount.name}`}
|
||||
value={formatMoney(p.accumulatedMinor)}
|
||||
tone="text-income"
|
||||
/>
|
||||
<Stat
|
||||
label={t.stats.remainingNeed}
|
||||
value={p.remainingNeedMinor !== null ? formatMoney(p.remainingNeedMinor) : "—"}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{p.estimatedCloseMonth && open ? (
|
||||
<p className="text-sm">
|
||||
{t.stats.estimatedClose}:{" "}
|
||||
<span className="font-medium capitalize">
|
||||
{monthFmt.format(new Date(`${p.estimatedCloseMonth}-15T12:00:00+03:00`))}
|
||||
</span>
|
||||
{item.monthlyPlanMinor ? (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
· {formatMoney(item.monthlyPlanMinor)}/ay
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
{item.currentPrice ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.stats.currentPrice(
|
||||
debt,
|
||||
formatMoney(debt ? item.currentPrice.sellMinor : item.currentPrice.buyMinor),
|
||||
d(item.currentPrice.date),
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
{item.note ? <p className="text-sm text-muted-foreground">{item.note}</p> : null}
|
||||
{item.payments.length ? (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">{t.payments}</p>
|
||||
<ul className="divide-y rounded-lg border text-sm">
|
||||
{item.payments.map((x) => (
|
||||
<li key={x.id} className="flex items-center justify-between gap-2 p-2">
|
||||
<span className="min-w-0 truncate">
|
||||
{d(x.date)} ·{" "}
|
||||
{t.paymentLine(
|
||||
t.methods[x.method],
|
||||
formatAssetQuantity(item.assetKey, x.quantity),
|
||||
x.amountMinor ? formatMoney(x.amountMinor) : null,
|
||||
)}
|
||||
</span>
|
||||
{x.account ? (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{x.account}</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
{canWrite && open ? (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{debt && item.savingsAccount ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onAction({ kind: "contribute", item })}
|
||||
>
|
||||
<PiggyBank />
|
||||
{t.contribute}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" onClick={() => onAction({ kind: "pay", item })}>
|
||||
<HandCoins />
|
||||
{t.pay}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Footer({ busy, formId, onClose }: { busy: boolean; formId: string; onClose: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={busy}>
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InKindForm({
|
||||
contactId,
|
||||
item,
|
||||
onClose,
|
||||
}: {
|
||||
contactId: string;
|
||||
item: InKindView | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const accounts = useAccounts();
|
||||
const [v, setV] = useState({
|
||||
direction: item?.direction ?? "debt",
|
||||
assetKey: (item?.assetKey ?? "gram_gold") as AssetKey,
|
||||
quantity: item ? qtyInput(item.quantity) : "",
|
||||
date: item?.date ?? todayIstanbul(),
|
||||
unitPrice: item?.unitPriceMinor ? formatMoneyInput(item.unitPriceMinor) : "",
|
||||
note: item?.note ?? "",
|
||||
savingsAccountId: item?.savingsAccount?.id ?? "",
|
||||
monthlyPlan: item?.monthlyPlanMinor ? formatMoneyInput(item.monthlyPlanMinor) : "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
|
||||
const NONE = "__none__";
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await saveInKindAction(contactId, item?.id ?? null, {
|
||||
...v,
|
||||
savingsAccountId: v.direction === "debt" ? v.savingsAccountId : "",
|
||||
monthlyPlan: v.direction === "debt" && v.savingsAccountId ? v.monthlyPlan : "",
|
||||
});
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.toast.saved);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
const f = t.fields;
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={item ? t.editTitle : t.createTitle}
|
||||
footer={<Footer busy={busy} formId="in-kind-form" onClose={onClose} />}
|
||||
>
|
||||
<form id="in-kind-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Tabs
|
||||
value={v.direction}
|
||||
onValueChange={(x) => set("direction", x as "debt" | "receivable")}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
{(["debt", "receivable"] as const).map((x) => (
|
||||
<TabsTrigger
|
||||
key={x}
|
||||
value={x}
|
||||
className="flex-1"
|
||||
disabled={Boolean(item?.payments.length)}
|
||||
>
|
||||
{t.directions[x]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="grid grid-cols-[1fr_8rem] gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ik-asset">{f.asset}</FieldLabel>
|
||||
<Select
|
||||
value={v.assetKey}
|
||||
onValueChange={(x) => set("assetKey", x as AssetKey)}
|
||||
disabled={Boolean(item?.payments.length)}
|
||||
>
|
||||
<SelectTrigger id="ik-asset" className="w-full">
|
||||
<SelectValue>{ASSETS[v.assetKey].label}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ASSET_KEYS.map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{ASSETS[k].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.quantity)}>
|
||||
<FieldLabel htmlFor="ik-qty">
|
||||
{f.quantity} ({ASSETS[v.assetKey].unit})
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="ik-qty"
|
||||
inputMode="decimal"
|
||||
value={v.quantity}
|
||||
onChange={(e) => set("quantity", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.quantity)}
|
||||
/>
|
||||
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.date)}>
|
||||
<FieldLabel htmlFor="ik-date">{f.date}</FieldLabel>
|
||||
<Input
|
||||
id="ik-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
onChange={(e) => set("date", e.target.value)}
|
||||
/>
|
||||
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.unitPrice)}>
|
||||
<FieldLabel htmlFor="ik-price">{f.unitPrice}</FieldLabel>
|
||||
<MoneyInput id="ik-price" value={v.unitPrice} onChange={(x) => set("unitPrice", x)} />
|
||||
{errors.unitPrice ? <FieldError>{errors.unitPrice}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<FieldDescription className="-mt-2">{f.unitPriceHint}</FieldDescription>
|
||||
{v.direction === "debt" ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.savingsAccountId)}>
|
||||
<FieldLabel htmlFor="ik-savings">{f.savingsAccount}</FieldLabel>
|
||||
<Select
|
||||
value={v.savingsAccountId || NONE}
|
||||
onValueChange={(x) => set("savingsAccountId", x === NONE ? "" : x)}
|
||||
>
|
||||
<SelectTrigger id="ik-savings" className="w-full">
|
||||
<SelectValue>
|
||||
{accounts?.find((a) => a.id === v.savingsAccountId)?.name ??
|
||||
(v.savingsAccountId ? item?.savingsAccount?.name : f.savingsNone)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>{f.savingsNone}</SelectItem>
|
||||
{(accounts ?? [])
|
||||
.filter((a) => a.type !== "credit_card")
|
||||
.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.savingsAccountId ? (
|
||||
<FieldError>{errors.savingsAccountId}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ik-plan">{f.monthlyPlan}</FieldLabel>
|
||||
<MoneyInput
|
||||
id="ik-plan"
|
||||
value={v.monthlyPlan}
|
||||
onChange={(x) => set("monthlyPlan", x)}
|
||||
disabled={!v.savingsAccountId}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
{v.direction === "debt" ? (
|
||||
<FieldDescription className="-mt-2">{f.planHint}</FieldDescription>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ik-note">{f.note}</FieldLabel>
|
||||
<Input id="ik-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||||
</Field>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ContributionForm({ item, onClose }: { item: InKindView; onClose: () => void }) {
|
||||
const accounts = useAccounts();
|
||||
const [v, setV] = useState({
|
||||
fromAccountId: "",
|
||||
amount: item.monthlyPlanMinor ? formatMoneyInput(item.monthlyPlanMinor) : "",
|
||||
date: todayIstanbul(),
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const sources = (accounts ?? []).filter((a) => a.id !== item.savingsAccount?.id);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await addContributionAction(item.id, {
|
||||
...v,
|
||||
fromAccountId: v.fromAccountId || sources[0]?.id || "",
|
||||
});
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.toast.contributed);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={`${t.contributionTitle} · ${item.label}`}
|
||||
description={`→ ${item.savingsAccount?.name ?? ""}`}
|
||||
footer={<Footer busy={busy} formId="contribution-form" onClose={onClose} />}
|
||||
>
|
||||
<form id="contribution-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={Boolean(errors.fromAccountId)}>
|
||||
<FieldLabel htmlFor="ct-from">{t.fromAccount}</FieldLabel>
|
||||
<AccountSelect
|
||||
id="ct-from"
|
||||
value={v.fromAccountId || sources[0]?.id || ""}
|
||||
onChange={(x) => setV((s) => ({ ...s, fromAccountId: x }))}
|
||||
accounts={sources}
|
||||
/>
|
||||
{errors.fromAccountId ? <FieldError>{errors.fromAccountId}</FieldError> : null}
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.amount)}>
|
||||
<FieldLabel htmlFor="ct-amount">{tr.transactions.fields.amount}</FieldLabel>
|
||||
<MoneyInput
|
||||
id="ct-amount"
|
||||
value={v.amount}
|
||||
onChange={(x) => setV((s) => ({ ...s, amount: x }))}
|
||||
/>
|
||||
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ct-date">{tr.transactions.fields.date}</FieldLabel>
|
||||
<Input
|
||||
id="ct-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
onChange={(e) => setV((s) => ({ ...s, date: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentForm({ item, onClose }: { item: InKindView; onClose: () => void }) {
|
||||
const accounts = useAccounts();
|
||||
const debt = item.direction === "debt";
|
||||
const [method, setMethod] = useState<"asset" | "cash">("cash");
|
||||
const [v, setV] = useState({
|
||||
date: todayIstanbul(),
|
||||
quantity: qtyInput(item.progress.remainingQuantity),
|
||||
amount: "",
|
||||
accountId: item.savingsAccount?.id ?? "",
|
||||
note: "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await recordPaymentAction(item.id, {
|
||||
method,
|
||||
date: v.date,
|
||||
quantity: method === "cash" ? "" : v.quantity,
|
||||
amount: v.amount,
|
||||
accountId: v.amount ? v.accountId || accounts?.[0]?.id || "" : "",
|
||||
note: v.note,
|
||||
});
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success((r.data as { closed: boolean }).closed ? t.toast.closed : t.toast.paid);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
const f = t.payFields;
|
||||
const hints = debt ? t.methodHints : t.receiveHints;
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={`${t.payTitle} · ${item.label}`}
|
||||
description={`${t.stats.remaining}: ${formatAssetQuantity(item.assetKey, item.progress.remainingQuantity)}`}
|
||||
footer={<Footer busy={busy} formId="payment-form" onClose={onClose} />}
|
||||
>
|
||||
<form id="payment-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Tabs value={method} onValueChange={(x) => setMethod(x as "asset" | "cash")}>
|
||||
<TabsList className="w-full">
|
||||
{(["cash", "asset"] as const).map((m) => (
|
||||
<TabsTrigger key={m} value={m} className="flex-1">
|
||||
{t.methods[m]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<p className="-mt-2 text-xs text-muted-foreground">{hints[method]}</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{method === "asset" ? (
|
||||
<Field data-invalid={Boolean(errors.quantity)}>
|
||||
<FieldLabel htmlFor="pay-qty">
|
||||
{f.quantity} ({ASSETS[item.assetKey].unit})
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="pay-qty"
|
||||
inputMode="decimal"
|
||||
value={v.quantity}
|
||||
onChange={(e) => setV((s) => ({ ...s, quantity: e.target.value }))}
|
||||
className="text-right tabular-nums"
|
||||
/>
|
||||
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
||||
</Field>
|
||||
) : null}
|
||||
<Field data-invalid={Boolean(errors.amount)}>
|
||||
<FieldLabel htmlFor="pay-amount">
|
||||
{method === "cash" ? f.amount : f.assetAmount}
|
||||
</FieldLabel>
|
||||
<MoneyInput
|
||||
id="pay-amount"
|
||||
value={v.amount}
|
||||
onChange={(x) => setV((s) => ({ ...s, amount: x }))}
|
||||
/>
|
||||
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pay-date">{tr.transactions.fields.date}</FieldLabel>
|
||||
<Input
|
||||
id="pay-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
max={todayIstanbul()}
|
||||
onChange={(e) => setV((s) => ({ ...s, date: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
{method === "cash" ? (
|
||||
<FieldDescription className="-mt-2">{f.quantityHint}</FieldDescription>
|
||||
) : null}
|
||||
{method === "cash" || v.amount ? (
|
||||
<Field data-invalid={Boolean(errors.accountId)}>
|
||||
<FieldLabel htmlFor="pay-account">{f.account}</FieldLabel>
|
||||
<AccountSelect
|
||||
id="pay-account"
|
||||
value={v.accountId || accounts?.[0]?.id || ""}
|
||||
onChange={(x) => setV((s) => ({ ...s, accountId: x }))}
|
||||
accounts={accounts ?? []}
|
||||
invalid={Boolean(errors.accountId)}
|
||||
/>
|
||||
{errors.accountId ? <FieldError>{errors.accountId}</FieldError> : null}
|
||||
</Field>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pay-note">{t.fields.note}</FieldLabel>
|
||||
<Input
|
||||
id="pay-note"
|
||||
value={v.note}
|
||||
onChange={(e) => setV((s) => ({ ...s, note: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, Plus } from "lucide-react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { MoneyInput } from "@/components/money-input";
|
||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ASSET_KEYS, ASSETS, type AssetKey } from "@/shared/assets";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { formatDate, formatMoney } from "@/shared/format";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
import { savePriceAction } from "../actions";
|
||||
import type { PriceRow } from "../service";
|
||||
|
||||
const t = tr.prices;
|
||||
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||
|
||||
export function PricesView({ rows, canWrite }: { rows: PriceRow[]; canWrite: boolean }) {
|
||||
const [editing, setEditing] = useState<AssetKey | null>(null);
|
||||
const [openRow, setOpenRow] = useState<AssetKey | null>(null);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{canWrite ? (
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setEditing("gram_gold")}>
|
||||
<Plus />
|
||||
{t.add}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-muted-foreground">
|
||||
<tr className="border-b">
|
||||
<th className="p-3 text-left font-normal">{t.asset}</th>
|
||||
<th className="p-3 text-right font-normal">{t.buy}</th>
|
||||
<th className="p-3 text-right font-normal">{t.sell}</th>
|
||||
<th className="p-3 text-right font-normal">{t.date}</th>
|
||||
<th className="w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y tabular-nums">
|
||||
{rows.map((r) => (
|
||||
<Fragment key={r.assetKey}>
|
||||
<tr>
|
||||
<td className="p-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-left font-medium disabled:cursor-default"
|
||||
onClick={() => setOpenRow(openRow === r.assetKey ? null : r.assetKey)}
|
||||
disabled={r.history.length < 2}
|
||||
aria-expanded={openRow === r.assetKey}
|
||||
>
|
||||
{r.history.length > 1 ? (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
openRow === r.assetKey && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{ASSETS[r.assetKey].label}
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{t.perUnit(ASSETS[r.assetKey].unit)}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{r.latest ? formatMoney(r.latest.buyMinor) : "—"}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{r.latest ? formatMoney(r.latest.sellMinor) : "—"}
|
||||
</td>
|
||||
<td className="p-3 text-right text-muted-foreground">
|
||||
{r.latest ? d(r.latest.date) : t.noPrice}
|
||||
</td>
|
||||
<td className="p-1 text-right">
|
||||
{canWrite ? (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => setEditing(r.assetKey)}
|
||||
aria-label={`${t.add}: ${ASSETS[r.assetKey].label}`}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
{openRow === r.assetKey
|
||||
? r.history.slice(1).map((h) => (
|
||||
<tr key={h.date} className="bg-muted/30 text-muted-foreground">
|
||||
<td className="p-2 pl-9 text-xs">{t.history}</td>
|
||||
<td className="p-2 text-right">{formatMoney(h.buyMinor)}</td>
|
||||
<td className="p-2 text-right">{formatMoney(h.sellMinor)}</td>
|
||||
<td className="p-2 text-right">{d(h.date)}</td>
|
||||
<td />
|
||||
</tr>
|
||||
))
|
||||
: null}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{editing ? (
|
||||
<PriceForm
|
||||
key={editing}
|
||||
assetKey={editing}
|
||||
latest={rows.find((r) => r.assetKey === editing)?.latest ?? null}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PriceForm({
|
||||
assetKey: initial,
|
||||
latest,
|
||||
onClose,
|
||||
}: {
|
||||
assetKey: AssetKey;
|
||||
latest: PriceRow["latest"];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [v, setV] = useState({
|
||||
assetKey: initial,
|
||||
date: todayIstanbul(),
|
||||
buy: latest ? formatMoneyInput(latest.buyMinor) : "",
|
||||
sell: latest ? formatMoneyInput(latest.sellMinor) : "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await savePriceAction(v);
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.saved);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={t.add}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form="price-form" disabled={busy}>
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="price-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-[1fr_auto] gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pr-asset">{t.asset}</FieldLabel>
|
||||
<Select
|
||||
value={v.assetKey}
|
||||
onValueChange={(x) => setV((s) => ({ ...s, assetKey: x as AssetKey }))}
|
||||
>
|
||||
<SelectTrigger id="pr-asset" className="w-full">
|
||||
<SelectValue>{ASSETS[v.assetKey].label}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ASSET_KEYS.map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{ASSETS[k].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.date)}>
|
||||
<FieldLabel htmlFor="pr-date">{t.date}</FieldLabel>
|
||||
<Input
|
||||
id="pr-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
max={todayIstanbul()}
|
||||
onChange={(e) => setV((s) => ({ ...s, date: e.target.value }))}
|
||||
/>
|
||||
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.buy)}>
|
||||
<FieldLabel htmlFor="pr-buy">{t.buy}</FieldLabel>
|
||||
<MoneyInput
|
||||
id="pr-buy"
|
||||
value={v.buy}
|
||||
onChange={(x) => setV((s) => ({ ...s, buy: x }))}
|
||||
autoFocus
|
||||
/>
|
||||
{errors.buy ? <FieldError>{errors.buy}</FieldError> : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pr-sell">{t.sell}</FieldLabel>
|
||||
<MoneyInput
|
||||
id="pr-sell"
|
||||
value={v.sell}
|
||||
onChange={(x) => setV((s) => ({ ...s, sell: x }))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<FieldDescription className="-mt-2">{t.sellHint}</FieldDescription>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
+106
@@ -363,6 +363,7 @@ export const tr = {
|
||||
reconciled: "Mutabakatı yapıldı",
|
||||
cancelInstallments: "Kalan taksitleri iptal et",
|
||||
contactEditHint: "Cari hareketi cari sayfasından düzenlenir.",
|
||||
inKindPaymentLocked: "Aynî borç ödemesi silinemez; ödeme cari sayfasındaki kayda bağlıdır.",
|
||||
readOnly: "İzleyici rolündesiniz; işlem ekleyemezsiniz.",
|
||||
noAccounts: "İşlem girmek için önce bir hesap gerekli.",
|
||||
noAccountsLink: "Hesaplara git",
|
||||
@@ -549,6 +550,111 @@ export const tr = {
|
||||
archived: "Arşivlenmiş cariye hareket eklenemez.",
|
||||
},
|
||||
},
|
||||
inKind: {
|
||||
title: "Aynî borç ve alacaklar",
|
||||
description:
|
||||
"Altın, ziynet veya döviz cinsinden borç/alacak. Kapanış yalnızca “Ödeme gerçekleşti” ile.",
|
||||
new: "Aynî kayıt ekle",
|
||||
createTitle: "Yeni aynî borç/alacak",
|
||||
editTitle: "Aynî kaydı düzenle",
|
||||
directions: { debt: "Ben borçluyum", receivable: "Bana borçlu" },
|
||||
fields: {
|
||||
direction: "Yön",
|
||||
asset: "Varlık",
|
||||
quantity: "Miktar",
|
||||
date: "Borç tarihi",
|
||||
unitPrice: "O tarihteki birim fiyat (TL)",
|
||||
unitPriceHint: "Boş bırakılırsa fiyat tablosundaki o tarihe ait son fiyat kullanılır.",
|
||||
note: "Not",
|
||||
savingsAccount: "Birikim hesabı",
|
||||
savingsNone: "— Birikim planı yok —",
|
||||
monthlyPlan: "Aylık ayrılacak tutar (TL)",
|
||||
planHint:
|
||||
"Borcu kapatmak için her ay bu hesaba para ayırın; borç yalnızca ödeme yapılınca kapanır.",
|
||||
},
|
||||
stats: {
|
||||
remaining: "Kalan",
|
||||
dateValue: (d: string) => `${d} değeri`,
|
||||
currentValue: "Güncel değer",
|
||||
accumulated: "Biriken",
|
||||
remainingNeed: "Kalan ihtiyaç",
|
||||
estimatedClose: "Tahmini kapanış",
|
||||
noPrice: "Güncel fiyat yok",
|
||||
closed: (d: string) => `Kapandı · ${d}`,
|
||||
priceAt: (price: string, date: string) => `Fiyat ${price} (${date})`,
|
||||
currentPrice: (debt: boolean, price: string, date: string) =>
|
||||
`Güncel ${debt ? "satış" : "alış"} fiyatı ${price} · ${date}`,
|
||||
},
|
||||
contribute: "Birikim ekle",
|
||||
contributionTitle: "Birikim ekle",
|
||||
fromAccount: "Kaynak hesap",
|
||||
contributionDescription: (label: string, name: string) => `Birikim: ${label} borcu (${name})`,
|
||||
pay: "Ödeme gerçekleşti",
|
||||
payTitle: "Ödeme gerçekleşti",
|
||||
methods: { asset: "Aynı varlıkla", cash: "TL karşılığıyla" },
|
||||
methodHints: {
|
||||
asset: "Varlığın kendisiyle ödendi (ör. kuyumcudan alınan altın verildi).",
|
||||
cash: "Varlığın TL karşılığı ödendi; hesaptan çıkış işlemi oluşturulur.",
|
||||
},
|
||||
receiveHints: {
|
||||
asset: "Varlığın kendisi teslim alındı.",
|
||||
cash: "Varlığın TL karşılığı hesaba tahsil edildi.",
|
||||
},
|
||||
payFields: {
|
||||
quantity: "Ödenen miktar",
|
||||
quantityHint: "Boş bırakılırsa tutar güncel fiyata bölünerek hesaplanır.",
|
||||
amount: "Tutar (TL)",
|
||||
assetAmount: "Varlığı almak için harcanan (TL, isteğe bağlı)",
|
||||
account: "Hesap",
|
||||
},
|
||||
paymentDescription: (debt: boolean, label: string, name: string) =>
|
||||
debt ? `Aynî borç ödemesi: ${label} (${name})` : `Aynî alacak tahsilatı: ${label} (${name})`,
|
||||
payments: "Ödemeler",
|
||||
paymentLine: (method: string, qty: string, amount: string | null) =>
|
||||
amount ? `${method} · ${qty} · ${amount}` : `${method} · ${qty}`,
|
||||
delete: "Kaydı sil",
|
||||
deleteTitle: "Aynî kayıt silinsin mi?",
|
||||
deleteDescription:
|
||||
"Kayıt listeden kaldırılır. Oluşturulmuş para hareketleri (birikim, ödeme) işlemlerde kalır.",
|
||||
prices: "Fiyat tablosu",
|
||||
toast: {
|
||||
saved: "Aynî kayıt kaydedildi.",
|
||||
contributed: "Birikim eklendi.",
|
||||
paid: "Ödeme kaydedildi.",
|
||||
closed: "Ödeme kaydedildi; kayıt kapandı.",
|
||||
deleted: "Aynî kayıt silindi.",
|
||||
},
|
||||
errors: {
|
||||
noPrice: "Bu tarih için fiyat yok; birim fiyatı girin veya fiyat tablosunu güncelleyin.",
|
||||
noSavings: "Önce kayda bir birikim hesabı seçin.",
|
||||
closed: "Kayıt kapanmış.",
|
||||
quantityBelowPaid: "Miktar ödenmiş miktardan az olamaz.",
|
||||
receivablePlan: "Birikim planı yalnızca borçlar içindir.",
|
||||
noCurrentPrice: "Güncel fiyat yok; ödenen miktarı girin.",
|
||||
sameAccount: "Kaynak hesap birikim hesabıyla aynı olamaz.",
|
||||
},
|
||||
validation: {
|
||||
quantity: "Geçerli bir miktar girin (en fazla 3 ondalık)",
|
||||
price: "Alış veya satış fiyatı girin",
|
||||
amount: "Tutar girin",
|
||||
account: "Hesap seçin",
|
||||
},
|
||||
},
|
||||
prices: {
|
||||
title: "Fiyat tablosu",
|
||||
description:
|
||||
"Altın ve döviz fiyatları (elle). Varlıklar ve aynî alacaklar alış, aynî borçlar satış fiyatıyla değerlenir.",
|
||||
add: "Fiyat gir",
|
||||
asset: "Varlık",
|
||||
date: "Tarih",
|
||||
buy: "Alış (TL)",
|
||||
sell: "Satış (TL)",
|
||||
sellHint: "Boş bırakılırsa alış fiyatı kullanılır (tek fiyat).",
|
||||
noPrice: "Fiyat yok",
|
||||
history: "Son fiyatlar",
|
||||
perUnit: (unit: string) => `/ ${unit}`,
|
||||
saved: "Fiyat kaydedildi.",
|
||||
},
|
||||
card: {
|
||||
title: "Kart özeti",
|
||||
debt: "Toplam borç",
|
||||
|
||||
Reference in New Issue
Block a user