Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Successful in 2m17s
Docker images / frontend-image (push) Successful in 4m11s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m18s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 8s
- Introduced Suspense for lazy loading in StatisticsPage to optimize rendering. - Refactored StatisticsPage to separate inner logic into StatisticsPageInner for better readability. - Updated PeriodSelector to utilize useCallback for handling state changes, improving performance and clarity. Co-authored-by: Cursor <[email protected]>
194 lines
6.2 KiB
TypeScript
194 lines
6.2 KiB
TypeScript
"use client"
|
||
|
||
import { useCallback, useMemo, useState } from "react"
|
||
import { format } from "date-fns"
|
||
import { ru } from "date-fns/locale"
|
||
import { CalendarIcon } from "lucide-react"
|
||
import {
|
||
DateSelector,
|
||
type DateSelectorValue,
|
||
} from "@/components/reui/date-selector"
|
||
import { DATE_SELECTOR_RU } from "@/components/statistics/date-selector-i18n"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||
|
||
/**
|
||
* Период отчёта — DateSelector в Popover (c-date-selector-2).
|
||
* @see https://reui.io/preview/base/components/c-date-selector-2
|
||
* @see https://reui.io/docs/components/base/date-selector
|
||
*/
|
||
|
||
export type PeriodPreset = "today" | "24h" | "7d" | "30d" | "month"
|
||
|
||
export interface DateRangeYmd {
|
||
from: string
|
||
to: string
|
||
}
|
||
|
||
const PRESETS: { id: PeriodPreset; label: string }[] = [
|
||
{ id: "today", label: "Сегодня" },
|
||
{ id: "24h", label: "24 ч" },
|
||
{ id: "7d", label: "7 д" },
|
||
{ id: "30d", label: "30 д" },
|
||
{ id: "month", label: "Месяц" },
|
||
]
|
||
|
||
function pad2(n: number): string {
|
||
return String(n).padStart(2, "0")
|
||
}
|
||
|
||
export function formatYmd(d: Date): string {
|
||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
|
||
}
|
||
|
||
export function parseYmd(s: string): Date | null {
|
||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
|
||
if (!m) return null
|
||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
|
||
return Number.isNaN(d.getTime()) ? null : d
|
||
}
|
||
|
||
export function rangeForPreset(preset: PeriodPreset, now = new Date()): DateRangeYmd {
|
||
const to = formatYmd(now)
|
||
if (preset === "today") return { from: to, to }
|
||
if (preset === "24h") {
|
||
const from = new Date(now)
|
||
from.setDate(from.getDate() - 1)
|
||
return { from: formatYmd(from), to }
|
||
}
|
||
if (preset === "7d") {
|
||
const from = new Date(now)
|
||
from.setDate(from.getDate() - 6)
|
||
return { from: formatYmd(from), to }
|
||
}
|
||
if (preset === "30d") {
|
||
const from = new Date(now)
|
||
from.setDate(from.getDate() - 29)
|
||
return { from: formatYmd(from), to }
|
||
}
|
||
const start = new Date(now.getFullYear(), now.getMonth(), 1)
|
||
return { from: formatYmd(start), to }
|
||
}
|
||
|
||
export function dateSelectorToRange(value: DateSelectorValue): DateRangeYmd | null {
|
||
if (value.period === "day") {
|
||
if (value.operator === "between") {
|
||
if (!value.startDate || !value.endDate) return null
|
||
const a = formatYmd(value.startDate)
|
||
const b = formatYmd(value.endDate)
|
||
return a <= b ? { from: a, to: b } : { from: b, to: a }
|
||
}
|
||
if (value.startDate) {
|
||
const d = formatYmd(value.startDate)
|
||
return { from: d, to: d }
|
||
}
|
||
}
|
||
if (value.period === "month" && value.year != null && value.month != null) {
|
||
const start = new Date(value.year, value.month, 1)
|
||
const end = new Date(value.year, value.month + 1, 0)
|
||
return { from: formatYmd(start), to: formatYmd(end) }
|
||
}
|
||
if (value.period === "year" && value.year != null) {
|
||
return { from: `${value.year}-01-01`, to: `${value.year}-12-31` }
|
||
}
|
||
if (value.period === "quarter" && value.year != null && value.quarter != null) {
|
||
const startMonth = value.quarter * 3
|
||
const start = new Date(value.year, startMonth, 1)
|
||
const end = new Date(value.year, startMonth + 3, 0)
|
||
return { from: formatYmd(start), to: formatYmd(end) }
|
||
}
|
||
if (value.period === "half-year" && value.year != null && value.halfYear != null) {
|
||
const startMonth = value.halfYear * 6
|
||
const start = new Date(value.year, startMonth, 1)
|
||
const end = new Date(value.year, startMonth + 6, 0)
|
||
return { from: formatYmd(start), to: formatYmd(end) }
|
||
}
|
||
return null
|
||
}
|
||
|
||
export function rangeToSelector(range: DateRangeYmd): DateSelectorValue {
|
||
return {
|
||
period: "day",
|
||
operator: "between",
|
||
startDate: parseYmd(range.from) ?? undefined,
|
||
endDate: parseYmd(range.to) ?? undefined,
|
||
}
|
||
}
|
||
|
||
function formatRangeLabel(range: DateRangeYmd): string {
|
||
const from = parseYmd(range.from)
|
||
const to = parseYmd(range.to)
|
||
if (!from || !to) return "Период"
|
||
if (range.from === range.to) return format(from, "d MMM yyyy", { locale: ru })
|
||
return `${format(from, "d MMM", { locale: ru })} – ${format(to, "d MMM yyyy", { locale: ru })}`
|
||
}
|
||
|
||
export function PeriodSelector({
|
||
range,
|
||
onChange,
|
||
}: {
|
||
range: DateRangeYmd
|
||
onChange: (next: DateRangeYmd) => void
|
||
}) {
|
||
const [open, setOpen] = useState(false)
|
||
const selectorValue = useMemo(() => rangeToSelector(range), [range])
|
||
|
||
const handleSelectorChange = useCallback(
|
||
(value: DateSelectorValue) => {
|
||
const next = dateSelectorToRange(value)
|
||
if (!next) return
|
||
if (next.from === range.from && next.to === range.to) return
|
||
onChange(next)
|
||
setOpen(false)
|
||
},
|
||
[onChange, range.from, range.to],
|
||
)
|
||
|
||
const activePreset = PRESETS.find((p) => {
|
||
const r = rangeForPreset(p.id)
|
||
return r.from === range.from && r.to === range.to
|
||
})?.id
|
||
|
||
return (
|
||
<div className="flex flex-wrap items-center gap-1.5">
|
||
{PRESETS.map((p) => (
|
||
<Button
|
||
key={p.id}
|
||
type="button"
|
||
variant={activePreset === p.id ? "secondary" : "ghost"}
|
||
size="sm"
|
||
onClick={() => onChange(rangeForPreset(p.id))}
|
||
>
|
||
{p.label}
|
||
</Button>
|
||
))}
|
||
<Popover open={open} onOpenChange={setOpen} modal={false}>
|
||
<PopoverTrigger
|
||
render={
|
||
<Button type="button" variant="outline" size="sm" className="min-w-40 justify-between" />
|
||
}
|
||
>
|
||
<CalendarIcon className="size-3.5" />
|
||
<span className="tabular-nums">{formatRangeLabel(range)}</span>
|
||
</PopoverTrigger>
|
||
<PopoverContent align="end" className="w-auto min-w-[32rem] max-w-[min(100vw-2rem,42rem)] p-3">
|
||
<DateSelector
|
||
value={selectorValue}
|
||
onChange={handleSelectorChange}
|
||
allowRange
|
||
defaultPeriodType="day"
|
||
defaultFilterType="between"
|
||
periodTypes={["day", "month", "year"]}
|
||
showTwoMonths
|
||
weekStartsOn={1}
|
||
maxYear={2035}
|
||
dayDateFormat="dd.MM.yyyy"
|
||
i18n={DATE_SELECTOR_RU}
|
||
className="sm:w-[470px]"
|
||
/>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
)
|
||
}
|