+ )
+}
+
+export function ParticipantsOverviewCard({
+ drawee,
+ drawer,
+ holder,
+ payee,
+ className,
+}: {
+ drawee?: IdentityPublicData
+ drawer?: IdentityPublicData
+ holder?: IdentityPublicData
+ payee?: IdentityPublicData
+ className?: string
+}) {
+ return (
+
)
}
-function Quote({ value, isFetching }: { value: InfoReply; isFetching: boolean }) {
+function IdentityPublicDataAvatar({ value, tooltip }: { value?: IdentityPublicData; tooltip?: React.ReactNode }) {
+ const avatar = (
+
+ )
+}
+
+function IdentityPublicDataCard({ value }: { value?: IdentityPublicData }) {
return (
- <>
-
-
-
-
- id:
- {value.id}
-
-
- status:
-
-
- {value.status}
-
-
-
-
- bill:
- {value.bill ? {JSON.stringify(value.bill, null, 2)} : "(empty)"}
-
-
-
-
-
+
+
+
- >
+
+
{value?.name}
+
+
+ {value?.address}, {value?.zip}, {value?.city}, {value?.country}
+
+
+
+
+ )
+}
+
+function Quote({ value, isFetching }: { value: InfoReply; isFetching: boolean }) {
+ return (
+
+
+
+
+ ID:
+
+ {value.id}
+
+
+
+ Status:
+
+
+ {value.status}
+
+
+
+
+ Sum:
+ {formatNumber("en", value.bill?.sum)} sat
+
+
+ Maturity date:
+
+ {!value.bill?.maturity_date ? (
+ <>(empty)>
+ ) : (
+
+ {formatDate("en", new Date(Date.parse(value.bill.maturity_date)))}
+ ({humanReadableDuration("en", new Date(Date.parse(value.bill.maturity_date)))})
+
+ )}
+
+
+
+ Participants:
+
+
+
+
+
+ Drawee:
+
+
+
+
+
+ Drawer:
+
+
+
+
+
+ Payee:
+
+
+
+
+
+ Holder:
+
+
+
+
+
+
+
+
+
)
}
@@ -171,7 +549,13 @@ function PageBody({ id }: { id: InfoReply["id"] }) {
return (
<>
- {isFetching && }
+ {" "}
+
>
@@ -196,7 +580,9 @@ export default function QuotePage() {
>
{id}
-
Quote {id}
+
+ Quote {truncateString(id, 16)}
+
}>
diff --git a/src/pages/quotes/QuotesPage.tsx b/src/pages/quotes/QuotesPage.tsx
index 7d273ff..e16e417 100644
--- a/src/pages/quotes/QuotesPage.tsx
+++ b/src/pages/quotes/QuotesPage.tsx
@@ -1,116 +1,24 @@
+import { Suspense } from "react"
import { Breadcrumbs } from "@/components/Breadcrumbs"
-import { H3 } from "@/components/Headings"
import { PageTitle } from "@/components/PageTitle"
-import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { listAcceptedQuotesOptions, listPendingQuotesOptions } from "@/generated/client/@tanstack/react-query.gen"
-import useLocalStorage from "@/hooks/use-local-storage"
import { useSuspenseQuery } from "@tanstack/react-query"
-import { LoaderIcon } from "lucide-react"
-import { Suspense } from "react"
-import { Link, useNavigate } from "react-router"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { cn } from "@/lib/utils"
+import { Link } from "react-router"
+import { ChevronRight } from "lucide-react"
function Loader() {
return (
-
+
+
)
}
-function QuoteListPending() {
- const navigate = useNavigate()
-
- const { data, isFetching } = useSuspenseQuery({
- ...listPendingQuotesOptions(),
- })
-
- return (
- <>
-
-
- Pending
- {isFetching && }
-
-
-
-
- {data.quotes.map((it, index) => {
- return (
-
- {isFetching ? (
- <>{it}>
- ) : (
- <>
- {it}
- >
- )}
-
- {
- void navigate("/quotes/:id".replace(":id", it))
- }}
- >
- View
-
-
- )
- })}
-
- >
- )
-}
-
-function QuoteListAccepted() {
- const navigate = useNavigate()
-
- const { data, isFetching } = useSuspenseQuery({
- ...listAcceptedQuotesOptions(),
- })
-
- return (
- <>
-
-
- Accepted
- {isFetching && }
-
-
-
-
- {data.quotes.map((it, index) => {
- return (
-
- {isFetching ? (
- <>{it}>
- ) : (
- <>
- {it}
- >
- )}
-
- {
- void navigate("/quotes/:id".replace(":id", it))
- }}
- >
- View
-
-
- )
- })}
-
- >
- )
-}
-
-function DevSection() {
- const [devMode] = useLocalStorage("devMode", false)
-
+function PageBody() {
const { data: quotesPending } = useSuspenseQuery({
...listPendingQuotesOptions({}),
})
@@ -120,29 +28,49 @@ function DevSection() {
})
return (
- <>
- {devMode && (
- <>
-
- {JSON.stringify(quotesPending, null, 2)}
-
-
- {JSON.stringify(quotesAccepted, null, 2)}
-
- >
- )}
- >
- )
-}
-
-function PageBody() {
- return (
- <>
-
}>
-
-
-
- >
+
+
+
0,
+ })}
+ >
+
+
+
+ Pending Quotes
+
+
+ {quotesPending.quotes.length === 0 ? (
+ <>💪 No pending quotes.>
+ ) : (
+ <>{quotesPending.quotes.length} pending>
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Accepted quotes
+
+ {quotesAccepted.quotes.length} accepted
+
+
+
+
+
+
+
+
)
}
@@ -151,9 +79,8 @@ export default function QuotesPage() {
<>
Quotes
Quotes
-
-
-
+ }>
+
>
)
diff --git a/src/pages/settings/SettingsPage.tsx b/src/pages/settings/SettingsPage.tsx
index 9178ac0..5fabd74 100644
--- a/src/pages/settings/SettingsPage.tsx
+++ b/src/pages/settings/SettingsPage.tsx
@@ -5,6 +5,7 @@ import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import useLocalStorage from "@/hooks/use-local-storage"
import { Suspense } from "react"
+import { toast } from "sonner"
function Loader() {
return (
@@ -26,6 +27,15 @@ function PageBody() {
className="cursor-pointer"
checked={devMode}
onCheckedChange={() => {
+ toast.info(
+ <>
+ Developer mode is
{(!devMode && "ON") || "OFF"}
+ >,
+ {
+ id: "settings-dev-mode",
+ duration: 1_337,
+ },
+ )
setDevMode((it) => !it)
}}
/>
diff --git a/src/utils/dates.ts b/src/utils/dates.ts
new file mode 100644
index 0000000..7c2ba2f
--- /dev/null
+++ b/src/utils/dates.ts
@@ -0,0 +1,47 @@
+import { differenceInCalendarYears, differenceInMinutes } from "date-fns"
+import { differenceInCalendarDays, differenceInCalendarMonths, differenceInHours, differenceInSeconds } from "date-fns"
+
+export const daysBetween = (startDate: Date, endDate: Date): number => {
+ return differenceInCalendarDays(endDate, startDate)
+}
+
+export function humanReadableDuration(locale: string, from: Date, until = new Date(Date.now())) {
+ const relativeTimeFormatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" })
+
+ const diffYears = differenceInCalendarYears(from, until)
+ if (Math.abs(diffYears) >= 1) {
+ return relativeTimeFormatter.format(diffYears, "years")
+ }
+ const diffMonths = differenceInCalendarMonths(from, until)
+ if (Math.abs(diffMonths) >= 1) {
+ return relativeTimeFormatter.format(diffMonths, "months")
+ }
+ const diffDays = differenceInCalendarDays(from, until)
+ if (Math.abs(diffDays) >= 1) {
+ return relativeTimeFormatter.format(diffDays, "days")
+ }
+ const diffHours = differenceInHours(from, until)
+ if (Math.abs(diffHours) > 1) {
+ return relativeTimeFormatter.format(diffHours, "hours")
+ }
+ const diffMinutes = differenceInMinutes(from, until)
+ if (Math.abs(diffMinutes) > 1) {
+ return relativeTimeFormatter.format(diffMinutes, "minutes")
+ }
+ const diffSeconds = differenceInSeconds(from, until)
+ return relativeTimeFormatter.format(diffSeconds, "seconds")
+}
+
+export function humanReadableDurationDays(locale: string, from: Date, until = new Date(Date.now())) {
+ const relativeTimeFormatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" })
+
+ const diffMillis = from.getTime() - until.getTime()
+ return relativeTimeFormatter.format(Math.round(diffMillis / (1000 * 60 * 60 * 24)), "day")
+}
+
+export const formatDate = (locale: string, date: Date): string => {
+ const year = new Intl.DateTimeFormat(locale, { year: "2-digit" }).format(date)
+ const month = new Intl.DateTimeFormat(locale, { month: "short" }).format(date)
+ const day = new Intl.DateTimeFormat(locale, { day: "2-digit" }).format(date)
+ return `${day}-${month}-${year}`
+}
diff --git a/src/utils/dev.ts b/src/utils/dev.ts
new file mode 100644
index 0000000..3f50e32
--- /dev/null
+++ b/src/utils/dev.ts
@@ -0,0 +1,9 @@
+export const randomAvatar = (path: "men" | "women" | undefined, seed: string | undefined) => {
+ const _path = path ?? (Math.random() > 0.5 ? "men" : "women")
+ const _seed =
+ (seed ?? `${Math.floor(Math.random() * 100)}`)
+ .split("")
+ .map((it) => it.charCodeAt(0))
+ .reduce((prev, curr) => prev + curr, 0) % 100
+ return `https://randomuser.me/api/portraits/${_path}/${_seed}.jpg`
+}
diff --git a/src/utils/discount-util.test.ts b/src/utils/discount-util.test.ts
new file mode 100644
index 0000000..31556d1
--- /dev/null
+++ b/src/utils/discount-util.test.ts
@@ -0,0 +1,90 @@
+import { describe, it, expect } from "vitest"
+import Big from "big.js"
+import { daysBetween } from "@/utils/dates"
+import { Act360 } from "./discount-util"
+
+describe("discount-util", () => {
+ describe("Act360", () => {
+ describe("netToGross", () => {
+ it("should calculate gross amount correctly (0)", () => {
+ const startDate = new Date(2024, 11, 6)
+ const endDate = new Date(2025, 2, 31)
+ const netAmount = new Big("10.12")
+ const discountRate = new Big("0.045")
+
+ const days = daysBetween(startDate, endDate)
+ const grossAmount = Act360.netToGross(netAmount, discountRate, days)
+ expect(grossAmount).toStrictEqual(new Big("10.26759670259987317692"))
+ expect(grossAmount!.toNumber()).toBe(10.267596702599873)
+ })
+
+ it("should calculate gross amount correctly (1)", () => {
+ expect(Act360.netToGross(new Big("1"), new Big("1"), -1)).toStrictEqual(new Big("0.99722991689750692521"))
+ expect(Act360.netToGross(new Big("1"), new Big("1"), 0)).toStrictEqual(new Big("1"))
+ expect(Act360.netToGross(new Big("1"), new Big("1"), 1)).toStrictEqual(new Big("1.00278551532033426184"))
+ expect(Act360.netToGross(new Big("1"), new Big("1"), 355)).toStrictEqual(new Big("71.99999999999999999424"))
+ expect(Act360.netToGross(new Big("1"), new Big("1"), 360)).toStrictEqual(undefined)
+ expect(Act360.netToGross(new Big("1"), new Big("1"), 365)).toStrictEqual(new Big("-71.99999999999999999424"))
+ })
+
+ it("should calculate gross amount correctly (2)", () => {
+ expect(Act360.netToGross(new Big(1), new Big("0.9863"), 365)).toStrictEqual(new Big("719999.999999999424"))
+ expect(Act360.netToGross(new Big(1), new Big("0.9864"), 365)).toStrictEqual(new Big("-10000"))
+ expect(Act360.netToGross(new Big(1), new Big("0.9865"), 365)).toStrictEqual(
+ new Big("-4965.51724137931031743163"),
+ )
+ })
+
+ it("should calculate gross amount correctly (step-by-step)", () => {
+ const startDate = new Date(2024, 11, 6)
+ const endDate = new Date(2025, 2, 31)
+ const netAmount = new Big("10.12")
+ const discountRate = new Big("0.045")
+
+ const days = daysBetween(startDate, endDate)
+ expect(days, "sanity check").toBe(115)
+
+ const discountDays = discountRate.times(days).div(360)
+ expect(discountDays.toNumber(), "sanity check").toBe(0.014375)
+
+ const factor = new Big(1).minus(discountDays)
+
+ const grossAmount = netAmount.div(factor)
+ expect(grossAmount).toStrictEqual(new Big("10.26759670259987317692"))
+ expect(grossAmount.toNumber()).toBe(10.267596702599873)
+
+ const calcGrossAmount = Act360.netToGross(netAmount, discountRate, days)
+ expect(calcGrossAmount).toStrictEqual(grossAmount)
+ })
+ })
+
+ describe("grossToNet", () => {
+ it("should calculate net amount correctly (0)", () => {
+ const startDate = new Date(2024, 11, 6)
+ const endDate = new Date(2025, 2, 31)
+ const grossAmount = new Big("10.12")
+ const discountRate = new Big("0.045")
+
+ const days = daysBetween(startDate, endDate)
+ const netAmount = Act360.grossToNet(grossAmount, discountRate, days)
+ expect(netAmount).toStrictEqual(new Big("9.974525"))
+ expect(netAmount.toNumber()).toBe(9.974525)
+ })
+
+ it("should calculate net amount correctly (1)", () => {
+ expect(Act360.grossToNet(new Big("1"), new Big("1"), -1)).toStrictEqual(new Big("1.00277777777777777778"))
+ expect(Act360.grossToNet(new Big("1"), new Big("1"), 0)).toStrictEqual(new Big("1"))
+ expect(Act360.grossToNet(new Big("1"), new Big("1"), 1)).toStrictEqual(new Big("0.99722222222222222222"))
+ expect(Act360.grossToNet(new Big("1"), new Big("1"), 355)).toStrictEqual(new Big("0.01388888888888888889"))
+ expect(Act360.grossToNet(new Big("1"), new Big("1"), 360)).toStrictEqual(new Big("0"))
+ expect(Act360.grossToNet(new Big("1"), new Big("1"), 365)).toStrictEqual(new Big("-0.01388888888888888889"))
+ })
+
+ it("should calculate net amount correctly (2)", () => {
+ expect(Act360.grossToNet(new Big(1), new Big("0.9863"), 365)).toStrictEqual(new Big("0.00000138888888888889"))
+ expect(Act360.grossToNet(new Big(1), new Big("0.9864"), 365)).toStrictEqual(new Big("-0.0001"))
+ expect(Act360.grossToNet(new Big(1), new Big("0.9865"), 365)).toStrictEqual(new Big("-0.00020138888888888889"))
+ })
+ })
+ })
+})
diff --git a/src/utils/discount-util.ts b/src/utils/discount-util.ts
new file mode 100644
index 0000000..9150d04
--- /dev/null
+++ b/src/utils/discount-util.ts
@@ -0,0 +1,19 @@
+import Big from "big.js"
+
+const BIG_1 = new Big("1")
+const BIG_360 = new Big("360")
+
+const factor = (discountRate: Big, days: number) => {
+ const discountDays = discountRate.times(days).div(BIG_360)
+ return BIG_1.minus(discountDays)
+}
+
+export const Act360 = {
+ netToGross: (netAmount: Big, discountRate: Big, days: number): Big | undefined => {
+ const divisor = factor(discountRate, days)
+ return divisor.toNumber() !== 0 ? netAmount.div(divisor) : undefined
+ },
+ grossToNet: (grossAmount: Big, discountRate: Big, days: number): Big => {
+ return grossAmount.times(factor(discountRate, days))
+ },
+}
diff --git a/src/utils/numbers.test.ts b/src/utils/numbers.test.ts
new file mode 100644
index 0000000..1d03258
--- /dev/null
+++ b/src/utils/numbers.test.ts
@@ -0,0 +1,34 @@
+import { describe, it, expect } from "vitest"
+import { parseFloatSafe, parseIntSafe } from "./numbers"
+
+describe("util", () => {
+ describe("parseFloatSafe", () => {
+ it("should safely parse floats", () => {
+ expect(parseFloatSafe("")).toBe(undefined)
+ expect(parseFloatSafe("NaN")).toBe(undefined)
+ expect(parseFloatSafe("Infinity")).toBe(undefined)
+ expect(parseFloatSafe(String(1 / 0))).toBe(undefined)
+ expect(parseFloatSafe("foobar")).toBe(undefined)
+ expect(parseFloatSafe("0")).toBe(0)
+ expect(parseFloatSafe("1")).toBe(1)
+ expect(parseFloatSafe("-1")).toBe(-1)
+ expect(parseFloatSafe("1.23456789")).toBe(1.23456789)
+ expect(parseFloatSafe("-1.23456789")).toBe(-1.23456789)
+ })
+ })
+
+ describe("parseIntSafe", () => {
+ it("should safely parse ints", () => {
+ expect(parseIntSafe("")).toBe(undefined)
+ expect(parseIntSafe("NaN")).toBe(undefined)
+ expect(parseIntSafe("Infinity")).toBe(undefined)
+ expect(parseIntSafe(String(1 / 0))).toBe(undefined)
+ expect(parseIntSafe("foobar")).toBe(undefined)
+ expect(parseIntSafe("0")).toBe(0)
+ expect(parseIntSafe("1")).toBe(1)
+ expect(parseIntSafe("-1")).toBe(-1)
+ expect(parseIntSafe("1.23456789")).toBe(1)
+ expect(parseIntSafe("-1.23456789")).toBe(-1)
+ })
+ })
+})
diff --git a/src/utils/numbers.ts b/src/utils/numbers.ts
new file mode 100644
index 0000000..ed1ae06
--- /dev/null
+++ b/src/utils/numbers.ts
@@ -0,0 +1,11 @@
+export const parseFloatSafe = (str: string | undefined) => {
+ if (str === undefined) return undefined
+ const parsed = parseFloat(str)
+ return isNaN(parsed) || !isFinite(parsed) ? undefined : parsed
+}
+
+export const parseIntSafe = (str: string | undefined) => {
+ if (str === undefined) return undefined
+ const parsed = parseInt(str, 10)
+ return isNaN(parsed) || !isFinite(parsed) ? undefined : parsed
+}
diff --git a/src/utils/strings.ts b/src/utils/strings.ts
new file mode 100644
index 0000000..856ba62
--- /dev/null
+++ b/src/utils/strings.ts
@@ -0,0 +1,8 @@
+export const truncateString = (str: string, maxLength: number): string =>
+ str.length <= maxLength
+ ? str
+ : str.slice(0, Math.floor((maxLength - 3) / 2)) + "…" + str.slice(-Math.floor((maxLength - 3) / 2))
+
+export const formatNumber = (locale: string, value: number): string => {
+ return new Intl.NumberFormat(locale).format(value)
+}