merge feat/lend: feature build-out
This commit is contained in:
commit
2b56d94a9f
7 changed files with 450 additions and 28 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -56,4 +56,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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
138
src/features/assets/TransactionPickerSheet.tsx
Normal file
138
src/features/assets/TransactionPickerSheet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/features/assets/lendingLinks.ts
Normal file
17
src/features/assets/lendingLinks.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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: "Үлдэгдэл",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue