138 lines
5.7 KiB
TypeScript
138 lines
5.7 KiB
TypeScript
"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>
|
|
);
|
|
}
|