refactor: colocate frontend component files
Frontend security / security (push) Successful in 9s
Frontend verify / verify (push) Successful in 5m10s
Conventional commits / commits (pull_request) Successful in 25s
Frontend security / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m10s
Frontend security / security (push) Successful in 9s
Frontend verify / verify (push) Successful in 5m10s
Conventional commits / commits (pull_request) Successful in 25s
Frontend security / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m10s
This commit is contained in:
+21
@@ -0,0 +1,21 @@
|
||||
import { Button } from "@mantine/core";
|
||||
import { StateView } from "@wealthledger/ui";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||
|
||||
type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>;
|
||||
|
||||
const AccountIncomePageError: FC<ErrorProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
error
|
||||
title={t("income.errorTitle")}
|
||||
text={t("income.errorText")}
|
||||
action={<Button onClick={props.retry}>{t("income.retry")}</Button>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { AccountIncomePageError };
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { StateView } from "@wealthledger/ui";
|
||||
import type { FC } from "react";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "../AccountIncomePage.module.css";
|
||||
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||
import { IncomeComparisons } from "./IncomeComparisons.view";
|
||||
import { IncomePayments } from "./IncomePayments.view";
|
||||
import { IncomePeriodForm } from "./IncomePeriodForm/IncomePeriodForm.view";
|
||||
import { IncomeSummary } from "./IncomeSummary.view";
|
||||
|
||||
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
|
||||
|
||||
const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
||||
const {
|
||||
analytics,
|
||||
display,
|
||||
form,
|
||||
refreshing,
|
||||
refreshError,
|
||||
retry,
|
||||
onSubmit,
|
||||
onReset,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack gap="lg" className={classes.page}>
|
||||
<Title order={1}>{t("income.title")}</Title>
|
||||
<FormProvider {...form}>
|
||||
<IncomePeriodForm
|
||||
key={props.formRevision}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
onReset={onReset}
|
||||
/>
|
||||
</FormProvider>
|
||||
{refreshing ? (
|
||||
<Group gap="xs" role="status" aria-label={t("income.refreshing")}>
|
||||
<Loader size="sm" />
|
||||
<Text>{t("income.refreshing")}</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
{refreshError ? (
|
||||
<Alert color="orange">
|
||||
<Stack gap="sm">
|
||||
<Text>{t("income.refreshError")}</Text>
|
||||
<Button onClick={retry} variant="default">
|
||||
{t("income.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
{analytics.totalsByCurrency.length ? (
|
||||
<IncomeSummary totals={analytics.totalsByCurrency} />
|
||||
) : (
|
||||
<StateView
|
||||
title={t("income.emptyTitle")}
|
||||
text={t("income.emptyText")}
|
||||
/>
|
||||
)}
|
||||
<Alert color="blue">{t("income.taxBoundary")}</Alert>
|
||||
<IncomeComparisons comparison={display.comparison} />
|
||||
<IncomePayments entries={display.entries} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { AccountIncomePageLoaded };
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { Paper, Skeleton, Stack } from "@mantine/core";
|
||||
import { LoadingSkeleton } from "@wealthledger/ui";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "../AccountIncomePage.module.css";
|
||||
|
||||
const AccountIncomePageLoading: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<LoadingSkeleton label={t("states.loadingTitle")}>
|
||||
<Stack gap="lg">
|
||||
<Skeleton height={32} width="40%" />
|
||||
<Paper p="md" radius="md" withBorder>
|
||||
<Stack gap="sm">
|
||||
<Skeleton height={20} width="20%" />
|
||||
<div className={classes.fields}>
|
||||
<Skeleton height={60} />
|
||||
<Skeleton height={60} />
|
||||
</div>
|
||||
<Skeleton height={44} width="45%" />
|
||||
</Stack>
|
||||
</Paper>
|
||||
<Skeleton height={100} radius="md" />
|
||||
<Skeleton height={180} radius="md" />
|
||||
<Skeleton height={24} width="25%" />
|
||||
<Skeleton height={80} radius="md" />
|
||||
<Skeleton height={80} radius="md" />
|
||||
</Stack>
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
};
|
||||
|
||||
export { AccountIncomePageLoading };
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Paper, Stack, Text, Title } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import classes from "../AccountIncomePage.module.css";
|
||||
import type { IncomeDisplayModel } from "../AccountIncomePage.vm";
|
||||
|
||||
type IncomeComparisonsProps = { comparison: IncomeDisplayModel["comparison"] };
|
||||
type ComparisonProps = NonNullable<IncomeDisplayModel["comparison"]>;
|
||||
type ComparisonValuesProps = {
|
||||
kind: "selected" | "compared" | "difference";
|
||||
label: string;
|
||||
values: ComparisonProps["values"][number]["selected"];
|
||||
percentages?: ComparisonProps["values"][number]["percentages"];
|
||||
};
|
||||
|
||||
const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={2}>{t("income.yearAgo")}</Title>
|
||||
{props.comparison ? (
|
||||
<Comparison {...props.comparison} />
|
||||
) : (
|
||||
<Paper p="sm" radius="md" className={classes.comparisonNotice}>
|
||||
<Text size="sm" c="gray.7">
|
||||
{t("income.yearUnavailable")}
|
||||
</Text>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const Comparison: FC<ComparisonProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section aria-label={t("income.yearAgo")} className={classes.comparison}>
|
||||
<Stack gap="md">
|
||||
{props.values.map((value) => (
|
||||
<Stack
|
||||
gap="xs"
|
||||
key={value.currency}
|
||||
role="group"
|
||||
aria-label={value.currency}
|
||||
>
|
||||
<div className={classes.comparisonGrid}>
|
||||
<ComparisonValues
|
||||
kind="selected"
|
||||
label={props.selectedPeriod}
|
||||
values={value.selected}
|
||||
/>
|
||||
<ComparisonValues
|
||||
kind="compared"
|
||||
label={props.comparedPeriod}
|
||||
values={value.compared}
|
||||
/>
|
||||
<ComparisonValues
|
||||
kind="difference"
|
||||
label={t("income.difference")}
|
||||
values={value.difference}
|
||||
percentages={value.percentages}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
))}
|
||||
{props.values.length === 0 ? (
|
||||
<Text size="sm" c="gray.7">
|
||||
{t("income.comparisonEmpty")}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const ComparisonValues: FC<ComparisonValuesProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section
|
||||
aria-label={props.label}
|
||||
className={classes.comparisonPanel}
|
||||
data-kind={props.kind}
|
||||
data-tone={
|
||||
props.kind === "difference"
|
||||
? differenceTone(props.values.total.value)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Text size="sm" fw={600} className={classes.comparisonLabel}>
|
||||
{props.label}
|
||||
</Text>
|
||||
<dl className={classes.comparisonValues}>
|
||||
{(["total", "coupon", "dividend"] as const).map((key) => {
|
||||
const money = props.values[key];
|
||||
const percentage = props.percentages?.[key];
|
||||
const tone =
|
||||
props.kind === "difference"
|
||||
? differenceTone(money.value)
|
||||
: undefined;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={
|
||||
key === "total"
|
||||
? classes.comparisonTotal
|
||||
: classes.comparisonDetail
|
||||
}
|
||||
>
|
||||
<dt>{t(`income.${key}`)}</dt>
|
||||
<dd data-tone={tone}>
|
||||
<span>
|
||||
{tone === "positive" ? "+" : ""}
|
||||
{formatMoney(money)}
|
||||
</span>
|
||||
{percentage ? (
|
||||
<span className={classes.comparisonPercent}>
|
||||
{percentage}
|
||||
</span>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
function differenceTone(value: string): "positive" | "negative" | "neutral" {
|
||||
if (!/[1-9]/.test(value)) return "neutral";
|
||||
return value.startsWith("-") ? "negative" : "positive";
|
||||
}
|
||||
|
||||
export { IncomeComparisons };
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LedgerEntryRow } from "@/entities/broker-operation";
|
||||
import classes from "../AccountIncomePage.module.css";
|
||||
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||
|
||||
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
|
||||
type IncomePaymentsProps = { entries: LoadedProps["display"]["entries"] };
|
||||
|
||||
const IncomePayments: FC<IncomePaymentsProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack component="section" aria-label={t("income.payments")}>
|
||||
<Title order={2}>{t("income.payments")}</Title>
|
||||
<Stack component="ul" gap="sm" className={classes.entries}>
|
||||
{props.entries.map(({ key, operation }) => (
|
||||
<li key={key}>
|
||||
<LedgerEntryRow operation={operation} />
|
||||
</li>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { IncomePayments };
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { FC } from "react";
|
||||
import { FormProvider, useForm, useFormState } from "react-hook-form";
|
||||
import type { IncomeForm } from "../../../../model/period";
|
||||
import { IncomePeriodForm } from "./IncomePeriodForm.view";
|
||||
|
||||
const period = { from: "01.08.2026", to: "27.08.2026" };
|
||||
const errors = {
|
||||
to: { type: "manual", message: "Синтетическая ошибка периода" },
|
||||
};
|
||||
|
||||
type PeriodExampleProps = { invalid?: boolean };
|
||||
|
||||
const PeriodExample: FC<PeriodExampleProps> = (props) => {
|
||||
const methods = useForm<IncomeForm>({
|
||||
defaultValues: period,
|
||||
errors: props.invalid ? errors : undefined,
|
||||
});
|
||||
const { isSubmitSuccessful } = useFormState({ control: methods.control });
|
||||
const submitted = isSubmitSuccessful ? methods.getValues() : null;
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<IncomePeriodForm
|
||||
onSubmit={methods.handleSubmit(() => undefined)}
|
||||
onReset={() => {
|
||||
methods.reset(period);
|
||||
}}
|
||||
/>
|
||||
<output aria-live="polite">
|
||||
{submitted
|
||||
? `Применено: ${submitted.from} – ${submitted.to}`
|
||||
: "Период не применён"}
|
||||
</output>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "Product/IncomePeriodForm",
|
||||
component: PeriodExample,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Изолированная форма с синтетическим периодом. Submit показывает значения локально; reset возвращает fixture. Ошибка задана явно: query, URL и production validation остаются у page VM, API и router providers не используются.",
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => <PeriodExample />,
|
||||
} satisfies Meta<typeof PeriodExample>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Default: Story = {};
|
||||
export const ValidationError: Story = {
|
||||
render: () => <PeriodExample invalid />,
|
||||
};
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { Button, Paper, Stack, Text } from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import type { FC, FormEventHandler } from "react";
|
||||
import { useController, useFormContext, useWatch } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
formatDraftDate,
|
||||
type IncomeForm,
|
||||
moscowDate,
|
||||
parseDraftDate,
|
||||
} from "../../../../model/period";
|
||||
import classes from "../../AccountIncomePage.module.css";
|
||||
|
||||
type IncomePeriodFormProps = {
|
||||
onSubmit: FormEventHandler<HTMLFormElement>;
|
||||
onReset: () => void;
|
||||
};
|
||||
|
||||
const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
} = useFormContext<IncomeForm>();
|
||||
const fromField = useController({ control, name: "from" });
|
||||
const toField = useController({ control, name: "to" });
|
||||
const [from, to] = useWatch({ control, name: ["from", "to"] });
|
||||
const today = moscowDate(new Date());
|
||||
const start = parseDraftDate(from);
|
||||
const end = parseDraftDate(to);
|
||||
return (
|
||||
<Paper
|
||||
component="form"
|
||||
onSubmit={props.onSubmit}
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text fw={700}>{t("income.period")}</Text>
|
||||
<div className={classes.fields}>
|
||||
<DatePickerInput
|
||||
type="range"
|
||||
allowSingleDateInRange
|
||||
clearable
|
||||
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
|
||||
ref={fromField.field.ref}
|
||||
label={t("income.period")}
|
||||
valueFormat="DD.MM.YYYY"
|
||||
value={[start, end]}
|
||||
onChange={([from, to]) => {
|
||||
fromField.field.onChange(from ? formatDraftDate(from) : "");
|
||||
toField.field.onChange(to ? formatDraftDate(to) : "");
|
||||
}}
|
||||
onBlur={() => {
|
||||
fromField.field.onBlur();
|
||||
toField.field.onBlur();
|
||||
}}
|
||||
maxDate={today}
|
||||
error={
|
||||
fromField.fieldState.error?.message ??
|
||||
toField.fieldState.error?.message
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
popoverProps={{
|
||||
returnFocus: true,
|
||||
middlewares: { shift: { crossAxis: true } },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.actions}>
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
{t("income.apply")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={props.onReset}
|
||||
disabled={isSubmitting}
|
||||
variant="default"
|
||||
>
|
||||
{t("income.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export { IncomePeriodForm };
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Paper, Stack, Text, Title } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { IncomeAnalytics } from "@/entities/income";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
|
||||
type IncomeSummaryProps = { totals: IncomeAnalytics["totalsByCurrency"] };
|
||||
|
||||
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={2}>{t("income.summaryTitle")}</Title>
|
||||
{props.totals.map((total) => (
|
||||
<Paper key={total.currency} p="md" radius="md" withBorder>
|
||||
<Text fw={700}>{formatMoney(total.total)}</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("income.coupon")}: {formatMoney(total.coupon)} ·{" "}
|
||||
{t("income.dividend")}: {formatMoney(total.dividend)}
|
||||
</Text>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { IncomeSummary };
|
||||
Reference in New Issue
Block a user