Skip to content

Commit 34b6950

Browse files
authored
feat: payment receipts (#92)
1 parent 070edc3 commit 34b6950

18 files changed

Lines changed: 1943 additions & 4 deletions

File tree

app/admin/(private)/payments/(default)/layout.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
IconListDetails,
77
IconSettings,
88
} from "@tabler/icons-react";
9+
import { HashIcon } from "lucide-react";
910
import { useTranslation } from "react-i18next";
1011
import { DefautLayout } from "@/app/admin/defaut-layout";
1112
import { SubNavShellRoot } from "@/components/sub-nav-shell";
@@ -29,6 +30,12 @@ const paymentsTabs = [
2930
icon: IconInvoice,
3031
nextUrl: "/admin/payments/bills" as never,
3132
},
33+
{
34+
value: "receiptNumberSeries",
35+
labelKey: "settings:page.receiptNumberSeries",
36+
icon: HashIcon,
37+
nextUrl: "/admin/payments/receipt-number-series" as never,
38+
},
3239
{
3340
value: "settings",
3441
labelKey: "navigation:main.settings",
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"use client";
2+
3+
import { useTranslation } from "react-i18next";
4+
import { SubNavShellContent } from "@/components/sub-nav-shell";
5+
6+
export default function Layout(
7+
props: Readonly<{
8+
children: React.ReactNode;
9+
}>,
10+
) {
11+
const { t } = useTranslation();
12+
13+
return (
14+
<SubNavShellContent title={t("settings:page.receiptNumberSeries")}>
15+
{props.children}
16+
</SubNavShellContent>
17+
);
18+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"use client";
2+
3+
import {
4+
createIdFromString,
5+
type KyselyNotNull,
6+
sqliteTrue,
7+
} from "@evolu/common";
8+
import { useMemo } from "react";
9+
import { useTranslation } from "react-i18next";
10+
import { PaymentReceiptLastNumberForm } from "@/app/admin/(private)/payments/(default)/receipt-number-series/payment-receipt-last-number-form";
11+
import { PaymentReceiptNumberSeriesForm } from "@/app/admin/(private)/payments/(default)/receipt-number-series/payment-receipt-number-series-form";
12+
import { ResponsiveCard } from "@/components/responsive-card";
13+
import {
14+
CardContent,
15+
CardDescription,
16+
CardHeader,
17+
CardTitle,
18+
} from "@/components/ui/card";
19+
import { useEvoluQuery } from "@/hooks/use-evolu-query";
20+
import { createQuery } from "@/lib/evolu";
21+
22+
export default function Page() {
23+
const { t } = useTranslation();
24+
const itemId = createIdFromString("");
25+
26+
const seriesQuery = useMemo(
27+
() =>
28+
createQuery((db) =>
29+
db
30+
.selectFrom("paymentReceiptNumberSeries")
31+
.selectAll()
32+
.where("isDeleted", "is not", sqliteTrue)
33+
.where("serialNumberDigits", "is not", null)
34+
.where("yearFormat", "is not", null)
35+
.where("monthFormat", "is not", null)
36+
.where("dayFormat", "is not", null)
37+
.where("id", "=", itemId)
38+
.$narrowType<{
39+
serialNumberDigits: KyselyNotNull;
40+
yearFormat: KyselyNotNull;
41+
monthFormat: KyselyNotNull;
42+
dayFormat: KyselyNotNull;
43+
}>(),
44+
),
45+
[itemId],
46+
);
47+
48+
const lastNumberQuery = useMemo(
49+
() =>
50+
createQuery((db) =>
51+
db
52+
.selectFrom("paymentReceiptLastNumber")
53+
.select(["id", "serialNumber", "date"])
54+
.where("isDeleted", "is not", sqliteTrue)
55+
.where("serialNumber", "is not", null)
56+
.where("id", "=", itemId)
57+
.$narrowType<{
58+
serialNumber: KyselyNotNull;
59+
}>(),
60+
),
61+
[itemId],
62+
);
63+
64+
const { data: seriesData } = useEvoluQuery(seriesQuery);
65+
const { data: lastNumberData } = useEvoluQuery(lastNumberQuery);
66+
67+
const item = seriesData[0];
68+
const lastNumber = lastNumberData[0];
69+
70+
return (
71+
<div className={"flex flex-col gap-4"}>
72+
<ResponsiveCard className="w-full max-w-xl">
73+
<CardHeader>
74+
<CardTitle>{t("settings:page.receiptNumberSeries")}</CardTitle>
75+
</CardHeader>
76+
<CardContent>
77+
<PaymentReceiptNumberSeriesForm
78+
defaultValues={
79+
item
80+
? {
81+
...item,
82+
serialNumberDigits: item.serialNumberDigits.toString(),
83+
prefix: item.prefix ?? "",
84+
}
85+
: undefined
86+
}
87+
/>
88+
</CardContent>
89+
</ResponsiveCard>
90+
91+
<ResponsiveCard className="w-full max-w-xl">
92+
<CardHeader>
93+
<CardTitle>{t("settings:page.lastReceiptNumber")}</CardTitle>
94+
<CardDescription>
95+
{t("settings:page.lastReceiptNumberDescription")}
96+
</CardDescription>
97+
</CardHeader>
98+
<CardContent>
99+
<PaymentReceiptLastNumberForm
100+
defaultValues={
101+
lastNumber
102+
? {
103+
...lastNumber,
104+
serialNumber: lastNumber.serialNumber.toString(),
105+
date: lastNumber.date ? new Date(lastNumber.date) : null,
106+
}
107+
: undefined
108+
}
109+
/>
110+
</CardContent>
111+
</ResponsiveCard>
112+
</div>
113+
);
114+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { createIdFromString, type Id } from "@evolu/common";
2+
import { merge } from "es-toolkit";
3+
import type { TFunction } from "i18next";
4+
import type React from "react";
5+
import { useMemo, useState } from "react";
6+
import { useTranslation } from "react-i18next";
7+
import type { PartialDeep } from "type-fest";
8+
import { z } from "zod";
9+
import { AutoForm, createAutoFormLayout } from "@/components/auto-form";
10+
import { useActionForm } from "@/hooks/use-action-form";
11+
import { useEvolu } from "@/hooks/use-evolu";
12+
import {
13+
DateToDateStringSchema,
14+
NonNegativeIntegerSchema,
15+
StringToNumberSchema,
16+
} from "@/lib/shared/types";
17+
18+
export const paymentReceiptLastNumberFormSchema = z.object({
19+
serialNumber: StringToNumberSchema.pipe(NonNegativeIntegerSchema),
20+
date: DateToDateStringSchema.nullable(),
21+
});
22+
23+
export const createPaymentReceiptLastNumberDefaultValues = () =>
24+
({
25+
serialNumber: "0",
26+
date: null,
27+
}) satisfies z.input<typeof paymentReceiptLastNumberFormSchema>;
28+
29+
const createComponents = (t: TFunction) =>
30+
createAutoFormLayout(paymentReceiptLastNumberFormSchema, ({ builder }) => ({
31+
...builder.magicInput("serialNumber").text({
32+
label: t(
33+
"settings:form.receipt-last-number-form.label.last-receipt-serial-number",
34+
),
35+
type: "number",
36+
}),
37+
38+
...builder.magicInput("date").date({
39+
label: t(
40+
"settings:form.receipt-last-number-form.label.last-receipt-date",
41+
),
42+
}),
43+
}));
44+
45+
export const PaymentReceiptLastNumberForm: React.FC<{
46+
defaultValues?: PartialDeep<
47+
z.input<typeof paymentReceiptLastNumberFormSchema>
48+
>;
49+
onSuccess?: (newEventId: Id) => unknown;
50+
}> = (params) => {
51+
const { t } = useTranslation();
52+
const evolu = useEvolu();
53+
const [defaultValues] = useState(() =>
54+
merge(
55+
createPaymentReceiptLastNumberDefaultValues(),
56+
params.defaultValues ?? {},
57+
),
58+
);
59+
const components = useMemo(() => createComponents(t), [t]);
60+
const form = useActionForm(paymentReceiptLastNumberFormSchema, {
61+
defaultValues,
62+
saveAction: async (values) => {
63+
const id = createIdFromString("");
64+
65+
evolu.upsert(
66+
"paymentReceiptLastNumber",
67+
{
68+
...values,
69+
id,
70+
},
71+
{
72+
onComplete: () => {
73+
params.onSuccess?.(id);
74+
},
75+
},
76+
);
77+
},
78+
});
79+
80+
return <AutoForm form={form} components={components} />;
81+
};
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { createIdFromString, type Id } from "@evolu/common";
2+
import { merge } from "es-toolkit";
3+
import type { TFunction } from "i18next";
4+
import type React from "react";
5+
import { useMemo, useState } from "react";
6+
import { useTranslation } from "react-i18next";
7+
import type { PartialDeep } from "type-fest";
8+
import { z } from "zod";
9+
import { AutoForm, createAutoFormLayout } from "@/components/auto-form";
10+
import { useActionForm } from "@/hooks/use-action-form";
11+
import { useEvolu } from "@/hooks/use-evolu";
12+
import {
13+
NonEmptyString32Schema,
14+
PositiveIntegerSchema,
15+
StringToNullableStringSchema,
16+
StringToNumberSchema,
17+
} from "@/lib/shared/types";
18+
19+
export const paymentReceiptNumberSeriesFormSchema = z.object({
20+
serialNumberDigits: StringToNumberSchema.pipe(PositiveIntegerSchema),
21+
yearFormat: z.enum(["default", "short"]),
22+
monthFormat: z.enum(["default", "hidden"]),
23+
dayFormat: z.enum(["default", "hidden"]),
24+
prefix: StringToNullableStringSchema.pipe(NonEmptyString32Schema.nullable()),
25+
});
26+
27+
export const createPaymentReceiptNumberSeriesDefaultValues = () =>
28+
({
29+
serialNumberDigits: "4",
30+
yearFormat: "default",
31+
monthFormat: "hidden",
32+
dayFormat: "hidden",
33+
prefix: "R",
34+
}) satisfies z.input<typeof paymentReceiptNumberSeriesFormSchema>;
35+
36+
const now = new Date();
37+
38+
const createComponents = (t: TFunction) =>
39+
createAutoFormLayout(paymentReceiptNumberSeriesFormSchema, ({ builder }) => ({
40+
...builder.magicInput("serialNumberDigits").text({
41+
label: t(
42+
"settings:form.receipt-number-series-form.label.number-of-digits",
43+
),
44+
type: "number",
45+
description: t(
46+
"settings:form.receipt-number-series-form.description.if-you-dont-issue-more-than-9999-receipts-per-time-period-number-4-will-be-opt",
47+
),
48+
}),
49+
50+
...builder.magicInput("yearFormat").select({
51+
values: {
52+
default: `${t("settings:form.receipt-number-series-form.option.default")} (${now.getFullYear()})`,
53+
short: `${t("settings:form.receipt-number-series-form.option.short")} (${now.getFullYear().toString().substring(2)})`,
54+
},
55+
allowEmpty: false,
56+
label: t("settings:form.receipt-number-series-form.label.year-format"),
57+
variant: "toggle",
58+
}),
59+
60+
...builder.magicInput("monthFormat").select({
61+
values: {
62+
default: `${t("settings:form.receipt-number-series-form.option.default")} (${(now.getMonth() + 1).toString().padStart(2, "0")})`,
63+
hidden: t("settings:form.receipt-number-series-form.option.hidden"),
64+
},
65+
allowEmpty: false,
66+
label: t("settings:form.receipt-number-series-form.label.month-format"),
67+
variant: "toggle",
68+
}),
69+
70+
...builder.when("monthFormat", (value) => value !== "hidden", {
71+
...builder.magicInput("dayFormat").select({
72+
values: {
73+
default: `${t("settings:form.receipt-number-series-form.option.default")} (${now.getDate().toString().padStart(2, "0")})`,
74+
hidden: t("settings:form.receipt-number-series-form.option.hidden"),
75+
},
76+
allowEmpty: false,
77+
label: t("settings:form.receipt-number-series-form.label.day-format"),
78+
variant: "toggle",
79+
}),
80+
}),
81+
82+
...builder.magicInput("prefix").text({
83+
label: t(
84+
"settings:form.receipt-number-series-form.label.receipt-number-prefix",
85+
),
86+
}),
87+
}));
88+
89+
export const PaymentReceiptNumberSeriesForm: React.FC<{
90+
defaultValues?: PartialDeep<
91+
z.input<typeof paymentReceiptNumberSeriesFormSchema>
92+
>;
93+
onSuccess?: (newEventId: Id) => unknown;
94+
}> = (params) => {
95+
const { t } = useTranslation();
96+
const evolu = useEvolu();
97+
const [defaultValues] = useState(() =>
98+
merge(
99+
createPaymentReceiptNumberSeriesDefaultValues(),
100+
params.defaultValues ?? {},
101+
),
102+
);
103+
const components = useMemo(() => createComponents(t), [t]);
104+
const form = useActionForm(paymentReceiptNumberSeriesFormSchema, {
105+
defaultValues,
106+
saveAction: async (values) => {
107+
const id = createIdFromString("");
108+
109+
evolu.upsert(
110+
"paymentReceiptNumberSeries",
111+
{
112+
...values,
113+
id,
114+
},
115+
{
116+
onComplete: () => {
117+
params.onSuccess?.(id);
118+
},
119+
},
120+
);
121+
},
122+
});
123+
124+
return <AutoForm form={form} components={components} />;
125+
};

app/admin/(private)/payments/detail/page.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { type FC, type ReactNode, useEffect, useMemo, useRef } from "react";
2828
import { useTranslation } from "react-i18next";
2929
import { FullscreenDialog } from "@/components/fullscreen-dialog";
3030
import { LoadingIndicator } from "@/components/loading-indicator";
31+
import { DownloadPaymentReceiptButton } from "@/components/payments/download-payment-receipt-button";
3132
import { ResponsiveCard } from "@/components/responsive-card";
3233
import { StaticCard } from "@/components/static-card";
3334
import { Button } from "@/components/ui/button";
@@ -865,6 +866,10 @@ export default function Home() {
865866
stoppedAt={payment.paymentWatchingState?.stoppedAt ?? null}
866867
className={"w-full"}
867868
/>
869+
<DownloadPaymentReceiptButton
870+
paymentId={payment.id}
871+
paymentStatus={paymentStatus}
872+
/>
868873
</CardContent>
869874
</ResponsiveCard>
870875

0 commit comments

Comments
 (0)