diff --git a/app/components/ui/$chart.tsx b/app/components/ui/$chart.tsx new file mode 100644 index 00000000..c0140d4d --- /dev/null +++ b/app/components/ui/$chart.tsx @@ -0,0 +1,371 @@ +import { + type ChartConfiguration, + type ChartDataset, + Chart as ChartJS, + type ChartOptions, + type ChartType, + type DefaultDataPoint, +} from "chart.js/auto" +import { useEffect, useRef, useState } from "hono/jsx" +import { twMerge } from "tailwind-merge" + +const CHART_COLOR_TOKENS = [ + "--color-chart-1", + "--color-chart-2", + "--color-chart-3", + "--color-chart-4", + "--color-chart-5", +] as const +type ChartColorToken = (typeof CHART_COLOR_TOKENS)[number] + +const CIRCULAR_CHART_TYPES = new Set([ + "pie", + "doughnut", + "polarArea", +]) + +/** + * Chart.jsの設定型 + */ +type BaseChartConfig = ChartConfiguration< + ChartType, + DefaultDataPoint, + unknown +> +type BaseChartData = BaseChartConfig["data"] +type ChartDatasetWithToken = ChartDataset & { + colorToken?: ChartColorToken +} +export type ChartConfig = Omit & { + data: Omit & { + datasets: ChartDatasetWithToken[] + } +} + +/** + * Chartコンポーネントのプロパティ + */ +export type ChartProps = { + /** Chart.jsの設定 */ + config: ChartConfig + /** 追加のCSSクラス */ + class?: string + /** アクセシビリティ用のラベル(未指定の場合はconfigのtitleを使用) */ + ariaLabel?: string +} + +type ChartDesignTokens = { + palette: string[] + textColor: string + gridColor: string + tooltipBackground: string + tooltipForeground: string + borderColor: string +} + +type ChartPluginOptions = NonNullable["plugins"]> +type LegendSetting = ChartPluginOptions["legend"] +type TooltipSetting = ChartPluginOptions["tooltip"] +type ScalesSetting = ChartOptions["scales"] + +const cssVar = (style: CSSStyleDeclaration, token: string, fallback = "") => { + const value = style.getPropertyValue(token).trim() + return value || fallback +} + +const readDesignTokens = (): ChartDesignTokens | null => { + if (typeof window === "undefined") { + return null + } + const style = window.getComputedStyle(document.documentElement) + return { + palette: CHART_COLOR_TOKENS.map((token, index) => + cssVar(style, token, `rgba(0, 0, 0, ${0.8 - index * 0.1})`), + ), + textColor: cssVar(style, "--color-muted-fg", "#1f2937"), + gridColor: cssVar(style, "--color-border", "rgba(148, 163, 184, 0.5)"), + tooltipBackground: cssVar( + style, + "--color-overlay", + "rgba(15, 23, 42, 0.9)", + ), + tooltipForeground: cssVar(style, "--color-overlay-fg", "#f8fafc"), + borderColor: cssVar(style, "--color-border", "rgba(148, 163, 184, 0.5)"), + } +} + +const useThemeVersion = () => { + const [version, setVersion] = useState(0) + + useEffect(() => { + if (typeof window === "undefined") return + const target = document.documentElement + const handleChange = () => setVersion((prev) => prev + 1) + const observer = new MutationObserver(handleChange) + observer.observe(target, { + attributes: true, + attributeFilter: ["class", "data-theme", "style"], + }) + + const media = window.matchMedia("(prefers-color-scheme: dark)") + if (typeof media.addEventListener === "function") { + media.addEventListener("change", handleChange) + } + + return () => { + observer.disconnect() + if (typeof media.removeEventListener === "function") { + media.removeEventListener("change", handleChange) + } + } + }, []) + + return version +} + +const buildSegmentColors = (count: number, palette: string[]) => { + if (count <= 0) { + return palette + } + return Array.from({ length: count }, (_, index) => { + const paletteIndex = index % palette.length + return palette[paletteIndex] ?? palette[0] ?? "rgba(0, 0, 0, 0.6)" + }) +} + +const resolveColorToken = ( + token: ChartColorToken | undefined, + tokens: ChartDesignTokens, +) => { + if (!token) { + return null + } + const index = CHART_COLOR_TOKENS.indexOf(token) + if (index === -1) { + return null + } + return tokens.palette[index] ?? null +} + +const prepareDatasets = ( + config: ChartConfig, + tokens: ChartDesignTokens, + labelCount: number, +) => { + return config.data.datasets.map((dataset, index) => { + const datasetType: ChartType = dataset.type ?? config.type + const { colorToken, ...datasetRest } = dataset + const styled: ChartDataset = { ...datasetRest } + const paletteFallback = + tokens.palette[index % tokens.palette.length] ?? + tokens.palette[0] ?? + "rgba(0, 0, 0, 0.6)" + const paletteColor = + resolveColorToken(colorToken, tokens) ?? paletteFallback + + if (CIRCULAR_CHART_TYPES.has(datasetType)) { + const segmentCount = Array.isArray(dataset.data) + ? dataset.data.length + : labelCount + const colors = buildSegmentColors(segmentCount, tokens.palette) + const borderColors = colors.map(() => tokens.borderColor) + if (!styled.backgroundColor) { + styled.backgroundColor = colors + } + if (!styled.borderColor) { + styled.borderColor = + segmentCount > 0 ? borderColors : tokens.borderColor + } + return styled + } + + if (!styled.borderColor) { + styled.borderColor = paletteColor + } + if (!styled.backgroundColor) { + styled.backgroundColor = paletteColor + } + + return styled + }) +} + +const prepareLegend = ( + legend: LegendSetting | undefined, + tokens: ChartDesignTokens, +): LegendSetting => { + const labels = legend?.labels ?? {} + return { + display: legend?.display ?? true, + position: legend?.position ?? "top", + ...legend, + labels: { + color: labels.color ?? tokens.textColor, + usePointStyle: labels.usePointStyle ?? true, + ...labels, + }, + } +} + +const prepareTooltip = ( + tooltip: TooltipSetting | undefined, + tokens: ChartDesignTokens, +): TooltipSetting => { + return { + backgroundColor: tooltip?.backgroundColor ?? tokens.tooltipBackground, + titleColor: tooltip?.titleColor ?? tokens.tooltipForeground, + bodyColor: tooltip?.bodyColor ?? tokens.tooltipForeground, + borderColor: tooltip?.borderColor ?? tokens.borderColor, + borderWidth: tooltip?.borderWidth ?? 1, + displayColors: tooltip?.displayColors ?? true, + ...tooltip, + } +} + +const prepareScales = ( + config: ChartConfig, + tokens: ChartDesignTokens, +): ScalesSetting => { + const provided = config.options?.scales + if (provided) { + const next: NonNullable = {} + for (const axis of Object.keys(provided) as Array< + keyof NonNullable + >) { + const scale = provided[axis] + if (!scale) { + next[axis] = scale + continue + } + const grid = scale.grid ?? {} + const ticks = scale.ticks ?? {} + next[axis] = { + ...scale, + grid: { + color: grid.color ?? tokens.gridColor, + ...grid, + }, + ticks: { + color: ticks.color ?? tokens.textColor, + ...ticks, + }, + } + } + return next + } + + if (CIRCULAR_CHART_TYPES.has(config.type)) { + return undefined + } + + const fallback: NonNullable = { + x: { + grid: { color: tokens.gridColor }, + }, + y: { + beginAtZero: true, + grid: { color: tokens.gridColor }, + ticks: { color: tokens.textColor }, + }, + } + return fallback +} + +const buildChartConfig = ( + config: ChartConfig, + tokens: ChartDesignTokens, +): ChartConfig => { + const labels = config.data.labels ? [...config.data.labels] : [] + const datasets = prepareDatasets(config, tokens, labels.length) + const baseOptions = config.options ?? {} + const pluginOverrides = baseOptions.plugins + + const options: ChartOptions = { + responsive: baseOptions.responsive ?? true, + maintainAspectRatio: baseOptions.maintainAspectRatio ?? false, + ...baseOptions, + plugins: { + ...pluginOverrides, + legend: prepareLegend(pluginOverrides?.legend, tokens), + tooltip: prepareTooltip(pluginOverrides?.tooltip, tokens), + }, + scales: prepareScales(config, tokens), + } + + return { + ...config, + data: { + ...config.data, + labels, + datasets, + }, + options, + } +} + +const updateChartInstance = (chart: ChartJS, config: ChartConfig) => { + const chartConfig = chart.config as ChartConfig + chartConfig.type = config.type + chart.options = config.options ?? {} + chartConfig.options = config.options + chartConfig.data = config.data + chartConfig.plugins = config.plugins + chart.data.labels = config.data.labels ?? [] + chart.data.datasets = config.data.datasets + chart.update() +} + +const extractTitleText = (config: ChartConfig) => { + const title = config.options?.plugins?.title?.text + if (!title) return "" + return Array.isArray(title) ? title.join(" / ") : title +} + +type ChartInstance = ChartJS, unknown> + +/** + * Chart.jsを使用したチャートコンポーネント + * + * デザイントークンを自動的に適用し、テーマの変更に対応します。 + */ +const Chart = ({ config, class: className, ariaLabel }: ChartProps) => { + const canvasRef = useRef(null) + const chartRef = useRef(null) + const themeVersion = useThemeVersion() + + useEffect(() => { + if (!canvasRef.current) return + const tokens = readDesignTokens() + if (!tokens) return + + const normalizedConfig = buildChartConfig(config, tokens) + if (!chartRef.current) { + chartRef.current = new ChartJS(canvasRef.current, normalizedConfig) + return + } + updateChartInstance(chartRef.current, normalizedConfig) + }, [config, themeVersion]) + + useEffect(() => { + return () => { + chartRef.current?.destroy() + chartRef.current = null + } + }, []) + + const fallbackLabel = ariaLabel ?? extractTitleText(config) + const containerClass = twMerge("relative h-full w-full min-h-0", className) + + return ( +
+ +
+ ) +} + +export default Chart diff --git a/app/domain/order/constants.ts b/app/domain/order/constants.ts new file mode 100644 index 00000000..8b2d86cf --- /dev/null +++ b/app/domain/order/constants.ts @@ -0,0 +1,8 @@ +import type Order from "./entities/order" + +export const ORDER_STATUSES: Order["status"][] = [ + "pending", + "processing", + "completed", + "cancelled", +] diff --git a/app/domain/order/entities/order.ts b/app/domain/order/entities/order.ts index ce5cf24d..515b63bd 100644 --- a/app/domain/order/entities/order.ts +++ b/app/domain/order/entities/order.ts @@ -1,9 +1,11 @@ +type OrderStatus = "pending" | "processing" | "completed" | "cancelled" + type Order = { id: number customerName: string | null createdAt: Date updatedAt: Date - status: "pending" | "processing" | "completed" | "cancelled" + status: OrderStatus orderItems: { productId: number | null productName: string diff --git a/app/domain/order/repositories/orderCommandRepository.ts b/app/domain/order/repositories/orderCommandRepository.ts index 278cd3d2..7f909f07 100644 --- a/app/domain/order/repositories/orderCommandRepository.ts +++ b/app/domain/order/repositories/orderCommandRepository.ts @@ -5,14 +5,10 @@ import { } from "../../../infrastructure/domain/order/orderCommandRepositoryImpl" import { countStringLength } from "../../../utils/text" import type { CommandRepositoryFunction, WithRepositoryImpl } from "../../types" +import { ORDER_STATUSES } from "../constants" import type Order from "../entities/order" -const ALLOWED_ORDER_STATUSES = new Set([ - "pending", - "processing", - "completed", - "cancelled", -]) +const ALLOWED_ORDER_STATUSES = new Set(ORDER_STATUSES) const validateOrder = ( order: Partial>, diff --git a/app/domain/order/repositories/orderQueryRepository.ts b/app/domain/order/repositories/orderQueryRepository.ts index 70b9236b..4cb945d0 100644 --- a/app/domain/order/repositories/orderQueryRepository.ts +++ b/app/domain/order/repositories/orderQueryRepository.ts @@ -1,8 +1,10 @@ import { + findAllDailyOrderAggregationsImpl, findAllOrdersByActiveStatusByUpdatedAtAscImpl, findAllOrdersByInactiveStatusByUpdatedAtDescImpl, findAllOrdersImpl, findOrderByIdImpl, + findOrderStatusCountsImpl, } from "../../../infrastructure/domain/order/orderQueryRepositoryImpl" import type { PaginatedQueryRepositoryFunction, @@ -24,6 +26,32 @@ export type FindAllOrdersByActiveStatusOrderByUpdatedAtAsc = export type FindAllOrdersByInactiveStatusOrderByUpdatedAtDesc = PaginatedQueryRepositoryFunction, Order> +type OrderStatusCount = { + status: Order["status"] + count: number +} + +type OrderDailyAggregation = { + date: Date + orderCount: number + revenue: number +} + +export type FindOrderStatusCounts = QueryRepositoryFunction< + Record, + OrderStatusCount[] +> + +export type FindAllDailyOrderAggregations = PaginatedQueryRepositoryFunction< + { + orderCreatedAtRange: { + from: Date + to: Date + } + }, + OrderDailyAggregation +> + export const findOrderById: WithRepositoryImpl = async ({ order, repositoryImpl = findOrderByIdImpl, @@ -59,3 +87,20 @@ export const findAllOrdersByInactiveStatusOrderByUpdatedAtDesc: WithRepositoryIm }) => { return repositoryImpl({ dbClient, pagination }) } + +export const findOrderStatusCounts: WithRepositoryImpl< + FindOrderStatusCounts +> = async ({ repositoryImpl = findOrderStatusCountsImpl, dbClient }) => { + return repositoryImpl({ dbClient }) +} + +export const findAllDailyOrderAggregations: WithRepositoryImpl< + FindAllDailyOrderAggregations +> = async ({ + repositoryImpl = findAllDailyOrderAggregationsImpl, + dbClient, + orderCreatedAtRange, + pagination, +}) => { + return repositoryImpl({ dbClient, pagination, orderCreatedAtRange }) +} diff --git a/app/infrastructure/domain/order/orderQueryRepositoryImpl.ts b/app/infrastructure/domain/order/orderQueryRepositoryImpl.ts index 5aff5427..d0f2ed1c 100644 --- a/app/infrastructure/domain/order/orderQueryRepositoryImpl.ts +++ b/app/infrastructure/domain/order/orderQueryRepositoryImpl.ts @@ -1,9 +1,11 @@ -import { asc, desc, eq, inArray } from "drizzle-orm" +import { and, asc, desc, eq, gte, inArray, lte, sql } from "drizzle-orm" import type { + FindAllDailyOrderAggregations, FindAllOrders, FindAllOrdersByActiveStatusOrderByUpdatedAtAsc, FindAllOrdersByInactiveStatusOrderByUpdatedAtDesc, FindOrderById, + FindOrderStatusCounts, } from "../../../domain/order/repositories/orderQueryRepository" import { orderTable } from "../../db/schema" @@ -116,3 +118,59 @@ export const findAllOrdersByInactiveStatusByUpdatedAtDescImpl: FindAllOrdersByIn })) return orders } + +export const findOrderStatusCountsImpl: FindOrderStatusCounts = async ({ + dbClient, +}) => { + const rows = await dbClient + .select({ + status: orderTable.status, + count: sql`count(${orderTable.id})`, + }) + .from(orderTable) + .groupBy(orderTable.status) + + return rows.map((row) => ({ + status: row.status, + count: Number(row.count ?? 0), + })) +} + +export const findAllDailyOrderAggregationsImpl: FindAllDailyOrderAggregations = + async ({ dbClient, pagination, orderCreatedAtRange }) => { + const dayExpression = sql`date_trunc('day', ${orderTable.createdAt})` + const rows = await dbClient + .select({ + day: dayExpression, + orderCount: sql`count(${orderTable.id})`, + revenue: sql`coalesce(sum(${orderTable.totalAmount}), 0)`, + }) + .from(orderTable) + .where( + and( + gte(orderTable.createdAt, orderCreatedAtRange.from), + lte(orderTable.createdAt, orderCreatedAtRange.to), + ), + ) + .groupBy(dayExpression) + .orderBy(dayExpression) + .limit(pagination.limit) + .offset(pagination.offset) + + const aggregations = rows.map((row) => { + const dayValue = + row.day instanceof Date ? row.day : new Date(row.day as string) + if (Number.isNaN(dayValue.getTime())) { + throw new Error( + `Invalid date value encountered in daily order aggregation: ${row.day}`, + ) + } + return { + date: dayValue, + orderCount: Number(row.orderCount ?? 0), + revenue: Number(row.revenue ?? 0), + } + }) + + return aggregations + } diff --git a/app/routes/staff/-components/$dailyOrdersTrendCard.tsx b/app/routes/staff/-components/$dailyOrdersTrendCard.tsx new file mode 100644 index 00000000..5bb259f2 --- /dev/null +++ b/app/routes/staff/-components/$dailyOrdersTrendCard.tsx @@ -0,0 +1,124 @@ +import type { ChartConfig } from "../../../components/ui/$chart" +import Chart from "../../../components/ui/$chart" +import type { StaffDashboardPageData } from "../../../usecases/getStaffDashboardPageData" +import { formatDateJP } from "../../../utils/date" +import { formatCurrencyJPY } from "../../../utils/money" + +type DailyOrdersTrendCardProps = { + data: StaffDashboardPageData["dailyOrders"] +} + +/** + * チャートの値を数値に変換する + * @param value - チャートライブラリから渡される値(数値または複雑な型の可能性がある) + * @returns 数値に変換された値、変換できない場合はnull + */ +const parseChartValue = (value: unknown): number | null => { + if (typeof value === "number") { + return value + } + if (typeof value === "object" && value !== null && "y" in value) { + const yValue = (value as { y?: unknown }).y + if (typeof yValue === "number") { + return yValue + } + } + const numericValue = Number(value) + return Number.isNaN(numericValue) ? null : numericValue +} + +const DailyOrdersTrendCard = ({ data }: DailyOrdersTrendCardProps) => { + const labels = data.map((entry) => formatDateJP(entry.date)) + const orderCounts = data.map((entry) => entry.orderCount) + const revenues = data.map((entry) => entry.revenue) + + const chartConfig: ChartConfig = { + type: "line", + data: { + labels, + datasets: [ + { + label: "注文数", + data: orderCounts, + yAxisID: "orders", + tension: 0.35, + fill: false, + colorToken: "--color-chart-1", + borderWidth: 2, + }, + { + label: "売上", + data: revenues, + yAxisID: "revenue", + tension: 0.35, + fill: false, + colorToken: "--color-chart-4", + borderWidth: 2, + }, + ], + }, + options: { + interaction: { mode: "index", intersect: false }, + scales: { + x: { grid: { display: false } }, + orders: { + type: "linear", + position: "left", + beginAtZero: true, + title: { display: true, text: "件数" }, + ticks: { + callback: (value) => { + const numericValue = parseChartValue(value) + return numericValue !== null ? `${numericValue}件` : String(value) + }, + }, + }, + revenue: { + type: "linear", + position: "right", + beginAtZero: true, + grid: { display: false }, + title: { display: true, text: "売上" }, + ticks: { + callback: (value) => { + const numericValue = parseChartValue(value) + return numericValue !== null + ? formatCurrencyJPY(numericValue) + : String(value) + }, + }, + }, + }, + plugins: { + tooltip: { + callbacks: { + label: (context) => { + const parsedValue = parseChartValue(context.parsed) ?? 0 + const label = context.dataset.label ?? "" + if (context.dataset.yAxisID === "revenue") { + return `${label}: ${formatCurrencyJPY(parsedValue)}` + } + return `${label}: ${parsedValue}件` + }, + }, + }, + }, + }, + } + + return ( +
+
+

7日間の推移

+

日別の注文数と売上の推移です

+
+ +
+ ) +} + +export default DailyOrdersTrendCard diff --git a/app/routes/staff/-components/$statusDistributionCard.tsx b/app/routes/staff/-components/$statusDistributionCard.tsx new file mode 100644 index 00000000..b701cefe --- /dev/null +++ b/app/routes/staff/-components/$statusDistributionCard.tsx @@ -0,0 +1,47 @@ +import type { ChartConfig } from "../../../components/ui/$chart" +import Chart from "../../../components/ui/$chart" +import type { StaffDashboardPageData } from "../../../usecases/getStaffDashboardPageData" + +type StatusDistributionCardProps = { + data: StaffDashboardPageData["statusDistribution"] +} + +const StatusDistributionCard = ({ data }: StatusDistributionCardProps) => { + const chartConfig: ChartConfig = { + type: "pie", + data: { + labels: data.map((entry) => entry.label), + datasets: [ + { + label: "注文数", + data: data.map((entry) => entry.count), + }, + ], + }, + options: { + plugins: { + legend: { + position: "right", + }, + }, + }, + } + + return ( +
+
+

ステータス別注文数

+

+ 各ステータスの注文数と比率を把握できます +

+
+ +
+ ) +} + +export default StatusDistributionCard diff --git a/app/routes/staff/index.tsx b/app/routes/staff/index.tsx index 95666a8c..80523612 100644 --- a/app/routes/staff/index.tsx +++ b/app/routes/staff/index.tsx @@ -6,16 +6,23 @@ import ClipboardListIcon from "../../components/icons/lucide/clipboardListIcon" import PackageIcon from "../../components/icons/lucide/packageIcon" import SettingsIcon from "../../components/icons/lucide/settingsIcon" import ShoppingCartIcon from "../../components/icons/lucide/shoppingCartIcon" +import { + getStaffDashboardPageData, + type StaffDashboardPageData, +} from "../../usecases/getStaffDashboardPageData" +import { formatCurrencyJPY } from "../../utils/money" +import DailyOrdersTrendCard from "./-components/$dailyOrdersTrendCard" +import StatusDistributionCard from "./-components/$statusDistributionCard" import Layout from "./-components/layout" -type DashboardCard = { +type QuickAccessCard = { title: string description: string href: string icon: FC } -const dashboardCards: DashboardCard[] = [ +const quickAccessCards: QuickAccessCard[] = [ { title: "注文一覧", description: "注文の確認と管理を行います", @@ -54,7 +61,7 @@ const dashboardCards: DashboardCard[] = [ }, ] -const DashboardCard: FC = ({ +const QuickAccessCard: FC = ({ title, description, href, @@ -80,16 +87,92 @@ const DashboardCard: FC = ({ ) } -export default createRoute((c) => { +type DashboardStatCardProps = { + title: string + value: string + description?: string +} + +const DashboardStatCard = ({ + title, + value, + description, +}: DashboardStatCardProps) => { + return ( +
+

{title}

+

{value}

+ {description && ( +

{description}

+ )} +
+ ) +} + +const buildDashboardStatCards = ( + summary: StaffDashboardPageData["summary"], +): DashboardStatCardProps[] => { + return [ + { + title: "本日の注文数", + value: `${summary.todayOrderCount}件`, + description: "本日0時以降の累計", + }, + { + title: "本日の売上", + value: formatCurrencyJPY(summary.todayRevenue), + description: "税込み想定", + }, + { + title: "未処理の注文", + value: `${summary.pendingOrderCount}件`, + description: "pending状態の件数", + }, + { + title: "7日間の平均客単価", + value: formatCurrencyJPY(summary.averageOrderValue7d), + description: `7日間の売上合計 ${formatCurrencyJPY(summary.totalRevenue7d)}`, + }, + ] +} + +export default createRoute(async (c) => { + const dashboardData = await getStaffDashboardPageData({ + dbClient: c.get("dbClient"), + }) + + const statCards = buildDashboardStatCards(dashboardData.summary) + return c.render( -
-

クイックアクセス

-
- {dashboardCards.map((card) => ( - - ))} -
+
+
+

サマリー

+
+ {statCards.map((card) => ( + + ))} +
+
+ +
+

クイックアクセス

+
+ {quickAccessCards.map((card) => ( + + ))} +
+
+ +
+ + +
, ) diff --git a/app/usecases/getStaffDashboardPageData.test.ts b/app/usecases/getStaffDashboardPageData.test.ts new file mode 100644 index 00000000..60debfb1 --- /dev/null +++ b/app/usecases/getStaffDashboardPageData.test.ts @@ -0,0 +1,163 @@ +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test" +import type { + FindAllDailyOrderAggregations, + FindOrderStatusCounts, +} from "../domain/order/repositories/orderQueryRepository" +import * as orderQueryRepository from "../domain/order/repositories/orderQueryRepository" +import type { DbClient } from "../infrastructure/db/client" +import { + addDays, + buildDateRange, + endOfDay, + formatDateKey, + startOfDay, +} from "../utils/date" +import { getStaffDashboardPageData } from "./getStaffDashboardPageData" + +const dbClient = {} as DbClient +const DAILY_RANGE_DAYS = 7 +const FIXED_NOW = new Date("2025-02-08T10:15:30.000Z") + +const getDateRangeStart = () => + startOfDay(addDays(FIXED_NOW, -(DAILY_RANGE_DAYS - 1))) +const getDateRangeEnd = () => endOfDay(FIXED_NOW) +const buildExpectedDailyKeys = () => + buildDateRange(getDateRangeStart(), DAILY_RANGE_DAYS).map(formatDateKey) +const createAggregationDate = (base: Date, offset: number, hour = 10) => { + const date = addDays(base, offset) + date.setHours(hour, 0, 0, 0) + return date +} + +type OrderStatusCount = Awaited>[number] +type OrderDailyAggregation = Awaited< + ReturnType +>[number] + +describe("getStaffDashboardPageData", () => { + afterEach(() => { + mock.restore() + }) + + it("直近7日間の集計と注文状況を取得できる", async () => { + const statusCounts: OrderStatusCount[] = [ + { status: "pending", count: 5 }, + { status: "completed", count: 8 }, + ] + spyOn(orderQueryRepository, "findOrderStatusCounts").mockResolvedValue( + statusCounts, + ) + + const summaryEntries: Array< + [number, { orderCount: number; revenue: number }] + > = [ + [0, { orderCount: 3, revenue: 4500 }], + [3, { orderCount: 1, revenue: 800 }], + [6, { orderCount: 4, revenue: 6000 }], + ] + const dateRangeStart = getDateRangeStart() + const summaryByOffset = new Map(summaryEntries) + const dailyAggregations: OrderDailyAggregation[] = summaryEntries.map( + ([offset, summary]) => ({ + date: createAggregationDate(dateRangeStart, offset, 9 + offset), + ...summary, + }), + ) + const findAllDailyOrderAggregationsSpy = spyOn( + orderQueryRepository, + "findAllDailyOrderAggregations", + ).mockResolvedValue(dailyAggregations) + + const result = await getStaffDashboardPageData({ + dbClient, + getCurrentTime: () => FIXED_NOW, + }) + + expect(orderQueryRepository.findOrderStatusCounts).toHaveBeenCalledWith({ + dbClient, + }) + expect(findAllDailyOrderAggregationsSpy).toHaveBeenCalledWith({ + dbClient, + orderCreatedAtRange: { + from: dateRangeStart, + to: getDateRangeEnd(), + }, + pagination: { offset: 0, limit: DAILY_RANGE_DAYS }, + }) + + const expectedDailyOrders = buildExpectedDailyKeys().map( + (dateKey, index) => { + const summary = summaryByOffset.get(index) + return { + date: dateKey, + orderCount: summary?.orderCount ?? 0, + revenue: summary?.revenue ?? 0, + } + }, + ) + expect(result.dailyOrders).toEqual(expectedDailyOrders) + + expect(result.statusDistribution).toEqual([ + { status: "pending", label: "受付待ち", count: 5 }, + { status: "processing", label: "処理中", count: 0 }, + { status: "completed", label: "完了", count: 8 }, + { status: "cancelled", label: "取消済", count: 0 }, + ]) + + const totalOrders = Array.from(summaryByOffset.values()).reduce( + (total, day) => total + day.orderCount, + 0, + ) + const totalRevenue = Array.from(summaryByOffset.values()).reduce( + (total, day) => total + day.revenue, + 0, + ) + const todaySummary = + summaryByOffset.get(DAILY_RANGE_DAYS - 1) ?? + ({ orderCount: 0, revenue: 0 } as const) + + expect(result.summary).toEqual({ + todayOrderCount: todaySummary.orderCount, + todayRevenue: todaySummary.revenue, + pendingOrderCount: 5, + averageOrderValue7d: Math.round(totalRevenue / totalOrders), + totalRevenue7d: totalRevenue, + }) + }) + + it("データがない場合はゼロ埋めした結果を返す", async () => { + spyOn(orderQueryRepository, "findOrderStatusCounts").mockResolvedValue([]) + spyOn( + orderQueryRepository, + "findAllDailyOrderAggregations", + ).mockResolvedValue([]) + + const result = await getStaffDashboardPageData({ + dbClient, + getCurrentTime: () => FIXED_NOW, + }) + + expect(result.statusDistribution).toEqual([ + { status: "pending", label: "受付待ち", count: 0 }, + { status: "processing", label: "処理中", count: 0 }, + { status: "completed", label: "完了", count: 0 }, + { status: "cancelled", label: "取消済", count: 0 }, + ]) + + expect(result.dailyOrders).toEqual( + buildExpectedDailyKeys().map((dateKey) => ({ + date: dateKey, + orderCount: 0, + revenue: 0, + })), + ) + + expect(result.summary).toEqual({ + todayOrderCount: 0, + todayRevenue: 0, + pendingOrderCount: 0, + averageOrderValue7d: 0, + totalRevenue7d: 0, + }) + }) +}) diff --git a/app/usecases/getStaffDashboardPageData.ts b/app/usecases/getStaffDashboardPageData.ts new file mode 100644 index 00000000..5b861548 --- /dev/null +++ b/app/usecases/getStaffDashboardPageData.ts @@ -0,0 +1,134 @@ +import { ORDER_STATUSES as DOMAIN_ORDER_STATUSES } from "../domain/order/constants" +import type Order from "../domain/order/entities/order" +import { + findAllDailyOrderAggregations, + findOrderStatusCounts, +} from "../domain/order/repositories/orderQueryRepository" +import type { DbClient } from "../infrastructure/db/client" +import { + addDays, + buildDateRange, + endOfDay, + formatDateKey, + startOfDay, +} from "../utils/date" + +const ORDER_STATUS_LABELS: Record = { + pending: "受付待ち", + processing: "処理中", + completed: "完了", + cancelled: "取消済", +} as const + +const ORDER_STATUSES: { status: Order["status"]; label: string }[] = + DOMAIN_ORDER_STATUSES.map((status) => ({ + status, + label: ORDER_STATUS_LABELS[status], + })) + +const DAILY_RANGE_DAYS = 7 +const DAY_IN_MS = 1000 * 60 * 60 * 24 +export type StaffDashboardPageData = { + summary: { + todayOrderCount: number + todayRevenue: number + pendingOrderCount: number + averageOrderValue7d: number + totalRevenue7d: number + } + statusDistribution: { + status: Order["status"] + label: string + count: number + }[] + dailyOrders: { + date: string + orderCount: number + revenue: number + }[] +} + +type GetStaffDashboardPageDataParams = { + dbClient: DbClient + getCurrentTime?: () => Date +} + +export const getStaffDashboardPageData = async ({ + dbClient, + getCurrentTime = () => new Date(), +}: GetStaffDashboardPageDataParams): Promise => { + const now = getCurrentTime() + const dateRangeStart = startOfDay(addDays(now, -(DAILY_RANGE_DAYS - 1))) + const dateRangeEnd = endOfDay(now) + const pagination = { + offset: 0, + limit: + Math.floor( + (dateRangeEnd.getTime() - dateRangeStart.getTime()) / DAY_IN_MS, + ) + 1, + } + const todayKey = formatDateKey(startOfDay(now)) + + const [statusCounts, dailyAggregations] = await Promise.all([ + findOrderStatusCounts({ dbClient }), + findAllDailyOrderAggregations({ + dbClient, + orderCreatedAtRange: { from: dateRangeStart, to: dateRangeEnd }, + pagination, + }), + ]) + + const statusDistribution = ORDER_STATUSES.map((entry) => ({ + ...entry, + count: + statusCounts.find((item) => item.status === entry.status)?.count ?? 0, + })) + + const dailyAggregationMap = new Map( + dailyAggregations.map((aggregation) => [ + formatDateKey(startOfDay(aggregation.date)), + { + orderCount: aggregation.orderCount, + revenue: aggregation.revenue, + }, + ]), + ) + + const dailyOrders = buildDateRange(dateRangeStart, DAILY_RANGE_DAYS).map( + (date) => { + const key = formatDateKey(date) + const summary = dailyAggregationMap.get(key) + + return { + date: key, + orderCount: summary?.orderCount ?? 0, + revenue: summary?.revenue ?? 0, + } + }, + ) + + const totalOrders7d = dailyOrders.reduce( + (total, day) => total + day.orderCount, + 0, + ) + const totalRevenue7d = dailyOrders.reduce( + (total, day) => total + day.revenue, + 0, + ) + const todaySummary = dailyOrders.find((day) => day.date === todayKey) + + return { + summary: { + todayOrderCount: todaySummary?.orderCount ?? 0, + todayRevenue: todaySummary?.revenue ?? 0, + pendingOrderCount: + statusDistribution.find((entry) => entry.status === "pending")?.count ?? + 0, + averageOrderValue7d: + totalOrders7d > 0 ? Math.round(totalRevenue7d / totalOrders7d) : 0, + totalRevenue7d, + }, + statusDistribution, + dailyOrders, + } +} diff --git a/app/utils/date.test.ts b/app/utils/date.test.ts index f1288a88..9133e4cf 100644 --- a/app/utils/date.test.ts +++ b/app/utils/date.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from "bun:test" -import { formatDateJP, formatDateTimeJP, formatTimeJP } from "./date" +import { + addDays, + buildDateRange, + endOfDay, + formatDateJP, + formatDateKey, + formatDateTimeJP, + formatTimeJP, + startOfDay, +} from "./date" describe("formatDateTimeJP", () => { it("日付を日本語の日時形式でフォーマットできる", () => { @@ -51,3 +60,73 @@ describe("formatTimeJP", () => { expect(() => formatTimeJP(new Date("invalid"))).toThrow("Invalid date") }) }) + +describe("addDays", () => { + it("指定した日数を加算した新しい日付を返す", () => { + const date = new Date("2024-01-15T00:00:00Z") + const result = addDays(date, 3) + + expect(result.toISOString()).toBe("2024-01-18T00:00:00.000Z") + expect(date.toISOString()).toBe("2024-01-15T00:00:00.000Z") + }) + + it("負の値を加算できる", () => { + const date = new Date("2024-01-15T00:00:00Z") + const result = addDays(date, -2) + + expect(result.toISOString()).toBe("2024-01-13T00:00:00.000Z") + }) +}) + +describe("startOfDay", () => { + it("日付の開始時刻を持つ新しい日付を返す", () => { + const date = new Date("2024-01-15T14:30:45.123Z") + const result = startOfDay(date) + + expect(result.toISOString()).toBe("2024-01-15T00:00:00.000Z") + expect(date.toISOString()).toBe("2024-01-15T14:30:45.123Z") + }) +}) + +describe("endOfDay", () => { + it("日付の終了時刻を持つ新しい日付を返す", () => { + const date = new Date("2024-01-15T14:30:45.123Z") + const result = endOfDay(date) + + expect(result.toISOString()).toBe("2024-01-15T23:59:59.999Z") + expect(date.toISOString()).toBe("2024-01-15T14:30:45.123Z") + }) +}) + +describe("buildDateRange", () => { + it("開始日から連続した日付の配列を生成する", () => { + const start = new Date("2024-01-10T00:00:00Z") + const result = buildDateRange(start, 3) + + expect(result.map((date) => date.toISOString())).toEqual([ + "2024-01-10T00:00:00.000Z", + "2024-01-11T00:00:00.000Z", + "2024-01-12T00:00:00.000Z", + ]) + expect(start.toISOString()).toBe("2024-01-10T00:00:00.000Z") + }) + + it("0日を指定した場合は空配列を返す", () => { + const start = new Date("2024-01-10T00:00:00Z") + expect(buildDateRange(start, 0)).toEqual([]) + }) +}) + +describe("formatDateKey", () => { + it("日付をISO形式の日付文字列にフォーマットする", () => { + const date = new Date("2024-01-15T14:30:45.123Z") + const result = formatDateKey(date) + + expect(result).toBe("2024-01-15") + }) + + it("UTCに正規化された日付文字列を返す", () => { + const date = new Date("2024-01-15T23:30:00-05:00") + expect(formatDateKey(date)).toBe("2024-01-16") + }) +}) diff --git a/app/utils/date.ts b/app/utils/date.ts index 45be0219..7eaacef3 100644 --- a/app/utils/date.ts +++ b/app/utils/date.ts @@ -66,6 +66,64 @@ export const formatTimeJP = (date: Date | string): string => { return timeFormatter.format(d) } +/** + * 日付に指定した日数を加算する + * + * @param date - 基準となる日付 + * @param days - 加算する日数(負の値も可) + * @returns 日数を加算した新しい日付 + */ +export const addDays = (date: Date, days: number): Date => { + const next = new Date(date) + next.setDate(next.getDate() + days) + return next +} + +/** + * 日付の開始時刻(0時0分0秒0ミリ秒)を取得する + * + * @param date - 基準となる日付 + * @returns 開始時刻に設定された新しい日付 + */ +export const startOfDay = (date: Date): Date => { + const next = new Date(date) + next.setHours(0, 0, 0, 0) + return next +} + +/** + * 日付の終了時刻(23時59分59秒999ミリ秒)を取得する + * + * @param date - 基準となる日付 + * @returns 終了時刻に設定された新しい日付 + */ +export const endOfDay = (date: Date): Date => { + const next = new Date(date) + next.setHours(23, 59, 59, 999) + return next +} + +/** + * 指定した開始日から連続した日付の配列を生成する + * + * @param start - 開始日 + * @param days - 生成する日数 + * @returns 連続した日付の配列 + */ +export const buildDateRange = (start: Date, days: number): Date[] => { + return Array.from({ length: days }, (_, index) => addDays(start, index)) +} + +/** + * 日付をISO形式の日付文字列(YYYY-MM-DD)にフォーマットする + * + * @param date - フォーマットしたい日付 + * @returns ISO形式の日付文字列(例: `2024-01-15`) + */ +export const formatDateKey = (date: Date): string => { + return date.toISOString().slice(0, 10) +} + /** * 有効な日付かどうかを判定する * diff --git a/bun.lock b/bun.lock index 18a1fd14..dfa910a9 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "basic", "dependencies": { + "chart.js": "^4.5.1", "drizzle-orm": "^0.44.4", "hono": "^4.10.4", "honox": "0.1.52", @@ -146,6 +147,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -298,6 +301,8 @@ "chai": ["chai@6.2.1", "", {}, "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg=="], + "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], diff --git a/package.json b/package.json index b4d73573..15c02e35 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ }, "private": true, "dependencies": { + "chart.js": "^4.5.1", "drizzle-orm": "^0.44.4", "hono": "^4.10.4", "honox": "0.1.52", diff --git a/tests/e2e/staff-dashboard.test.ts b/tests/e2e/staff-dashboard.test.ts new file mode 100644 index 00000000..20cf4b0a --- /dev/null +++ b/tests/e2e/staff-dashboard.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "@playwright/test" +import { waitForHydration } from "./utils" + +test.describe("スタッフダッシュボード", () => { + test("チャートがクライアントレンダリングされる", async ({ page }) => { + await waitForHydration(page, "/staff") + + const trendChart = page.getByRole("img", { + name: "日別の注文数と売上の推移", + }) + const statusChart = page.getByRole("img", { + name: "ステータス別注文数の内訳", + }) + + await expect(trendChart).toBeVisible() + await expect(statusChart).toBeVisible() + + await page.waitForFunction( + (selector) => + !!document + .querySelector(selector) + ?.getAttribute("style"), + 'canvas[aria-label="日別の注文数と売上の推移"]', + ) + await page.waitForFunction( + (selector) => + !!document + .querySelector(selector) + ?.getAttribute("style"), + 'canvas[aria-label="ステータス別注文数の内訳"]', + ) + + const trendBox = await trendChart.boundingBox() + expect(trendBox?.height ?? 0).toBeGreaterThan(100) + expect(trendBox?.width ?? 0).toBeGreaterThan(100) + }) +}) diff --git a/tests/integration/staff-dashboard.test.ts b/tests/integration/staff-dashboard.test.ts new file mode 100644 index 00000000..6a1cd574 --- /dev/null +++ b/tests/integration/staff-dashboard.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "vitest" +import { app, assertBasicHtmlResponse } from "./utils" + +describe("スタッフダッシュボード", () => { + describe("GET /staff", () => { + test("静的な要素が表示される", async () => { + const res = await app.request("/staff") + const html = await res.text() + + assertBasicHtmlResponse(res, html) + + const summaryHeadingIndex = html.indexOf("サマリー") + const quickAccessHeadingIndex = html.indexOf("クイックアクセス") + expect(summaryHeadingIndex).toBeGreaterThan(-1) + expect(quickAccessHeadingIndex).toBeGreaterThan(-1) + expect(summaryHeadingIndex).toBeLessThan(quickAccessHeadingIndex) + + expect(html).toContain("本日の注文数") + expect(html).toContain("本日の売上") + expect(html).toContain("未処理の注文") + expect(html).toContain("7日間の平均客単価") + expect(html).toContain("ステータス別注文数") + expect(html).toContain("7日間の推移") + + const quickAccessLinks = [ + "/staff/orders", + "/staff/orders/new", + "/staff/orders/progress", + "/staff/products", + "/staff/analytics", + "/staff/settings", + ] + quickAccessLinks.forEach((href) => { + expect(html).toContain(`href="${href}"`) + }) + }) + }) +})