feat(web): txn-detail subscription/convert-to-lending + lending transaction-linking

This commit is contained in:
Munkherdene 2026-08-22 23:02:01 +08:00
parent 5eff0f661f
commit 2637eb48a7
7 changed files with 450 additions and 28 deletions

View file

@ -16,9 +16,21 @@ import {
Skeleton, Skeleton,
TextFieldRoot, TextFieldRoot,
TextFieldTextarea, TextFieldTextarea,
TextFieldInput,
SwitchRoot,
SwitchControl,
SwitchThumb,
BottomSheetRoot,
BottomSheetBackdrop,
BottomSheetPositioner,
BottomSheetContent,
BottomSheetHeader,
BottomSheetTitle,
BottomSheetBody,
BottomSheetFooter,
} from "@seed-design/react"; } from "@seed-design/react";
import { useTransactions, useCategories } from "@/api/hooks/reads"; import { useTransactions, useCategories, useSubscriptions } from "@/api/hooks/reads";
import { useCategorize, useRenameTxn, useSetNote } from "@/api/hooks/mutations"; import { useCategorize, useRenameTxn, useSetNote, useSubscriptionMutations, useLendingMutations } from "@/api/hooks/mutations";
import { Card, MercuryButton, NameEdit } from "@/ds"; import { Card, MercuryButton, NameEdit } from "@/ds";
import { MASKED, tugrikRaw } from "@/ds/money"; import { MASKED, tugrikRaw } from "@/ds/money";
import { accountingStrings as s } from "./strings"; import { accountingStrings as s } from "./strings";
@ -103,9 +115,12 @@ export function TransactionDetail({ id }: TransactionDetailProps) {
const router = useRouter(); const router = useRouter();
const { data: transactions, isLoading } = useTransactions(); const { data: transactions, isLoading } = useTransactions();
const { data: categories = [] } = useCategories(); const { data: categories = [] } = useCategories();
const { data: subscriptions } = useSubscriptions();
const categorize = useCategorize(); const categorize = useCategorize();
const renameTxn = useRenameTxn(); const renameTxn = useRenameTxn();
const setNoteMutation = useSetNote(); const setNoteMutation = useSetNote();
const subscriptionMutations = useSubscriptionMutations();
const lendingMutations = useLendingMutations();
const hiddenAmounts = useHiddenAmounts(); const hiddenAmounts = useHiddenAmounts();
const txn = useMemo(() => findTxnByRouteId(transactions ?? [], id), [transactions, id]); const txn = useMemo(() => findTxnByRouteId(transactions ?? [], id), [transactions, id]);
@ -117,6 +132,7 @@ export function TransactionDetail({ id }: TransactionDetailProps) {
const [assignedCategory, setAssignedCategory] = useState<string | null>(null); const [assignedCategory, setAssignedCategory] = useState<string | null>(null);
const [displayTitle, setDisplayTitle] = useState<string | null>(null); const [displayTitle, setDisplayTitle] = useState<string | null>(null);
const [noteOverride, setNoteOverride] = useState<string | null>(null); const [noteOverride, setNoteOverride] = useState<string | null>(null);
const [subscriptionOverride, setSubscriptionOverride] = useState<boolean | null>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false);
@ -124,13 +140,16 @@ export function TransactionDetail({ id }: TransactionDetailProps) {
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
const [editingNote, setEditingNote] = useState(false); const [editingNote, setEditingNote] = useState(false);
const [noteDraft, setNoteDraft] = useState(""); const [noteDraft, setNoteDraft] = useState("");
const [convertOpen, setConvertOpen] = useState(false);
useEffect(() => { useEffect(() => {
setAssignedCategory(null); setAssignedCategory(null);
setDisplayTitle(null); setDisplayTitle(null);
setNoteOverride(null); setNoteOverride(null);
setSubscriptionOverride(null);
setRenaming(false); setRenaming(false);
setEditingNote(false); setEditingNote(false);
setConvertOpen(false);
}, [id]); }, [id]);
if (isLoading && !txn) { if (isLoading && !txn) {
@ -158,10 +177,23 @@ export function TransactionDetail({ id }: TransactionDetailProps) {
const title = displayTitle ?? txn.title; const title = displayTitle ?? txn.title;
const note = noteOverride ?? txn.note ?? ""; const note = noteOverride ?? txn.note ?? "";
const canNote = txn.txnId != null && txn.txnId > 0; const canNote = txn.txnId != null && txn.txnId > 0;
// A settled row has a stable txnId; pending holds (nil/0) can't be linked
// to a lending entry or the subscription-detection reconciliation below.
const hasStableTxnId = txn.txnId != null && txn.txnId > 0;
const amountRaw = hiddenAmounts ? MASKED : tugrikRaw(txn.amount); const amountRaw = hiddenAmounts ? MASKED : tugrikRaw(txn.amount);
const signedAmount = hiddenAmounts ? MASKED : `${income ? "+" : ""}${tugrikRaw(txn.amount)}`; const signedAmount = hiddenAmounts ? MASKED : `${income ? "+" : ""}${tugrikRaw(txn.amount)}`;
// Whether this merchant is already marked as a subscription override, so the
// toggle opens in the right state (mirrors iOS's `.task` reconciliation
// against `api.recurring()`), keyed on the same match key categorize/rename
// already use for this merchant.
const subscriptionKey = (txn.matchKey ?? txn.title).toLowerCase().trim();
const detectedSubscription = (subscriptions?.subscriptions ?? []).some(
(sub) => (sub.matchKey ?? sub.label.toLowerCase()) === subscriptionKey,
);
const isSubscription = subscriptionOverride ?? detectedSubscription;
async function confirmRename() { async function confirmRename() {
if (!pendingName) return; if (!pendingName) return;
const name = pendingName; const name = pendingName;
@ -188,6 +220,33 @@ export function TransactionDetail({ id }: TransactionDetailProps) {
} }
} }
/** Toggle this merchant's subscription override (force-add / force-remove),
* mirroring iOS's `setSubscription`. */
async function toggleSubscription(next: boolean) {
setSubscriptionOverride(next);
try {
await subscriptionMutations.setSubscription.mutateAsync({
matchKey: txn!.matchKey ?? txn!.title,
active: next,
});
} catch {
// Best-effort — leave the optimistic toggle in place.
}
}
/** Create a lending entry from this expense, linked to the transaction when
* it has a stable id, then navigate to the new entry's detail page. */
async function convertToLending(values: { person: string; amount: string; lentOn: string }) {
const created = await lendingMutations.create.mutateAsync({
person: values.person,
amount: values.amount,
lentOn: values.lentOn,
txnId: hasStableTxnId ? (txn!.txnId as number) : undefined,
});
setConvertOpen(false);
router.push(created ? `/assets/lending/${created.id}` : "/assets");
}
if (renaming) { if (renaming) {
return ( return (
<div style={{ paddingTop: 24 }}> <div style={{ paddingTop: 24 }}>
@ -237,11 +296,7 @@ export function TransactionDetail({ id }: TransactionDetailProps) {
{txn.balanceAfter != null && ( {txn.balanceAfter != null && (
<DetailRow label={s.detail.balance} value={hiddenAmounts ? MASKED : tugrikRaw(txn.balanceAfter)} /> <DetailRow label={s.detail.balance} value={hiddenAmounts ? MASKED : tugrikRaw(txn.balanceAfter)} />
)} )}
<DetailRow <DetailRow label={s.detail.type} value={income ? s.detail.income : s.detail.expense} />
label={s.detail.type}
value={income ? s.detail.income : s.detail.expense}
last={!canNote}
/>
{canNote && {canNote &&
(editingNote ? ( (editingNote ? (
<div style={{ padding: 12, display: "flex", flexDirection: "column", gap: 8 }}> <div style={{ padding: 12, display: "flex", flexDirection: "column", gap: 8 }}>
@ -271,15 +326,39 @@ export function TransactionDetail({ id }: TransactionDetailProps) {
label={s.detail.note} label={s.detail.note}
value={note || s.detail.noteAdd} value={note || s.detail.noteAdd}
chevron chevron
last
onClick={() => { onClick={() => {
setNoteDraft(note); setNoteDraft(note);
setEditingNote(true); setEditingNote(true);
}} }}
/> />
))} ))}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: "12px 16px" }}>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
{s.detailActions.subscriptionLabel}
</span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
{isSubscription ? s.detailActions.subscriptionActive : s.detailActions.subscriptionInactive}
</span>
<SwitchRoot
checked={isSubscription}
onCheckedChange={toggleSubscription}
disabled={subscriptionMutations.setSubscription.isPending}
>
<SwitchControl>
<SwitchThumb />
</SwitchControl>
</SwitchRoot>
</div>
</div>
</Card> </Card>
{!income && (
<MercuryButton variant="secondary" onClick={() => setConvertOpen(true)}>
{s.detailActions.convertToLending}
</MercuryButton>
)}
<CategorizeSheet <CategorizeSheet
open={pickerOpen} open={pickerOpen}
onOpenChange={setPickerOpen} onOpenChange={setPickerOpen}
@ -313,10 +392,93 @@ export function TransactionDetail({ id }: TransactionDetailProps) {
</DialogContent> </DialogContent>
</DialogPositioner> </DialogPositioner>
</DialogRoot> </DialogRoot>
{convertOpen && (
<ConvertToLendingSheet
initialPerson={title}
initialAmount={txn.amount}
initialLentOn={txn.date.slice(0, 10)}
saving={lendingMutations.create.isPending}
onClose={() => setConvertOpen(false)}
onSave={convertToLending}
/>
)}
</div> </div>
); );
} }
/**
* Small pre-filled form (Card style="Зээл болгох") that turns an expense into a
* lending entry ports the pre-fill in `TransactionDetailView.swift`'s
* `showMarkAsLending` (person/amount/date seeded via `LendingAutofill`),
* simplified to just those three editable fields per this task's scope.
*/
function ConvertToLendingSheet({
initialPerson,
initialAmount,
initialLentOn,
saving,
onClose,
onSave,
}: {
initialPerson: string;
initialAmount: string;
initialLentOn: string;
saving: boolean;
onClose: () => void;
onSave: (values: { person: string; amount: string; lentOn: string }) => void | Promise<void>;
}) {
const [person, setPerson] = useState(initialPerson);
const [amount, setAmount] = useState(initialAmount);
const [lentOn, setLentOn] = useState(initialLentOn);
const canSave = person.trim().length > 0 && Number(amount) > 0 && !saving;
return (
<BottomSheetRoot open onOpenChange={(next) => { if (!next) onClose(); }}>
<BottomSheetBackdrop />
<BottomSheetPositioner>
<BottomSheetContent>
<BottomSheetHeader>
<BottomSheetTitle>{s.detailActions.lendingSheetTitle}</BottomSheetTitle>
</BottomSheetHeader>
<BottomSheetBody style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<TextFieldRoot value={person} onValueChange={setPerson} name="convert-person">
<TextFieldInput placeholder={s.detailActions.person} aria-label={s.detailActions.person} autoFocus />
</TextFieldRoot>
<TextFieldRoot value={amount} onValueChange={setAmount} name="convert-amount">
<TextFieldInput
type="number"
inputMode="numeric"
placeholder={s.detailActions.amount}
aria-label={s.detailActions.amount}
/>
</TextFieldRoot>
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13 }}>
{s.detailActions.lentOn}
<input type="date" value={lentOn} onChange={(e) => setLentOn(e.target.value)} style={{ padding: 8, borderRadius: 8 }} />
</label>
</BottomSheetBody>
<BottomSheetFooter style={{ display: "flex", gap: 8 }}>
<MercuryButton variant="secondary" onClick={onClose} style={{ flex: 1 }}>
{s.detailActions.cancel}
</MercuryButton>
<MercuryButton
variant="primary"
style={{ flex: 1 }}
disabled={!canSave}
loading={saving}
onClick={() => onSave({ person: person.trim(), amount, lentOn })}
>
{s.detailActions.save}
</MercuryButton>
</BottomSheetFooter>
</BottomSheetContent>
</BottomSheetPositioner>
</BottomSheetRoot>
);
}
function BackButton({ onClick }: { onClick: () => void }) { function BackButton({ onClick }: { onClick: () => void }) {
return ( return (
<button <button

View file

@ -41,4 +41,19 @@ export const accountingStrings = {
cancel: "Болих", cancel: "Болих",
select: "Сонгох", select: "Сонгох",
}, },
// New actions on the transaction detail (subscription toggle + convert-to-
// lending), ported from `TransactionDetailView.swift`'s `subscriptionControls`
// and "Зээл болгох" button / `LendingAddView` pre-fill flow.
detailActions: {
subscriptionLabel: "Захиалга болгох",
subscriptionActive: "Идэвхтэй",
subscriptionInactive: "Идэвхгүй",
convertToLending: "Зээл болгох",
lendingSheetTitle: "Зээлд шилжүүлэх",
person: "Хэнд өгсөн",
amount: "Дүн",
lentOn: "Өгсөн огноо",
save: "Хадгалах",
cancel: "Болих",
},
} as const; } as const;

View file

@ -19,10 +19,12 @@ import { useNetWorth, useManualAssets, useLending } from "@/api/hooks/reads";
import { useManualAssetMutations, useLendingMutations } from "@/api/hooks/mutations"; import { useManualAssetMutations, useLendingMutations } from "@/api/hooks/mutations";
import { Card, HideAmountsToggle, MercuryButton, IconChip, SectionHeader, EmptyState, Icon, type IconName } from "@/ds"; import { Card, HideAmountsToggle, MercuryButton, IconChip, SectionHeader, EmptyState, Icon, type IconName } from "@/ds";
import { tugrik, tugrikRaw } from "@/ds/money"; import { tugrik, tugrikRaw } from "@/ds/money";
import type { Account, ManualAsset, Lending } from "@/api/schemas"; import type { Account, ManualAsset, Lending, Txn } from "@/api/schemas";
import { assetsStrings as s } from "./strings"; import { assetsStrings as s } from "./strings";
import { useHideAmountsTick } from "./useHideAmountsTick"; import { useHideAmountsTick } from "./useHideAmountsTick";
import { ConfirmDialog } from "./ConfirmDialog"; import { ConfirmDialog } from "./ConfirmDialog";
import { TransactionPickerSheet } from "./TransactionPickerSheet";
import { linkedTxnIdsOf } from "./lendingLinks";
const mutedStyle: React.CSSProperties = { color: "var(--seed-color-fg-neutral-subtle)" }; const mutedStyle: React.CSSProperties = { color: "var(--seed-color-fg-neutral-subtle)" };
const rowStyle: React.CSSProperties = { const rowStyle: React.CSSProperties = {
@ -79,6 +81,7 @@ export function AssetsView() {
const accounts: Account[] = netWorth.data?.accounts ?? []; const accounts: Account[] = netWorth.data?.accounts ?? [];
const assets: ManualAsset[] = manualAssets.data ?? []; const assets: ManualAsset[] = manualAssets.data ?? [];
const loans: Lending[] = lending.data ?? []; const loans: Lending[] = lending.data ?? [];
const linkedTxnIds = linkedTxnIdsOf(loans);
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 28 }}> <div style={{ display: "flex", flexDirection: "column", gap: 28 }}>
@ -177,6 +180,7 @@ export function AssetsView() {
{addLoanOpen && ( {addLoanOpen && (
<AddLoanSheet <AddLoanSheet
linkedTxnIds={linkedTxnIds}
onClose={() => setAddLoanOpen(false)} onClose={() => setAddLoanOpen(false)}
saving={lendingMutations.create.isPending} saving={lendingMutations.create.isPending}
onSave={async (values) => { onSave={async (values) => {
@ -392,9 +396,14 @@ function LoanRow({ loan, onDelete }: { loan: Lending; onDelete: () => void }) {
<IconChip icon="hand-coins" {...LENDING_TINT} /> <IconChip icon="hand-coins" {...LENDING_TINT} />
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}> <div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
<span style={titleStyle}>{loan.person}</span> <span style={titleStyle}>{loan.person}</span>
<span style={{ fontSize: 12, color: loan.overdue ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-neutral-subtle)" }}> <span style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 12 }}>
<span style={{ color: loan.overdue ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-neutral-subtle)" }}>
{statusLabel(loan)} {statusLabel(loan)}
</span> </span>
{loan.txnId != null && loan.txnId !== 0 && (
<span style={{ color: "var(--seed-color-fg-neutral-subtle)" }}>{s.lending.linkedBadge}</span>
)}
</span>
</div> </div>
</Link> </Link>
<div style={{ textAlign: "right", flexShrink: 0 }}> <div style={{ textAlign: "right", flexShrink: 0 }}>
@ -525,6 +534,7 @@ interface NewLoanValues {
lentOn: string; lentOn: string;
dueOn?: string; dueOn?: string;
note?: string; note?: string;
txnId?: number;
} }
function todayISO(): string { function todayISO(): string {
@ -532,10 +542,12 @@ function todayISO(): string {
} }
function AddLoanSheet({ function AddLoanSheet({
linkedTxnIds,
onClose, onClose,
onSave, onSave,
saving, saving,
}: { }: {
linkedTxnIds: Set<number>;
onClose: () => void; onClose: () => void;
onSave: (values: NewLoanValues) => void | Promise<void>; onSave: (values: NewLoanValues) => void | Promise<void>;
saving: boolean; saving: boolean;
@ -545,10 +557,13 @@ function AddLoanSheet({
const [lentOn, setLentOn] = React.useState(todayISO()); const [lentOn, setLentOn] = React.useState(todayISO());
const [dueOn, setDueOn] = React.useState(""); const [dueOn, setDueOn] = React.useState("");
const [note, setNote] = React.useState(""); const [note, setNote] = React.useState("");
const [linkedTxnId, setLinkedTxnId] = React.useState<number | undefined>(undefined);
const [pickerOpen, setPickerOpen] = React.useState(false);
const canSave = person.trim().length > 0 && Number(amount) > 0 && !saving; const canSave = person.trim().length > 0 && Number(amount) > 0 && !saving;
return ( return (
<>
<BottomSheetRoot open onOpenChange={(next) => { if (!next) onClose(); }}> <BottomSheetRoot open onOpenChange={(next) => { if (!next) onClose(); }}>
<BottomSheetBackdrop /> <BottomSheetBackdrop />
<BottomSheetPositioner> <BottomSheetPositioner>
@ -579,6 +594,16 @@ function AddLoanSheet({
<TextFieldRoot value={note} onValueChange={setNote} name="loan-note"> <TextFieldRoot value={note} onValueChange={setNote} name="loan-note">
<TextFieldInput placeholder={s.lending.fields.note} aria-label={s.lending.fields.note} /> <TextFieldInput placeholder={s.lending.fields.note} aria-label={s.lending.fields.note} />
</TextFieldRoot> </TextFieldRoot>
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13 }}>
{s.lending.fields.linkTxn}
<button
type="button"
onClick={() => setPickerOpen(true)}
style={{ padding: 8, borderRadius: 8, textAlign: "left", border: "1px solid var(--seed-color-border-neutral, #e5e5e5)", background: "none", cursor: "pointer" }}
>
{linkedTxnId ? s.lending.linkedBadge : "—"}
</button>
</label>
</BottomSheetBody> </BottomSheetBody>
<BottomSheetFooter style={{ display: "flex", gap: 12 }}> <BottomSheetFooter style={{ display: "flex", gap: 12 }}>
<MercuryButton variant="secondary" onClick={onClose} style={{ flex: 1 }}> <MercuryButton variant="secondary" onClick={onClose} style={{ flex: 1 }}>
@ -596,6 +621,7 @@ function AddLoanSheet({
lentOn, lentOn,
dueOn: dueOn || undefined, dueOn: dueOn || undefined,
note: note.trim() || undefined, note: note.trim() || undefined,
txnId: linkedTxnId,
}) })
} }
> >
@ -605,5 +631,20 @@ function AddLoanSheet({
</BottomSheetContent> </BottomSheetContent>
</BottomSheetPositioner> </BottomSheetPositioner>
</BottomSheetRoot> </BottomSheetRoot>
<TransactionPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
direction="expense"
linkedTxnIds={linkedTxnIds}
onPick={(txn) => {
setLinkedTxnId(txn.txnId ?? undefined);
setAmount(txn.amount);
setLentOn(txn.date.slice(0, 10));
if (!person.trim()) setPerson(txn.title);
setPickerOpen(false);
}}
/>
</>
); );
} }

View file

@ -23,6 +23,8 @@ import { tugrikRaw } from "@/ds/money";
import { assetsStrings as s } from "./strings"; import { assetsStrings as s } from "./strings";
import { ConfirmDialog } from "./ConfirmDialog"; import { ConfirmDialog } from "./ConfirmDialog";
import { DetailHeader } from "./DetailHeader"; import { DetailHeader } from "./DetailHeader";
import { TransactionPickerSheet } from "./TransactionPickerSheet";
import { linkedTxnIdsOf } from "./lendingLinks";
type LendingRepayment = Lending["repayments"][number]; type LendingRepayment = Lending["repayments"][number];
@ -57,7 +59,9 @@ export function LendingDetail({ id }: LendingDetailProps) {
const [deletingRepayment, setDeletingRepayment] = React.useState<LendingRepayment | null>(null); const [deletingRepayment, setDeletingRepayment] = React.useState<LendingRepayment | null>(null);
const [confirmDeleteEntry, setConfirmDeleteEntry] = React.useState(false); const [confirmDeleteEntry, setConfirmDeleteEntry] = React.useState(false);
const loan: Lending | undefined = (lending.data ?? []).find((l) => l.id === id); const loans = lending.data ?? [];
const loan: Lending | undefined = loans.find((l) => l.id === id);
const linkedTxnIds = linkedTxnIdsOf(loans);
if (lending.isLoading && !loan) { if (lending.isLoading && !loan) {
return ( return (
@ -95,10 +99,9 @@ export function LendingDetail({ id }: LendingDetailProps) {
<div style={{ marginTop: 6, ...mutedStyle }}> <div style={{ marginTop: 6, ...mutedStyle }}>
{s.lending.total} <strong>{tugrikRaw(loan.principal)}</strong> {s.lending.total} <strong>{tugrikRaw(loan.principal)}</strong>
</div> </div>
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", gap: 8, marginTop: 10 }}>
<div <div
style={{ style={{
display: "inline-block",
marginTop: 10,
padding: "5px 12px", padding: "5px 12px",
borderRadius: 999, borderRadius: 999,
fontSize: 12, fontSize: 12,
@ -109,6 +112,10 @@ export function LendingDetail({ id }: LendingDetailProps) {
> >
{statusLabel(loan)} {statusLabel(loan)}
</div> </div>
{loan.txnId != null && loan.txnId !== 0 && (
<span style={{ fontSize: 12, ...mutedStyle }}>{s.lending.linkedBadge}</span>
)}
</div>
</Card> </Card>
<Card> <Card>
@ -127,7 +134,12 @@ export function LendingDetail({ id }: LendingDetailProps) {
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}> <div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
<IconChip icon="receipt" tint="#E1E5EA" fg="#42505F" size={36} /> <IconChip icon="receipt" tint="#E1E5EA" fg="#42505F" size={36} />
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 700 }}>{tugrikRaw(r.amount)}</div> <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: 15, fontWeight: 700 }}>{tugrikRaw(r.amount)}</span>
{r.txnId != null && r.txnId !== 0 && (
<span style={{ fontSize: 11, ...mutedStyle }}>{s.lending.linkedBadge}</span>
)}
</div>
<div style={{ fontSize: 12, ...mutedStyle }}> <div style={{ fontSize: 12, ...mutedStyle }}>
{r.paidOn} {r.paidOn}
{r.note ? ` · ${r.note}` : ""} {r.note ? ` · ${r.note}` : ""}
@ -160,6 +172,7 @@ export function LendingDetail({ id }: LendingDetailProps) {
{addRepaymentOpen && ( {addRepaymentOpen && (
<AddRepaymentSheet <AddRepaymentSheet
linkedTxnIds={linkedTxnIds}
onClose={() => setAddRepaymentOpen(false)} onClose={() => setAddRepaymentOpen(false)}
saving={mutations.addRepayment.isPending} saving={mutations.addRepayment.isPending}
onSave={async (values) => { onSave={async (values) => {
@ -195,21 +208,26 @@ export function LendingDetail({ id }: LendingDetailProps) {
} }
function AddRepaymentSheet({ function AddRepaymentSheet({
linkedTxnIds,
onClose, onClose,
onSave, onSave,
saving, saving,
}: { }: {
linkedTxnIds: Set<number>;
onClose: () => void; onClose: () => void;
onSave: (values: { amount: string; paidOn: string; note?: string }) => void | Promise<void>; onSave: (values: { amount: string; paidOn: string; note?: string; txnId?: number }) => void | Promise<void>;
saving: boolean; saving: boolean;
}) { }) {
const [amount, setAmount] = React.useState(""); const [amount, setAmount] = React.useState("");
const [paidOn, setPaidOn] = React.useState(todayISO()); const [paidOn, setPaidOn] = React.useState(todayISO());
const [note, setNote] = React.useState(""); const [note, setNote] = React.useState("");
const [linkedTxnId, setLinkedTxnId] = React.useState<number | undefined>(undefined);
const [pickerOpen, setPickerOpen] = React.useState(false);
const canSave = Number(amount) > 0 && !saving; const canSave = Number(amount) > 0 && !saving;
return ( return (
<>
<BottomSheetRoot open onOpenChange={(next) => { if (!next) onClose(); }}> <BottomSheetRoot open onOpenChange={(next) => { if (!next) onClose(); }}>
<BottomSheetBackdrop /> <BottomSheetBackdrop />
<BottomSheetPositioner> <BottomSheetPositioner>
@ -234,6 +252,16 @@ function AddRepaymentSheet({
<TextFieldRoot value={note} onValueChange={setNote} name="repayment-note"> <TextFieldRoot value={note} onValueChange={setNote} name="repayment-note">
<TextFieldInput placeholder={s.lending.fields.note} aria-label={s.lending.fields.note} /> <TextFieldInput placeholder={s.lending.fields.note} aria-label={s.lending.fields.note} />
</TextFieldRoot> </TextFieldRoot>
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13 }}>
{s.lending.fields.linkTxn}
<button
type="button"
onClick={() => setPickerOpen(true)}
style={{ padding: 8, borderRadius: 8, textAlign: "left", border: "1px solid var(--seed-color-border-neutral, #e5e5e5)", background: "none", cursor: "pointer" }}
>
{linkedTxnId ? s.lending.linkedBadge : "—"}
</button>
</label>
</BottomSheetBody> </BottomSheetBody>
<BottomSheetFooter style={{ display: "flex", gap: 12 }}> <BottomSheetFooter style={{ display: "flex", gap: 12 }}>
<MercuryButton variant="secondary" onClick={onClose} style={{ flex: 1 }}> <MercuryButton variant="secondary" onClick={onClose} style={{ flex: 1 }}>
@ -244,7 +272,7 @@ function AddRepaymentSheet({
style={{ flex: 1 }} style={{ flex: 1 }}
disabled={!canSave} disabled={!canSave}
loading={saving} loading={saving}
onClick={() => onSave({ amount, paidOn, note: note.trim() || undefined })} onClick={() => onSave({ amount, paidOn, note: note.trim() || undefined, txnId: linkedTxnId })}
> >
{s.common.save} {s.common.save}
</MercuryButton> </MercuryButton>
@ -252,5 +280,19 @@ function AddRepaymentSheet({
</BottomSheetContent> </BottomSheetContent>
</BottomSheetPositioner> </BottomSheetPositioner>
</BottomSheetRoot> </BottomSheetRoot>
<TransactionPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
direction="income"
linkedTxnIds={linkedTxnIds}
onPick={(txn) => {
setLinkedTxnId(txn.txnId ?? undefined);
setAmount(txn.amount);
setPaidOn(txn.date.slice(0, 10));
setPickerOpen(false);
}}
/>
</>
); );
} }

View file

@ -0,0 +1,138 @@
"use client";
import * as React from "react";
import {
BottomSheetRoot,
BottomSheetBackdrop,
BottomSheetPositioner,
BottomSheetContent,
BottomSheetHeader,
BottomSheetTitle,
BottomSheetCloseButton,
BottomSheetBody,
TextFieldRoot,
TextFieldInput,
ListRoot,
ListItem,
Icon,
} from "@seed-design/react";
import { useTransactions, todayLocalDate } from "@/api/hooks/reads";
import type { Txn } from "@/api/schemas";
import { tugrikRaw } from "@/ds/money";
import { assetsStrings as s } from "./strings";
const closeSvg = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
);
export interface TransactionPickerSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** "expense" for a new loan, "income" for a repayment matches
* `TransactionPickerView`'s direction filter on iOS. */
direction: "income" | "expense";
/** Transaction ids already linked to some loan/repayment, so already-linked
* rows can carry a "Холбоотой" hint (mirrors `LendingModel.linkedTxnIds`). */
linkedTxnIds?: Set<number>;
onPick: (txn: Txn) => void;
}
/**
* A searchable bottom sheet of the user's transactions, filtered to one
* direction, for linking a lending entry or repayment to the real transaction
* that created it. Ports `TransactionPickerView.swift` + `LendingAutofill.swift`
* (the filtering/candidate logic lives inline below, small enough not to need
* its own module).
*/
export function TransactionPickerSheet({ open, onOpenChange, direction, linkedTxnIds, onPick }: TransactionPickerSheetProps) {
const [search, setSearch] = React.useState("");
// Pull ~1 year so older loans/repayments stay linkable, matching iOS.
const from = React.useMemo(() => {
const d = new Date();
d.setFullYear(d.getFullYear() - 1);
return todayLocalDate(d);
}, []);
const to = React.useMemo(() => todayLocalDate(), []);
const { data: transactions, isLoading } = useTransactions({ direction, from, to, limit: 500 });
const shown = React.useMemo(() => {
const q = search.trim().toLowerCase();
return (transactions ?? []).filter((t) => {
// Pending holds (no stable txnId) can't be linked.
if (t.txnId == null || t.txnId === 0) return false;
if (!q) return true;
return t.title.toLowerCase().includes(q) || t.amount.toLowerCase().includes(q);
});
}, [transactions, search]);
return (
<BottomSheetRoot open={open} onOpenChange={onOpenChange}>
<BottomSheetBackdrop />
<BottomSheetPositioner>
<BottomSheetContent>
<BottomSheetHeader style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<BottomSheetTitle>{s.lending.picker.title}</BottomSheetTitle>
<BottomSheetCloseButton aria-label={s.common.cancel}>
<Icon svg={closeSvg} size="16px" />
</BottomSheetCloseButton>
</BottomSheetHeader>
<BottomSheetBody style={{ display: "flex", flexDirection: "column", gap: 10, maxHeight: "70vh", overflowY: "auto" }}>
<TextFieldRoot value={search} onValueChange={setSearch} name="txn-picker-search">
<TextFieldInput placeholder={s.lending.picker.search} aria-label={s.lending.picker.search} autoFocus />
</TextFieldRoot>
{isLoading ? (
<p style={{ margin: 0, color: "var(--seed-color-fg-neutral-subtle)" }}></p>
) : shown.length === 0 ? (
<p style={{ margin: 0, color: "var(--seed-color-fg-neutral-subtle)" }}>{s.lending.picker.empty}</p>
) : (
<ListRoot>
{shown.map((txn) => {
const linked = txn.txnId != null && linkedTxnIds?.has(txn.txnId);
return (
<ListItem key={`${txn.txnId}-${txn.date}`} style={{ padding: 0 }}>
<button
type="button"
onClick={() => onPick(txn)}
style={{
display: "flex",
width: "100%",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
background: "none",
border: "none",
textAlign: "left",
cursor: "pointer",
padding: "10px 4px",
font: "inherit",
color: "inherit",
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{txn.title}
</div>
<div style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-subtle)" }}>{txn.date.slice(0, 10)}</div>
</div>
<div style={{ textAlign: "right", flexShrink: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600 }}>{tugrikRaw(txn.amount)}</div>
{linked && (
<div style={{ fontSize: 11, color: "var(--seed-color-fg-neutral-subtle)" }}>{s.lending.linkedBadge}</div>
)}
</div>
</button>
</ListItem>
);
})}
</ListRoot>
)}
</BottomSheetBody>
</BottomSheetContent>
</BottomSheetPositioner>
</BottomSheetRoot>
);
}

View file

@ -0,0 +1,17 @@
import type { Lending } from "@/api/schemas";
/**
* Transaction ids already linked to some loan or repayment (for the picker's
* "already linked" hint). Drops the 0/nil "unlinked" sentinel. Ports
* `LendingModel.linkedTxnIds` (iOS) pure, no networking.
*/
export function linkedTxnIdsOf(loans: Lending[]): Set<number> {
const ids = new Set<number>();
for (const l of loans) {
if (l.txnId != null && l.txnId !== 0) ids.add(l.txnId);
for (const r of l.repayments) {
if (r.txnId != null && r.txnId !== 0) ids.add(r.txnId);
}
}
return ids;
}

View file

@ -82,7 +82,9 @@ export const assetsStrings = {
lentOn: "Өгсөн огноо", lentOn: "Өгсөн огноо",
dueOn: "Төлөх огноо", dueOn: "Төлөх огноо",
note: "Тэмдэглэл", note: "Тэмдэглэл",
linkTxn: "Гүйлгээ холбох",
}, },
linkedBadge: "Холбоотой",
repayments: { repayments: {
title: "Төлөлтийн түүх", title: "Төлөлтийн түүх",
empty: "Төлөлт бүртгэгдээгүй", empty: "Төлөлт бүртгэгдээгүй",
@ -90,6 +92,11 @@ export const assetsStrings = {
deleteTitle: "Төлөлт устгах уу?", deleteTitle: "Төлөлт устгах уу?",
deleteDescription: "Энэ төлөлтийг түүхээс хасна.", deleteDescription: "Энэ төлөлтийг түүхээс хасна.",
}, },
picker: {
title: "Гүйлгээ сонгох",
search: "Хайх",
empty: "Гүйлгээ олдсонгүй",
},
}, },
accountDetail: { accountDetail: {
balance: "Үлдэгдэл", balance: "Үлдэгдэл",