diff --git a/frontend/src/components/dashboard/dashboard-edit/EditCardContent.tsx b/frontend/src/components/dashboard/dashboard-edit/EditCardContent.tsx index eef9343ab..5685476fd 100644 --- a/frontend/src/components/dashboard/dashboard-edit/EditCardContent.tsx +++ b/frontend/src/components/dashboard/dashboard-edit/EditCardContent.tsx @@ -6,8 +6,8 @@ import { } from '@/components/menu'; import { AveragePriceContent, + OrderChannelContent, OrderCountContent, - OrderMethodContent, PaymentMethodContent, PeakTimeContent, RealSalesContent, @@ -24,8 +24,8 @@ import { } from '@/constants/menu'; import { AVERAGE_PRICE, + ORDER_CHANNEL, ORDER_COUNT, - ORDER_METHOD, PAYMENT_METHOD, PEAK_TIME, REAL_SALES, @@ -33,7 +33,6 @@ import { SALES_TREND, SALES_TYPE, } from '@/constants/sales'; - interface EditCardContentProps { cardCode: MetricCardCode; } @@ -60,11 +59,11 @@ const { EXAMPLE_SALES_SOURCE_DATA: SALES_TYPE_EXAMPLE_SALES_SOURCE_DATA, } = SALES_TYPE; const { - EXAMPLE_TOP_TYPE: ORDER_METHOD_EXAMPLE_TOP_TYPE, - EXAMPLE_TOP_SHARE: ORDER_METHOD_EXAMPLE_TOP_SHARE, - EXAMPLE_DELTA_SHARE: ORDER_METHOD_EXAMPLE_DELTA_SHARE, - EXAMPLE_ORDER_METHOD_DATA: ORDER_METHOD_EXAMPLE_ORDER_METHOD_DATA, -} = ORDER_METHOD; + EXAMPLE_TOP_TYPE: ORDER_CHANNEL_EXAMPLE_TOP_TYPE, + EXAMPLE_TOP_SHARE: ORDER_CHANNEL_EXAMPLE_TOP_SHARE, + EXAMPLE_DELTA_SHARE: ORDER_CHANNEL_EXAMPLE_DELTA_SHARE, + EXAMPLE_ORDER_CHANNEL_DATA: ORDER_CHANNEL_EXAMPLE_ORDER_CHANNEL_DATA, +} = ORDER_CHANNEL; const { EXAMPLE_TOP_TYPE: PAYMENT_METHOD_EXAMPLE_TOP_TYPE, EXAMPLE_TOP_SHARE: PAYMENT_METHOD_EXAMPLE_TOP_SHARE, @@ -138,14 +137,14 @@ export const EditCardContent = ({ cardCode }: EditCardContentProps) => { case 'SLS_07_02': case 'SLS_07_03': return ( - ); case 'SLS_08_01': diff --git a/frontend/src/components/dashboard/dashboard-layout/DashboardLayout.tsx b/frontend/src/components/dashboard/dashboard-layout/DashboardLayout.tsx index 101676aea..8355e41d2 100644 --- a/frontend/src/components/dashboard/dashboard-layout/DashboardLayout.tsx +++ b/frontend/src/components/dashboard/dashboard-layout/DashboardLayout.tsx @@ -1,7 +1,10 @@ import { Suspense } from 'react'; import { Tabs } from '@/components/shared/shadcn-ui'; -import { useDashboardTabsContext } from '@/hooks/dashboard'; +import { + useDashboardSseConnection, + useDashboardTabsContext, +} from '@/hooks/dashboard'; import { DashboardHeader } from '../dashboard-header'; import { DashboardMain } from '../dashboard-main'; @@ -10,6 +13,7 @@ import { DashboardMainSuspense } from '../dashboard-main'; export const DashboardLayout = () => { const { currentDashboardId, setCurrentDashboardId } = useDashboardTabsContext(); + useDashboardSseConnection(); return ( { + switch (cardCode) { + case 'SLS_01_01': + case 'SLS_01_02': + case 'SLS_01_03': + return ; + case 'SLS_02_01': + case 'SLS_02_02': + case 'SLS_02_03': + return ; + case 'SLS_03_01': + case 'SLS_03_02': + case 'SLS_03_03': + return ; + case 'SLS_06_01': + case 'SLS_06_02': + case 'SLS_06_03': + return ; + case 'SLS_07_01': + case 'SLS_07_02': + case 'SLS_07_03': + return ; + case 'SLS_08_01': + case 'SLS_08_02': + case 'SLS_08_03': + return ; + case 'SLS_09_04': + case 'SLS_10_07': + case 'SLS_11_07': + return ; + case 'SLS_13_01': + return ; + case 'SLS_14_06': + return ; + case 'MNU_01_01': + case 'MNU_01_04': + case 'MNU_01_05': + return ; + case 'MNU_03_01': + return ; + case 'MNU_04_01': + return ; + case 'MNU_05_04': + return ; + default: + return null; + } +}; diff --git a/frontend/src/components/dashboard/dashboard-main/DashboardMain.tsx b/frontend/src/components/dashboard/dashboard-main/DashboardMain.tsx index ebacc5d7a..efc9de88a 100644 --- a/frontend/src/components/dashboard/dashboard-main/DashboardMain.tsx +++ b/frontend/src/components/dashboard/dashboard-main/DashboardMain.tsx @@ -1,25 +1,19 @@ -import { useSuspenseQuery } from '@tanstack/react-query'; - -import { useDashboardTabsContext } from '@/hooks/dashboard'; -import { dashboardOptions } from '@/services/dashboard'; +import { + useDashboardCardList, + useDashboardCardSubscription, +} from '@/hooks/dashboard'; import { DashboardEmptyContent } from './DashboardEmptyContent'; import { DashboardMainContent } from './DashboardMainContent'; export const DashboardMain = () => { - const { currentDashboardId } = useDashboardTabsContext(); + const { cardList } = useDashboardCardList(); + + useDashboardCardSubscription({ cardList }); - const { data: cardList } = useSuspenseQuery( - dashboardOptions.cardList(currentDashboardId), - ); + if (!cardList || cardList.length === 0) { + return ; + } - return ( - <> - {cardList?.length > 0 ? ( - - ) : ( - - )} - - ); + return ; }; diff --git a/frontend/src/components/dashboard/dashboard-main/DashboardMainContent.tsx b/frontend/src/components/dashboard/dashboard-main/DashboardMainContent.tsx index 61af4efa5..05bac8076 100644 --- a/frontend/src/components/dashboard/dashboard-main/DashboardMainContent.tsx +++ b/frontend/src/components/dashboard/dashboard-main/DashboardMainContent.tsx @@ -1,26 +1,59 @@ -import { DASHBOARD_METRIC_CARDS } from '@/constants/dashboard'; +import { useNavigate } from 'react-router-dom'; + +import { DefaultCardWrapper } from '@/components/shared'; +import { DefaultCardFetchBoundary } from '@/components/shared/default-card-fetch-boundary'; +import { + DASHBOARD_METRIC_CARDS, + isMenuMetricCardCode, + isSalesMetricCardCode, + type MetricCardCode, +} from '@/constants/dashboard'; +import { ROUTE_PATHS } from '@/constants/shared'; import type { DashboardCard } from '@/types/dashboard'; +import { DashboardCard as DashboardCardComponent } from './DashboardCard'; + interface DashboardMainContentProps { cards: DashboardCard[]; } export const DashboardMainContent = ({ cards }: DashboardMainContentProps) => { + const navigate = useNavigate(); + + const handleClickChevronRightIcon = (cardCode: MetricCardCode) => { + const analysisPath = ROUTE_PATHS.ANALYSIS.BASE; + if (isSalesMetricCardCode(cardCode)) { + navigate(`${analysisPath}/${ROUTE_PATHS.ANALYSIS.SALES}`); + return; + } + if (isMenuMetricCardCode(cardCode)) { + navigate(`${analysisPath}/${ROUTE_PATHS.ANALYSIS.MENU}`); + return; + } + }; + return (
{cards.map((item) => { const card = DASHBOARD_METRIC_CARDS[item.cardCode]; + return ( -
- 카드 {item.cardCode} -
+ + + handleClickChevronRightIcon(item.cardCode) + } + > + + + ); })}
diff --git a/frontend/src/components/dashboard/dashboard-menu/DashboardIngredientUsageRankingCard.tsx b/frontend/src/components/dashboard/dashboard-menu/DashboardIngredientUsageRankingCard.tsx new file mode 100644 index 000000000..9d9f0e7cf --- /dev/null +++ b/frontend/src/components/dashboard/dashboard-menu/DashboardIngredientUsageRankingCard.tsx @@ -0,0 +1,41 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { + IngredientUsageRankingCardContent, + IngredientUsageRankingCardContentEmptyView, +} from '@/components/menu'; +import { + DASHBOARD_METRICS, + type ExtractCardCodes, +} from '@/constants/dashboard'; +import { useDashboardCardDetailQueryOption } from '@/hooks/dashboard'; +import type { GetIngredientUsageRankingResponseDto } from '@/types/menu'; + +type DashboardIngredientUsageRankingCardCodes = ExtractCardCodes< + typeof DASHBOARD_METRICS.MENU.sections.INGREDIENT_CONSUMPTION_RANK.items.INGREDIENT_CONSUMPTION_RANK +>; +interface DashboardIngredientUsageRankingCardProps { + cardCode: DashboardIngredientUsageRankingCardCodes; +} + +export const DashboardIngredientUsageRankingCard = ({ + cardCode, +}: DashboardIngredientUsageRankingCardProps) => { + const { createCardDetailQuery } = useDashboardCardDetailQueryOption(); + + const queryOption = + createCardDetailQuery(cardCode); + + const { data } = useSuspenseQuery(queryOption); + + if (data.hasIngredient && data.items.length === 0) { + return ; + } + + return ( + + ); +}; diff --git a/frontend/src/components/dashboard/dashboard-menu/DashboardMenuSalesRankingCard.tsx b/frontend/src/components/dashboard/dashboard-menu/DashboardMenuSalesRankingCard.tsx new file mode 100644 index 000000000..2e3e832e7 --- /dev/null +++ b/frontend/src/components/dashboard/dashboard-menu/DashboardMenuSalesRankingCard.tsx @@ -0,0 +1,37 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { + MenuSalesRankingCardContent, + MenuSalesRankingCardContentEmptyView, +} from '@/components/menu'; +import { + DASHBOARD_METRICS, + type ExtractCardCodes, +} from '@/constants/dashboard'; +import { useDashboardCardDetailQueryOption } from '@/hooks/dashboard'; +import type { GetMenuSalesRankingResponseDto } from '@/types/menu'; + +type DashboardMenuSalesRankingCardCodes = ExtractCardCodes< + typeof DASHBOARD_METRICS.MENU.sections.POPULAR_MENU.items.MENU_SALES_RANKING +>; + +interface DashboardMenuSalesRankingCardProps { + cardCode: DashboardMenuSalesRankingCardCodes; +} + +export const DashboardMenuSalesRankingCard = ({ + cardCode, +}: DashboardMenuSalesRankingCardProps) => { + const { createCardDetailQuery } = useDashboardCardDetailQueryOption(); + + const queryOption = + createCardDetailQuery(cardCode); + + const { data } = useSuspenseQuery(queryOption); + + if (data.items.length === 0) { + return ; + } + + return ; +}; diff --git a/frontend/src/components/dashboard/dashboard-menu/DashboardPopularMenuCombinationCard.tsx b/frontend/src/components/dashboard/dashboard-menu/DashboardPopularMenuCombinationCard.tsx new file mode 100644 index 000000000..57bf9f1fa --- /dev/null +++ b/frontend/src/components/dashboard/dashboard-menu/DashboardPopularMenuCombinationCard.tsx @@ -0,0 +1,38 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { PopularMenuCombinationCardContent } from '@/components/menu'; +import { + DASHBOARD_METRICS, + type ExtractCardCodes, +} from '@/constants/dashboard'; +import { useDashboardCardDetailQueryOption } from '@/hooks/dashboard'; +import type { GetPopularMenuCombinationResponseDto } from '@/types/menu'; + +type DashboardPopularMenuCombinationCardCodes = ExtractCardCodes< + typeof DASHBOARD_METRICS.MENU.sections.POPULAR_MENU_COMBINATION.items.POPULAR_MENU_COMBINATION +>; + +interface DashboardPopularMenuCombinationCardProps { + cardCode: DashboardPopularMenuCombinationCardCodes; +} + +export const DashboardPopularMenuCombinationCard = ({ + cardCode, +}: DashboardPopularMenuCombinationCardProps) => { + const { createCardDetailQuery } = useDashboardCardDetailQueryOption(); + + const queryOption = + createCardDetailQuery(cardCode); + + const { data } = useSuspenseQuery(queryOption); + + const firstMenuName = data.items[0]?.baseMenuName; + const secondMenuName = data.items[0]?.pairedMenus?.[0]?.menuName; + + return ( + + ); +}; diff --git a/frontend/src/components/dashboard/dashboard-menu/DashboardTimeSlotMenuOrderCountCard.tsx b/frontend/src/components/dashboard/dashboard-menu/DashboardTimeSlotMenuOrderCountCard.tsx new file mode 100644 index 000000000..9905cc55c --- /dev/null +++ b/frontend/src/components/dashboard/dashboard-menu/DashboardTimeSlotMenuOrderCountCard.tsx @@ -0,0 +1,38 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { TimeSlotMenuOrderCountCardContent } from '@/components/menu'; +import { + DASHBOARD_METRICS, + type ExtractCardCodes, +} from '@/constants/dashboard'; +import { useDashboardCardDetailQueryOption } from '@/hooks/dashboard'; +import type { GetDetailTimeSlotMenuOrderCountResponseDto } from '@/types/menu'; + +type DashboardTimeSlotMenuOrderCountCardCodes = ExtractCardCodes< + typeof DASHBOARD_METRICS.MENU.sections.MENU_SALES_PATTERN.items.TIME_BASED_MENU_ORDER_COUNT +>; + +interface DashboardTimeSlotMenuOrderCountCardProps { + cardCode: DashboardTimeSlotMenuOrderCountCardCodes; +} + +export const DashboardTimeSlotMenuOrderCountCard = ({ + cardCode, +}: DashboardTimeSlotMenuOrderCountCardProps) => { + const { createCardDetailQuery } = useDashboardCardDetailQueryOption(); + + const queryOption = + createCardDetailQuery(cardCode); + + const { data } = useSuspenseQuery(queryOption); + + const timeSlot2H = data.items[0]?.timeSlot2H; + const menuName = data.items[0]?.menus[0]?.menuName; + + return ( + + ); +}; diff --git a/frontend/src/components/dashboard/dashboard-menu/index.ts b/frontend/src/components/dashboard/dashboard-menu/index.ts new file mode 100644 index 000000000..af03ed881 --- /dev/null +++ b/frontend/src/components/dashboard/dashboard-menu/index.ts @@ -0,0 +1,4 @@ +export { DashboardMenuSalesRankingCard } from './DashboardMenuSalesRankingCard'; +export { DashboardTimeSlotMenuOrderCountCard } from './DashboardTimeSlotMenuOrderCountCard'; +export { DashboardIngredientUsageRankingCard } from './DashboardIngredientUsageRankingCard'; +export { DashboardPopularMenuCombinationCard } from './DashboardPopularMenuCombinationCard'; diff --git a/frontend/src/components/dashboard/dashboard-sales/DashboardAveragePriceCard.tsx b/frontend/src/components/dashboard/dashboard-sales/DashboardAveragePriceCard.tsx new file mode 100644 index 000000000..ae3dc4a01 --- /dev/null +++ b/frontend/src/components/dashboard/dashboard-sales/DashboardAveragePriceCard.tsx @@ -0,0 +1,36 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { AveragePriceContent } from '@/components/sales'; +import { + DASHBOARD_METRICS, + type ExtractCardCodes, +} from '@/constants/dashboard'; +import { useDashboardCardDetailQueryOption } from '@/hooks/dashboard'; +import type { GetAveragePriceResponseDto } from '@/types/sales'; + +type DashboardAveragePriceCardCodes = ExtractCardCodes< + typeof DASHBOARD_METRICS.SALES.sections.CURRENT_SALES.items.AVERAGE_PRICE +>; + +interface DashboardAveragePriceCardProps { + cardCode: DashboardAveragePriceCardCodes; +} +export const DashboardAveragePriceCard = ({ + cardCode, +}: DashboardAveragePriceCardProps) => { + const { createCardDetailQuery } = useDashboardCardDetailQueryOption(); + + const queryOption = + createCardDetailQuery(cardCode); + + const { data } = useSuspenseQuery(queryOption); + + return ( + + ); +}; diff --git a/frontend/src/components/dashboard/dashboard-sales/DashboardIncomeStructureOrderChannelCard.tsx b/frontend/src/components/dashboard/dashboard-sales/DashboardIncomeStructureOrderChannelCard.tsx new file mode 100644 index 000000000..b10a83075 --- /dev/null +++ b/frontend/src/components/dashboard/dashboard-sales/DashboardIncomeStructureOrderChannelCard.tsx @@ -0,0 +1,45 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { + OrderChannelContent, + OrderChannelContentEmptyView, +} from '@/components/sales'; +import { + DASHBOARD_METRICS, + type ExtractCardCodes, +} from '@/constants/dashboard'; +import { useDashboardCardDetailQueryOption } from '@/hooks/dashboard'; +import type { GetIncomeStructureByOrderChannelResponseDto } from '@/types/sales'; + +type DashboardIncomeStructureOrderChannelCardCodes = ExtractCardCodes< + typeof DASHBOARD_METRICS.SALES.sections.INCOME_STRUCTURE.items.ORDER_CHANNEL +>; + +interface DashboardIncomeStructureOrderChannelCardProps { + cardCode: DashboardIncomeStructureOrderChannelCardCodes; +} + +export const DashboardIncomeStructureOrderChannelCard = ({ + cardCode, +}: DashboardIncomeStructureOrderChannelCardProps) => { + const { createCardDetailQuery } = useDashboardCardDetailQueryOption(); + + const queryOption = + createCardDetailQuery( + cardCode, + ); + + const { data } = useSuspenseQuery(queryOption); + + if (data.items.length === 0) { + return ; + } + + return ( + + ); +}; diff --git a/frontend/src/components/dashboard/dashboard-sales/DashboardIncomeStructurePaymentMethodCard.tsx b/frontend/src/components/dashboard/dashboard-sales/DashboardIncomeStructurePaymentMethodCard.tsx new file mode 100644 index 000000000..2197f5ef8 --- /dev/null +++ b/frontend/src/components/dashboard/dashboard-sales/DashboardIncomeStructurePaymentMethodCard.tsx @@ -0,0 +1,44 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { + PaymentMethodContent, + PaymentMethodContentEmptyView, +} from '@/components/sales'; +import type { + DASHBOARD_METRICS, + ExtractCardCodes, +} from '@/constants/dashboard'; +import { useDashboardCardDetailQueryOption } from '@/hooks/dashboard'; +import type { GetIncomeStructureByPaymentMethodResponseDto } from '@/types/sales'; + +type DashboardIncomeStructurePaymentMethodCardCodes = ExtractCardCodes< + typeof DASHBOARD_METRICS.SALES.sections.INCOME_STRUCTURE.items.PAYMENT_METHOD +>; +interface DashboardIncomeStructurePaymentMethodCardProps { + cardCode: DashboardIncomeStructurePaymentMethodCardCodes; +} + +export const DashboardIncomeStructurePaymentMethodCard = ({ + cardCode, +}: DashboardIncomeStructurePaymentMethodCardProps) => { + const { createCardDetailQuery } = useDashboardCardDetailQueryOption(); + + const queryOption = + createCardDetailQuery( + cardCode, + ); + + const { data } = useSuspenseQuery(queryOption); + + if (data.items.length === 0) { + return ; + } + + return ( + + ); +}; diff --git a/frontend/src/components/dashboard/dashboard-sales/DashboardIncomeStructureSalesTypeCard.tsx b/frontend/src/components/dashboard/dashboard-sales/DashboardIncomeStructureSalesTypeCard.tsx new file mode 100644 index 000000000..8b70f0922 --- /dev/null +++ b/frontend/src/components/dashboard/dashboard-sales/DashboardIncomeStructureSalesTypeCard.tsx @@ -0,0 +1,43 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { + SalesTypeContent, + SalesTypeContentEmptyView, +} from '@/components/sales'; +import { + DASHBOARD_METRICS, + type ExtractCardCodes, +} from '@/constants/dashboard'; +import { useDashboardCardDetailQueryOption } from '@/hooks/dashboard'; +import type { GetIncomeStructureBySalesTypeResponseDto } from '@/types/sales'; + +type DashboardIncomeStructureSalesTypeCardCodes = ExtractCardCodes< + typeof DASHBOARD_METRICS.SALES.sections.INCOME_STRUCTURE.items.SALES_TYPE +>; + +interface DashboardIncomeStructureSalesTypeCardProps { + cardCode: DashboardIncomeStructureSalesTypeCardCodes; +} + +export const DashboardIncomeStructureSalesTypeCard = ({ + cardCode, +}: DashboardIncomeStructureSalesTypeCardProps) => { + const { createCardDetailQuery } = useDashboardCardDetailQueryOption(); + + const queryOption = + createCardDetailQuery(cardCode); + + const { data } = useSuspenseQuery(queryOption); + + if (data.items.length === 0) { + return ; + } + + return ( + + ); +}; diff --git a/frontend/src/components/dashboard/dashboard-sales/DashboardOrderCountCard.tsx b/frontend/src/components/dashboard/dashboard-sales/DashboardOrderCountCard.tsx new file mode 100644 index 000000000..1e4190562 --- /dev/null +++ b/frontend/src/components/dashboard/dashboard-sales/DashboardOrderCountCard.tsx @@ -0,0 +1,36 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { OrderCountContent } from '@/components/sales'; +import { + DASHBOARD_METRICS, + type ExtractCardCodes, +} from '@/constants/dashboard'; +import { useDashboardCardDetailQueryOption } from '@/hooks/dashboard'; +import type { GetOrderCountResponseDto } from '@/types/sales'; + +type DashboardOrderCountCardCodes = ExtractCardCodes< + typeof DASHBOARD_METRICS.SALES.sections.CURRENT_SALES.items.ORDER_COUNT +>; + +interface DashboardOrderCountCardProps { + cardCode: DashboardOrderCountCardCodes; +} + +export const DashboardOrderCountCard = ({ + cardCode, +}: DashboardOrderCountCardProps) => { + const { createCardDetailQuery } = useDashboardCardDetailQueryOption(); + + const queryOption = createCardDetailQuery(cardCode); + + const { data } = useSuspenseQuery(queryOption); + + return ( + + ); +}; diff --git a/frontend/src/components/dashboard/dashboard-sales/DashboardPeakTimeCard.tsx b/frontend/src/components/dashboard/dashboard-sales/DashboardPeakTimeCard.tsx new file mode 100644 index 000000000..600eb67bd --- /dev/null +++ b/frontend/src/components/dashboard/dashboard-sales/DashboardPeakTimeCard.tsx @@ -0,0 +1,30 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { PeakTimeContent } from '@/components/sales'; +import { + DASHBOARD_METRICS, + type ExtractCardCodes, +} from '@/constants/dashboard'; +import { useDashboardCardDetailQueryOption } from '@/hooks/dashboard'; +import type { GetDetailPeakTimeResponseDto } from '@/types/sales'; + +type DashboardPeakTimeCardCodes = ExtractCardCodes< + typeof DASHBOARD_METRICS.SALES.sections.SALES_PATTERN.items.PEAK_TIME +>; + +interface DashboardPeakTimeCardProps { + cardCode: DashboardPeakTimeCardCodes; +} + +export const DashboardPeakTimeCard = ({ + cardCode, +}: DashboardPeakTimeCardProps) => { + const { createCardDetailQuery } = useDashboardCardDetailQueryOption(); + + const queryOption = + createCardDetailQuery(cardCode); + + const { data } = useSuspenseQuery(queryOption); + + return ; +}; diff --git a/frontend/src/components/dashboard/dashboard-sales/DashboardRealSalesCard.tsx b/frontend/src/components/dashboard/dashboard-sales/DashboardRealSalesCard.tsx new file mode 100644 index 000000000..e44601514 --- /dev/null +++ b/frontend/src/components/dashboard/dashboard-sales/DashboardRealSalesCard.tsx @@ -0,0 +1,35 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { RealSalesContent } from '@/components/sales'; +import { + DASHBOARD_METRICS, + type ExtractCardCodes, +} from '@/constants/dashboard'; +import { useDashboardCardDetailQueryOption } from '@/hooks/dashboard'; +import type { GetRealSalesResponseDto } from '@/types/sales'; + +type DashboardRealSalesCardCodes = ExtractCardCodes< + typeof DASHBOARD_METRICS.SALES.sections.CURRENT_SALES.items.REAL_SALES +>; + +interface DashboardRealSalesCardProps { + cardCode: DashboardRealSalesCardCodes; +} +export const DashboardRealSalesCard = ({ + cardCode, +}: DashboardRealSalesCardProps) => { + const { createCardDetailQuery } = useDashboardCardDetailQueryOption(); + + const queryOption = createCardDetailQuery(cardCode); + + const { data } = useSuspenseQuery(queryOption); + + return ( + + ); +}; diff --git a/frontend/src/components/dashboard/dashboard-sales/DashboardSalesByDayCard.tsx b/frontend/src/components/dashboard/dashboard-sales/DashboardSalesByDayCard.tsx new file mode 100644 index 000000000..54733084d --- /dev/null +++ b/frontend/src/components/dashboard/dashboard-sales/DashboardSalesByDayCard.tsx @@ -0,0 +1,36 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { SalesByDayContent } from '@/components/sales'; +import { + DASHBOARD_METRICS, + type ExtractCardCodes, +} from '@/constants/dashboard'; +import { useDashboardCardDetailQueryOption } from '@/hooks/dashboard'; +import type { GetDetailSalesByDayResponseDto } from '@/types/sales'; + +type DashboardSalesByDayCardCodes = ExtractCardCodes< + typeof DASHBOARD_METRICS.SALES.sections.SALES_PATTERN.items.WEEKDAY_SALES_PATTERN +>; + +interface DashboardSalesByDayCardProps { + cardCode: DashboardSalesByDayCardCodes; +} + +export const DashboardSalesByDayCard = ({ + cardCode, +}: DashboardSalesByDayCardProps) => { + const { createCardDetailQuery } = useDashboardCardDetailQueryOption(); + + const queryOption = + createCardDetailQuery(cardCode); + + const { data } = useSuspenseQuery(queryOption); + + return ( + + ); +}; diff --git a/frontend/src/components/dashboard/dashboard-sales/DashboardSalesTrendCard.tsx b/frontend/src/components/dashboard/dashboard-sales/DashboardSalesTrendCard.tsx new file mode 100644 index 000000000..88b6e7c56 --- /dev/null +++ b/frontend/src/components/dashboard/dashboard-sales/DashboardSalesTrendCard.tsx @@ -0,0 +1,59 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { SalesTrendContent } from '@/components/sales'; +import { + DASHBOARD_METRIC_CARDS, + DASHBOARD_METRICS, + type ExtractCardCodesFromSection, +} from '@/constants/dashboard'; +import { PERIOD_PRESETS } from '@/constants/shared'; +import { useDashboardCardDetailQueryOption } from '@/hooks/dashboard'; +import type { GetSalesTrendResponseDto } from '@/types/sales'; + +type DashboardSalesTrendCardCodes = ExtractCardCodesFromSection< + typeof DASHBOARD_METRICS.SALES.sections.SALES_TREND +>; + +const TREND_CHART_WIDTH_FOR_RECENT_30_DAYS = 660; +const TREND_CHART_WIDTH = 1020; +const TREND_CHART_HEIGHT = 120; + +interface DashboardSalesTrendCardProps { + cardCode: DashboardSalesTrendCardCodes; +} + +export const DashboardSalesTrendCard = ({ + cardCode, +}: DashboardSalesTrendCardProps) => { + const { period } = DASHBOARD_METRIC_CARDS[cardCode]; + const { createCardDetailQuery } = useDashboardCardDetailQueryOption(); + + const queryOption = createCardDetailQuery(cardCode); + + const { data } = useSuspenseQuery(queryOption); + + const salesTrendData = { + data: { + mainX: data.items.map((item) => ({ amount: item.label, unit: '' })), + mainY: data.items.map((item) => ({ amount: item.netAmount, unit: '원' })), + subX: data.items.map((item) => ({ amount: item.label, unit: '' })), + subY: data.items.map((item) => ({ amount: item.orderCount, unit: '건' })), + }, + color: 'var(--color-grey-400)', + }; + + const trendChartWidth = + period === PERIOD_PRESETS.recentDays7_14_30.recent30Days + ? TREND_CHART_WIDTH_FOR_RECENT_30_DAYS + : TREND_CHART_WIDTH; + const trendChartHeight = TREND_CHART_HEIGHT; + + return ( + + ); +}; diff --git a/frontend/src/components/dashboard/dashboard-sales/index.ts b/frontend/src/components/dashboard/dashboard-sales/index.ts new file mode 100644 index 000000000..f3f55a144 --- /dev/null +++ b/frontend/src/components/dashboard/dashboard-sales/index.ts @@ -0,0 +1,9 @@ +export { DashboardRealSalesCard } from './DashboardRealSalesCard'; +export { DashboardOrderCountCard } from './DashboardOrderCountCard'; +export { DashboardAveragePriceCard } from './DashboardAveragePriceCard'; +export { DashboardIncomeStructureSalesTypeCard } from './DashboardIncomeStructureSalesTypeCard'; +export { DashboardIncomeStructureOrderChannelCard } from './DashboardIncomeStructureOrderChannelCard'; +export { DashboardIncomeStructurePaymentMethodCard } from './DashboardIncomeStructurePaymentMethodCard'; +export { DashboardSalesTrendCard } from './DashboardSalesTrendCard'; +export { DashboardPeakTimeCard } from './DashboardPeakTimeCard'; +export { DashboardSalesByDayCard } from './DashboardSalesByDayCard'; diff --git a/frontend/src/components/menu/dashboard-menu-combination/PopularMenuCombinationCardContent.tsx b/frontend/src/components/menu/dashboard-menu-combination/PopularMenuCombinationCardContent.tsx index 2e36f77cb..e999096f6 100644 --- a/frontend/src/components/menu/dashboard-menu-combination/PopularMenuCombinationCardContent.tsx +++ b/frontend/src/components/menu/dashboard-menu-combination/PopularMenuCombinationCardContent.tsx @@ -1,19 +1,20 @@ -import { POPULAR_MENU_COMBINATION } from '@/constants/menu'; -import type { GetDashboardPopularMenuCombinationResponseDto } from '@/types/menu'; - import { PopularMenuCombinationContent } from './PopularMenuCombinationContent'; +import { PopularMenuCombinationContentEmptyView } from './PopularMenuCombinationContentEmptyView'; -const { EXAMPLE_FIRST_MENU_NAME, EXAMPLE_SECOND_MENU_NAME } = - POPULAR_MENU_COMBINATION; -interface PopularMenuCombinationCardContentProps extends GetDashboardPopularMenuCombinationResponseDto { +interface PopularMenuCombinationCardContentProps { className?: string; + firstMenuName?: string | null; + secondMenuName?: string | null; } export const PopularMenuCombinationCardContent = ({ - firstMenuName = EXAMPLE_FIRST_MENU_NAME, - secondMenuName = EXAMPLE_SECOND_MENU_NAME, + firstMenuName, + secondMenuName, className, }: PopularMenuCombinationCardContentProps) => { + if (!firstMenuName || !secondMenuName) { + return ; + } return ( { + return ( +
+
+ +
+

+ 아직 추천해 드릴{'\n'} + 인기 조합이 없어요. +

+ + 조금만 기다려 주시면{'\n'}맛있는 조합을 찾아올게요! + +
+ ); +}; diff --git a/frontend/src/components/menu/dashboard-menu-order/TimeSlotMenuOrderCountCardContent.tsx b/frontend/src/components/menu/dashboard-menu-order/TimeSlotMenuOrderCountCardContent.tsx index db2e4a7a6..a9334bbb9 100644 --- a/frontend/src/components/menu/dashboard-menu-order/TimeSlotMenuOrderCountCardContent.tsx +++ b/frontend/src/components/menu/dashboard-menu-order/TimeSlotMenuOrderCountCardContent.tsx @@ -1,19 +1,21 @@ -import { ORDER_COUNT } from '@/constants/menu'; import type { GetDashboardTimeSlotMenuOrderCountResponseDto } from '@/types/menu'; import { TimeSlotMenuOrderCountContent } from './TimeSlotMenuOrderCountContent'; +import { TimeSlotMenuOrderCountContentEmptyView } from './TimeSlotMenuOrderCountContentEmptyView'; -const { EXAMPLE_TIME_SLOT_2H, EXAMPLE_MENU_NAME } = ORDER_COUNT; // 현재 주문건수가 가장 많은 메뉴 카드 -interface TimeSlotMenuOrderCountCardContentProps extends GetDashboardTimeSlotMenuOrderCountResponseDto { +interface TimeSlotMenuOrderCountCardContentProps extends Partial { className?: string; } export const TimeSlotMenuOrderCountCardContent = ({ - timeSlot2H = EXAMPLE_TIME_SLOT_2H, - menuName = EXAMPLE_MENU_NAME, + timeSlot2H, + menuName, className, }: TimeSlotMenuOrderCountCardContentProps) => { + if (!timeSlot2H || !menuName) { + return ; + } return ( {menuName} - {`는 ${timeSlot2H}~${getNextHour(timeSlot2H)}시`} + {`은(는) ${timeSlot2H}~${getNextHour(timeSlot2H)}시`} 주문이 가장 많아요

diff --git a/frontend/src/components/menu/dashboard-menu-order/TimeSlotMenuOrderCountContentEmptyView.tsx b/frontend/src/components/menu/dashboard-menu-order/TimeSlotMenuOrderCountContentEmptyView.tsx new file mode 100644 index 000000000..8f23eb14b --- /dev/null +++ b/frontend/src/components/menu/dashboard-menu-order/TimeSlotMenuOrderCountContentEmptyView.tsx @@ -0,0 +1,19 @@ +import { Clock } from 'lucide-react'; + +export const TimeSlotMenuOrderCountContentEmptyView = () => { + return ( +
+
+ +
+

+ 오늘 주문 데이터가{'\n'} + 아직 없어요 +

+ + 첫 주문이 들어오면{'\n'} + 시간대별 분석을 시작할게요! + +
+ ); +}; diff --git a/frontend/src/components/menu/dashboard-menu-ranking/IngredientUsageRankingCardContentEmptyView.tsx b/frontend/src/components/menu/dashboard-menu-ranking/IngredientUsageRankingCardContentEmptyView.tsx new file mode 100644 index 000000000..8005ecd2f --- /dev/null +++ b/frontend/src/components/menu/dashboard-menu-ranking/IngredientUsageRankingCardContentEmptyView.tsx @@ -0,0 +1,18 @@ +import { ShoppingBasket } from 'lucide-react'; + +export const IngredientUsageRankingCardContentEmptyView = () => { + return ( +
+
+ +
+

+ 오늘 소진된 식재료가{'\n'}아직 + 없어요 +

+ + 첫 주문이 들어오면{'\n'}식재료 소진량을 분석해 드릴게요! + +
+ ); +}; diff --git a/frontend/src/components/menu/dashboard-menu-ranking/MenuSalesRankingCardContentEmptView.tsx b/frontend/src/components/menu/dashboard-menu-ranking/MenuSalesRankingCardContentEmptView.tsx new file mode 100644 index 000000000..5a9201330 --- /dev/null +++ b/frontend/src/components/menu/dashboard-menu-ranking/MenuSalesRankingCardContentEmptView.tsx @@ -0,0 +1,83 @@ +import { ListOrdered } from 'lucide-react'; + +import { + DASHBOARD_METRIC_CARDS, + DASHBOARD_METRICS, + type ExtractCardCodes, +} from '@/constants/dashboard'; +import { + PERIOD_PRESET_KEYS, + PERIOD_PRESETS, + type PeriodType, +} from '@/constants/shared'; +import { createMessageToken } from '@/utils/sales/dashboard'; +import { cn } from '@/utils/shared'; + +type MenuSalesRankingCardContentEmptyViewCodes = ExtractCardCodes< + typeof DASHBOARD_METRICS.MENU.sections.POPULAR_MENU.items.MENU_SALES_RANKING +>; + +interface MenuSalesRankingCardContentEmptyViewProps { + cardCode: MenuSalesRankingCardContentEmptyViewCodes; +} + +const getEmptyViewMessage = ( + period: PeriodType, +) => { + const dateMessage = period; + + if (period === PERIOD_PRESETS.today7_30.today) { + return { + body: [ + createMessageToken(dateMessage), + createMessageToken(' 판매된 메뉴', true, 'primary'), + createMessageToken('가\n 아직 없어요.'), + ], + caption: [ + createMessageToken('첫 주문이 들어오면\n 인기 메뉴를 알려드릴게요!'), + ], + }; + } + + return { + body: [ + createMessageToken(dateMessage), + createMessageToken(' 주문 데이터', true, 'primary'), + createMessageToken('가\n 아직 부족해요.'), + ], + caption: [ + createMessageToken('데이터가 쌓이면\n 주간 인기 메뉴를 분석해 드릴게요!'), + ], + }; +}; + +export const MenuSalesRankingCardContentEmptyView = ({ + cardCode, +}: MenuSalesRankingCardContentEmptyViewProps) => { + const { period } = DASHBOARD_METRIC_CARDS[cardCode]; + const { body, caption } = getEmptyViewMessage(period); + + return ( +
+
+ +
+

+ {body.map(({ text, isHighlight, highlightColor }, index) => ( + + {text} + + ))} +

+ + {caption.map((token) => token.text).join('')} + +
+ ); +}; diff --git a/frontend/src/components/menu/dashboard-menu-ranking/index.ts b/frontend/src/components/menu/dashboard-menu-ranking/index.ts index b8a4a5120..4610b63fa 100644 --- a/frontend/src/components/menu/dashboard-menu-ranking/index.ts +++ b/frontend/src/components/menu/dashboard-menu-ranking/index.ts @@ -1,2 +1,4 @@ export { IngredientUsageRankingCardContent } from './IngredientUsageRankingCardContent'; export { MenuSalesRankingCardContent } from './MenuSalesRankingCardContent'; +export { MenuSalesRankingCardContentEmptyView } from './MenuSalesRankingCardContentEmptView'; +export { IngredientUsageRankingCardContentEmptyView } from './IngredientUsageRankingCardContentEmptyView'; diff --git a/frontend/src/components/menu/index.ts b/frontend/src/components/menu/index.ts index f01b60ef9..144bc537c 100644 --- a/frontend/src/components/menu/index.ts +++ b/frontend/src/components/menu/index.ts @@ -6,6 +6,8 @@ export { CategoryRevenueChartLegend } from './CategoryRevenueChartLegend'; export { IngredientUsageRankingCardContent, MenuSalesRankingCardContent, + MenuSalesRankingCardContentEmptyView, + IngredientUsageRankingCardContentEmptyView, } from './dashboard-menu-ranking'; export { TimeSlotMenuOrderCountCardContent } from './dashboard-menu-order'; export { PopularMenuCombinationCardContent } from './dashboard-menu-combination'; diff --git a/frontend/src/components/sales/dashboard-current-sales/RealSalesContent.tsx b/frontend/src/components/sales/dashboard-current-sales/RealSalesContent.tsx index 337b07628..8691b5756 100644 --- a/frontend/src/components/sales/dashboard-current-sales/RealSalesContent.tsx +++ b/frontend/src/components/sales/dashboard-current-sales/RealSalesContent.tsx @@ -4,7 +4,7 @@ import { type ExtractCardCodes, } from '@/constants/dashboard'; import { REAL_SALES, SALES_UNIT } from '@/constants/sales'; -import type { GetRealTimeSalesResponseDto } from '@/types/sales'; +import type { GetRealSalesResponseDto } from '@/types/sales'; import { getMetricTrend } from '@/utils/dashboard'; import { getSalesCurrentComparisonMessage } from '@/utils/sales'; @@ -24,7 +24,7 @@ type RealSalesCardCodes = ExtractCardCodes< >; interface RealSalesContentProps extends Omit< - GetRealTimeSalesResponseDto, + GetRealSalesResponseDto, 'differenceAmount' > { cardCode: RealSalesCardCodes; diff --git a/frontend/src/components/sales/dashboard-sales-income/DashboardSalesIncomeContent.tsx b/frontend/src/components/sales/dashboard-sales-income/DashboardSalesIncomeContent.tsx index b72bbf7c5..c101803d3 100644 --- a/frontend/src/components/sales/dashboard-sales-income/DashboardSalesIncomeContent.tsx +++ b/frontend/src/components/sales/dashboard-sales-income/DashboardSalesIncomeContent.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; import { DoughnutChart } from '@/components/shared'; +import type { SALES_SOURCE, SalesSourceType } from '@/constants/sales'; import { PERIOD_PRESETS } from '@/constants/shared'; import type { SalesIncomeStructureInsight, SalesSource } from '@/types/sales'; import type { DoughnutChartItem } from '@/types/shared'; @@ -32,20 +33,22 @@ export const DashboardSalesIncomeContent = ({ interface DashboardSalesIncomeContentComparisonMessageProps { periodType: ValueOf; - topType: SalesIncomeStructureInsight['topType']; - topShare: SalesIncomeStructureInsight['topShare']; - deltaShare: SalesIncomeStructureInsight['deltaShare']; + topTypeLabel: SalesSourceType; + topShare: SalesIncomeStructureInsight['topShare']; + deltaShare: SalesIncomeStructureInsight< + keyof typeof SALES_SOURCE + >['deltaShare']; } export const DashboardSalesIncomeContentComparisonMessage = ({ periodType, - topType, + topTypeLabel, topShare, deltaShare, }: DashboardSalesIncomeContentComparisonMessageProps) => { const comparisonMessageTokens = getSalesIncomeStructureComparisonMessage({ periodType, - topType, + topTypeLabel, topShare, deltaShare, }); diff --git a/frontend/src/components/sales/dashboard-sales-income/OrderMethodContent.tsx b/frontend/src/components/sales/dashboard-sales-income/OrderChannelContent.tsx similarity index 53% rename from frontend/src/components/sales/dashboard-sales-income/OrderMethodContent.tsx rename to frontend/src/components/sales/dashboard-sales-income/OrderChannelContent.tsx index 5ca7f662d..91eee40aa 100644 --- a/frontend/src/components/sales/dashboard-sales-income/OrderMethodContent.tsx +++ b/frontend/src/components/sales/dashboard-sales-income/OrderChannelContent.tsx @@ -3,53 +3,62 @@ import { DASHBOARD_METRICS, type ExtractCardCodes, } from '@/constants/dashboard'; -import { ORDER_METHOD, SALES_SOURCE_COLORS } from '@/constants/sales'; -import type { GetIncomeStructureByOrderMethodResponseDto } from '@/types/sales'; +import { + ORDER_CHANNEL, + SALES_SOURCE, + SALES_SOURCE_COLORS, +} from '@/constants/sales'; +import type { GetIncomeStructureByOrderChannelResponseDto } from '@/types/sales'; import { DashboardSalesIncomeContent } from './DashboardSalesIncomeContent'; -const { DOUGHNUT_CHART_TITLE } = ORDER_METHOD; +const { DOUGHNUT_CHART_TITLE } = ORDER_CHANNEL; -type OrderMethodCardCodes = ExtractCardCodes< - typeof DASHBOARD_METRICS.SALES.sections.INCOME_STRUCTURE.items.ORDER_METHOD +type OrderChannelCardCodes = ExtractCardCodes< + typeof DASHBOARD_METRICS.SALES.sections.INCOME_STRUCTURE.items.ORDER_CHANNEL >; -interface OrderMethodContentProps extends GetIncomeStructureByOrderMethodResponseDto { - cardCode: OrderMethodCardCodes; +interface OrderChannelContentProps extends GetIncomeStructureByOrderChannelResponseDto { + cardCode: OrderChannelCardCodes; } -export const OrderMethodContent = ({ +export const OrderChannelContent = ({ cardCode, insight, items, -}: OrderMethodContentProps) => { +}: OrderChannelContentProps) => { const periodType = DASHBOARD_METRIC_CARDS[cardCode].period; - const orderMethodData = items.map((item) => ({ - salesSourceType: item.orderChannel, + const orderChannelData = items.map((item) => ({ + salesSourceType: SALES_SOURCE.ORDER_METHOD[item.orderChannel], revenue: item.salesAmount, count: item.orderCount, changeRate: item.deltaShare, })); - const chartData = orderMethodData.map((data) => ({ + const chartData = orderChannelData.map((data) => ({ label: data.salesSourceType, value: data.revenue, color: SALES_SOURCE_COLORS[data.salesSourceType], })); + const topTypeLabel = + SALES_SOURCE.ORDER_METHOD[ + insight.topType as keyof typeof SALES_SOURCE.ORDER_METHOD + ]; + return ( diff --git a/frontend/src/components/sales/dashboard-sales-income/OrderChannelContentEmptyView.tsx b/frontend/src/components/sales/dashboard-sales-income/OrderChannelContentEmptyView.tsx new file mode 100644 index 000000000..91159b134 --- /dev/null +++ b/frontend/src/components/sales/dashboard-sales-income/OrderChannelContentEmptyView.tsx @@ -0,0 +1,17 @@ +import { Inbox } from 'lucide-react'; + +export const OrderChannelContentEmptyView = () => { + return ( +
+
+ +
+

+ 주문 내역이 없어요 +

+ + POS, 키오스크, 배달앱 등을 통해{'\n'}주문된 내역이 없습니다. + +
+ ); +}; diff --git a/frontend/src/components/sales/dashboard-sales-income/PaymentMethodContent.tsx b/frontend/src/components/sales/dashboard-sales-income/PaymentMethodContent.tsx index 26e49de50..582c1025c 100644 --- a/frontend/src/components/sales/dashboard-sales-income/PaymentMethodContent.tsx +++ b/frontend/src/components/sales/dashboard-sales-income/PaymentMethodContent.tsx @@ -3,7 +3,11 @@ import { DASHBOARD_METRICS, type ExtractCardCodes, } from '@/constants/dashboard'; -import { PAYMENT_METHOD, SALES_SOURCE_COLORS } from '@/constants/sales'; +import { + PAYMENT_METHOD, + SALES_SOURCE, + SALES_SOURCE_COLORS, +} from '@/constants/sales'; import type { GetIncomeStructureByPaymentMethodResponseDto } from '@/types/sales'; import { DashboardSalesIncomeContent } from './DashboardSalesIncomeContent'; @@ -26,7 +30,7 @@ export const PaymentMethodContent = ({ const periodType = DASHBOARD_METRIC_CARDS[cardCode].period; const paymentMethodData = items.map((item) => ({ - salesSourceType: item.payMethod, + salesSourceType: SALES_SOURCE.PAYMENT_METHOD[item.payMethod], revenue: item.salesAmount, count: item.orderCount, changeRate: item.deltaShare, @@ -38,11 +42,13 @@ export const PaymentMethodContent = ({ color: SALES_SOURCE_COLORS[data.salesSourceType], })); + const topTypeLabel = SALES_SOURCE.PAYMENT_METHOD[insight.topType]; + return ( diff --git a/frontend/src/components/sales/dashboard-sales-income/PaymentMethodContentEmptyView.tsx b/frontend/src/components/sales/dashboard-sales-income/PaymentMethodContentEmptyView.tsx new file mode 100644 index 000000000..11ddd7e61 --- /dev/null +++ b/frontend/src/components/sales/dashboard-sales-income/PaymentMethodContentEmptyView.tsx @@ -0,0 +1,17 @@ +import { CreditCard } from 'lucide-react'; + +export const PaymentMethodContentEmptyView = () => { + return ( +
+
+ +
+

+ 결제 내역이 없어요 +

+ + 카드, 현금, 간편결제 등으로{'\n'}결제된 내역이 없습니다. + +
+ ); +}; diff --git a/frontend/src/components/sales/dashboard-sales-income/SalesTypeContent.tsx b/frontend/src/components/sales/dashboard-sales-income/SalesTypeContent.tsx index 310cb20f4..474d1fd75 100644 --- a/frontend/src/components/sales/dashboard-sales-income/SalesTypeContent.tsx +++ b/frontend/src/components/sales/dashboard-sales-income/SalesTypeContent.tsx @@ -3,7 +3,11 @@ import { DASHBOARD_METRICS, type ExtractCardCodes, } from '@/constants/dashboard'; -import { SALES_SOURCE_COLORS, SALES_TYPE } from '@/constants/sales'; +import { + SALES_SOURCE, + SALES_SOURCE_COLORS, + SALES_TYPE, +} from '@/constants/sales'; import type { GetIncomeStructureBySalesTypeResponseDto } from '@/types/sales'; import { DashboardSalesIncomeContent } from './DashboardSalesIncomeContent'; @@ -26,7 +30,7 @@ export const SalesTypeContent = ({ const periodType = DASHBOARD_METRIC_CARDS[cardCode].period; const salesTypeData = items.map((item) => ({ - salesSourceType: item.salesType, + salesSourceType: SALES_SOURCE.SALE_TYPE[item.salesType], revenue: item.salesAmount, count: item.orderCount, changeRate: item.deltaShare, @@ -38,11 +42,13 @@ export const SalesTypeContent = ({ color: SALES_SOURCE_COLORS[data.salesSourceType], })); + const topTypeLabel = SALES_SOURCE.SALE_TYPE[insight.topType]; + return ( diff --git a/frontend/src/components/sales/dashboard-sales-income/SalesTypeContentEmptyView.tsx b/frontend/src/components/sales/dashboard-sales-income/SalesTypeContentEmptyView.tsx new file mode 100644 index 000000000..f69e42303 --- /dev/null +++ b/frontend/src/components/sales/dashboard-sales-income/SalesTypeContentEmptyView.tsx @@ -0,0 +1,17 @@ +import { PieChart } from 'lucide-react'; + +export const SalesTypeContentEmptyView = () => { + return ( +
+
+ +
+

+ 판매 내역이 없어요 +

+ + 선택하신 기간 동안 발생한 홀, 배달, 포장{'\n'}매출 데이터가 없습니다. + +
+ ); +}; diff --git a/frontend/src/components/sales/dashboard-sales-income/index.ts b/frontend/src/components/sales/dashboard-sales-income/index.ts index 108d2f0e2..37c7bdabc 100644 --- a/frontend/src/components/sales/dashboard-sales-income/index.ts +++ b/frontend/src/components/sales/dashboard-sales-income/index.ts @@ -1,4 +1,7 @@ export { DashboardSalesIncomeContent } from './DashboardSalesIncomeContent'; export { SalesTypeContent } from './SalesTypeContent'; -export { OrderMethodContent } from './OrderMethodContent'; +export { OrderChannelContent } from './OrderChannelContent'; export { PaymentMethodContent } from './PaymentMethodContent'; +export { SalesTypeContentEmptyView } from './SalesTypeContentEmptyView'; +export { PaymentMethodContentEmptyView } from './PaymentMethodContentEmptyView'; +export { OrderChannelContentEmptyView } from './OrderChannelContentEmptyView'; diff --git a/frontend/src/components/sales/dashboard-sales-pattern/PeakTimeContent.tsx b/frontend/src/components/sales/dashboard-sales-pattern/PeakTimeContent.tsx index 27b4508b6..01545837b 100644 --- a/frontend/src/components/sales/dashboard-sales-pattern/PeakTimeContent.tsx +++ b/frontend/src/components/sales/dashboard-sales-pattern/PeakTimeContent.tsx @@ -20,7 +20,7 @@ export const PeakTimeContent = ({ peakTimeData, className, }: PeakTimeContentProps) => { - const weekday = DAY_OF_WEEK_LIST[new Date().getDay()]; + const weekday = DAY_OF_WEEK_LIST[(new Date().getDay() + 6) % 7]; const { todayItems, @@ -37,14 +37,39 @@ export const PeakTimeContent = ({ }); const primarySeries = useMemo(() => { + const lastItemIndexNotNull = [...todayItems] + .reverse() + .findIndex((item) => item.orderCount !== null); + const lastItemIndex = + lastItemIndexNotNull === -1 + ? -1 + : todayItems.length - lastItemIndexNotNull - 1; + return { - ...createPeakTimeSeries(todayItems, 'var(--color-brand-main)'), + ...createPeakTimeSeries( + todayItems.map((item, index) => { + if (index < lastItemIndex) { + return { + ...item, + orderCount: item.orderCount ?? 0, + }; + } + return item; + }), + 'var(--color-brand-main)', + ), }; }, [todayItems]); const secondarySeries = useMemo(() => { return { - ...createPeakTimeSeries(week4Items, 'var(--color-grey-400)'), + ...createPeakTimeSeries( + week4Items.map((item) => ({ + ...item, + orderCount: item.orderCount ?? 0, + })), + 'var(--color-grey-400)', + ), }; }, [week4Items]); @@ -62,9 +87,9 @@ export const PeakTimeContent = ({ color="default" /> -
+
-

+

{peakTimeBriefingMessage.map( ({ text, isHighlight, highlightColor }, index) => { return ( diff --git a/frontend/src/components/sales/dashboard-sales-pattern/SalesByDayContent.tsx b/frontend/src/components/sales/dashboard-sales-pattern/SalesByDayContent.tsx index 1bf8f27d2..38e2e746e 100644 --- a/frontend/src/components/sales/dashboard-sales-pattern/SalesByDayContent.tsx +++ b/frontend/src/components/sales/dashboard-sales-pattern/SalesByDayContent.tsx @@ -29,7 +29,7 @@ export const SalesByDayContent = ({ unit: CHART_X_UNIT, })), mainY: salesByDayItems.map((item) => ({ - amount: item.avgNetAmount, + amount: item.avgNetAmount ?? 0, unit: CHART_Y_UNIT, })), }, @@ -53,13 +53,13 @@ export const SalesByDayContent = ({ barChartSeries={salesByDaySeries} hasXAxis hasBarGradient - showYGuideLine yGuideLineCount={4} hasBarLabel={false} + barColorChangeOnHover={false} activeDataIndex={activeDataIndex === -1 ? undefined : activeDataIndex} xAxisType="default" /> -

+

{salesByDayBriefingMessage.map( ({ text, isHighlight, highlightColor }, index) => { return ( diff --git a/frontend/src/components/sales/dashboard-sales-trend/SalesTrendContent.tsx b/frontend/src/components/sales/dashboard-sales-trend/SalesTrendContent.tsx index 50b455d45..50e4fbb20 100644 --- a/frontend/src/components/sales/dashboard-sales-trend/SalesTrendContent.tsx +++ b/frontend/src/components/sales/dashboard-sales-trend/SalesTrendContent.tsx @@ -28,7 +28,7 @@ export const SalesTrendContent = ({ trendChartHeight, className, }: SalesTrendContentProps) => { - const { period, label } = DASHBOARD_METRIC_CARDS[cardCode]; + const { period } = DASHBOARD_METRIC_CARDS[cardCode]; const { DEFAULT_TREND_CHART_WIDTH, DEFAULT_TREND_CHART_WIDTH_FOR_RECENT_30_DAYS, @@ -53,29 +53,30 @@ export const SalesTrendContent = ({ className, )} > -

-

{label}

-
-
-
- 실매출 -
-
-
- 주문건수 -
+
+
+
+ 실매출
+
+
+ 주문건수 +
+
+
+
- ); }; diff --git a/frontend/src/components/sales/index.ts b/frontend/src/components/sales/index.ts index 70173ff0b..f0aefd52f 100644 --- a/frontend/src/components/sales/index.ts +++ b/frontend/src/components/sales/index.ts @@ -7,11 +7,14 @@ export { OrderCountContent, RealSalesContent, } from './dashboard-current-sales'; -export { DashboardSalesIncomeContent } from './dashboard-sales-income'; export { + DashboardSalesIncomeContent, SalesTypeContent, - OrderMethodContent, + OrderChannelContent, PaymentMethodContent, + SalesTypeContentEmptyView, + PaymentMethodContentEmptyView, + OrderChannelContentEmptyView, } from './dashboard-sales-income'; export { PeakTimeContent, SalesByDayContent } from './dashboard-sales-pattern'; export { SalesTrendContent } from './dashboard-sales-trend'; diff --git a/frontend/src/components/shared/bar-line-chart/BarLineSeriesWithTooltip.tsx b/frontend/src/components/shared/bar-line-chart/BarLineSeriesWithTooltip.tsx index ebf27ba53..32949ad3e 100644 --- a/frontend/src/components/shared/bar-line-chart/BarLineSeriesWithTooltip.tsx +++ b/frontend/src/components/shared/bar-line-chart/BarLineSeriesWithTooltip.tsx @@ -35,6 +35,8 @@ export const BarLineSeriesWithTooltip = ({ + {/* g태그는 Dot과 Bar 사이의 빈공간에 대한 interaction을 하지 않음 + 따라서, Dot과 Bar를 감싸는 path 태그를 추가해서 interaction을 가능하게 함 */} { + const titleComponent = ({ + title, + hasChevronRightIcon, + onClickChevronRightIcon, + }: { + title?: string; + hasChevronRightIcon?: boolean; + onClickChevronRightIcon?: () => void; + }) => { + if (hasChevronRightIcon && onClickChevronRightIcon) { + return ( + + ); + } + + if (title) { + return

{title}

; + } + + return null; + }; + return (
{(title || hasChevronRightIcon) && (
- {title &&

{title}

} - {hasChevronRightIcon && ( - - )} + {titleComponent({ + title, + hasChevronRightIcon, + onClickChevronRightIcon, + })}
)} {children} diff --git a/frontend/src/components/shared/line-chart/LineChart.tsx b/frontend/src/components/shared/line-chart/LineChart.tsx index 86ebcea21..7ca37aaa2 100644 --- a/frontend/src/components/shared/line-chart/LineChart.tsx +++ b/frontend/src/components/shared/line-chart/LineChart.tsx @@ -155,15 +155,6 @@ export const LineChart = ({ /> )} - {secondarySeries && ( )} + ); }; diff --git a/frontend/src/constants/dashboard/dashboardMetric.ts b/frontend/src/constants/dashboard/dashboardMetric.ts index 3cfb12848..f1d7d6d09 100644 --- a/frontend/src/constants/dashboard/dashboardMetric.ts +++ b/frontend/src/constants/dashboard/dashboardMetric.ts @@ -1,4 +1,4 @@ -import type { MetricCardCode } from './dashboardMetricCards'; +import { isMetricCardCode, type MetricCardCode } from './dashboardMetricCards'; /** * 카드 및 섹션 구조 인터페이스 @@ -46,7 +46,7 @@ const SALES_METRICS = { label: '판매유형별 매출', cardCodes: ['SLS_06_01', 'SLS_06_02', 'SLS_06_03'] as const, }, - ORDER_METHOD: { + ORDER_CHANNEL: { label: '주문수단별 매출', cardCodes: ['SLS_07_01', 'SLS_07_02', 'SLS_07_03'] as const, }, @@ -205,3 +205,106 @@ export type ExtractCardCodes = T extends { export type ExtractCardCodesFromSection = T extends MetricSection ? ExtractCardCodes : never; + +type ExtractCardCodesFromMetricTabs = T extends MetricTabs + ? ExtractCardCodesFromSection + : never; + +const createMetricCardCodeByCategoryGuard = ( + metrics: M, +) => { + const codesInMetrics = new Set( + Object.values(metrics.sections).flatMap((section) => + Object.values(section.items).flatMap((item) => item.cardCodes), + ), + ); + + return (code: string): code is ExtractCardCodesFromMetricTabs => { + return codesInMetrics.has(code as MetricCardCode); + }; +}; + +export const isSalesMetricCardCode = + createMetricCardCodeByCategoryGuard(SALES_METRICS); +export const isMenuMetricCardCode = + createMetricCardCodeByCategoryGuard(MENU_METRICS); + +const createMetricCardCodeBySectionItemGuard = ( + metrics: M, +) => { + const codesInMetrics = new Set(metrics.cardCodes); + + return (code: string): code is ExtractCardCodes => { + if (!isMetricCardCode(code)) { + return false; + } + return codesInMetrics.has(code); + }; +}; + +/** + * 매출현황 관련 카드 코드 타입 가드 + */ +export const isRealSalesMetricCardCode = createMetricCardCodeBySectionItemGuard( + SALES_METRICS.sections.CURRENT_SALES.items.REAL_SALES, +); +export const isOrderCountMetricCardCode = + createMetricCardCodeBySectionItemGuard( + SALES_METRICS.sections.CURRENT_SALES.items.ORDER_COUNT, + ); +export const isAveragePriceMetricCardCode = + createMetricCardCodeBySectionItemGuard( + SALES_METRICS.sections.CURRENT_SALES.items.AVERAGE_PRICE, + ); +export const isSalesTypeMetricCardCode = createMetricCardCodeBySectionItemGuard( + SALES_METRICS.sections.INCOME_STRUCTURE.items.SALES_TYPE, +); +export const isOrderChannelMetricCardCode = + createMetricCardCodeBySectionItemGuard( + SALES_METRICS.sections.INCOME_STRUCTURE.items.ORDER_CHANNEL, + ); +export const isPaymentMethodMetricCardCode = + createMetricCardCodeBySectionItemGuard( + SALES_METRICS.sections.INCOME_STRUCTURE.items.PAYMENT_METHOD, + ); +export const isDailySalesTrendMetricCardCode = + createMetricCardCodeBySectionItemGuard( + SALES_METRICS.sections.SALES_TREND.items.DAILY_SALES_TREND, + ); +export const isWeeklySalesTrendMetricCardCode = + createMetricCardCodeBySectionItemGuard( + SALES_METRICS.sections.SALES_TREND.items.WEEKLY_SALES_TREND, + ); +export const isMonthlySalesTrendMetricCardCode = + createMetricCardCodeBySectionItemGuard( + SALES_METRICS.sections.SALES_TREND.items.MONTHLY_SALES_TREND, + ); +export const isPeakTimeMetricCardCode = createMetricCardCodeBySectionItemGuard( + SALES_METRICS.sections.SALES_PATTERN.items.PEAK_TIME, +); +export const isWeekdaySalesPatternMetricCardCode = + createMetricCardCodeBySectionItemGuard( + SALES_METRICS.sections.SALES_PATTERN.items.WEEKDAY_SALES_PATTERN, + ); + +/** + * 메뉴분석 관련 카드 코드 타입 가드 + */ +export const isMenuSalesRankingMetricCardCode = + createMetricCardCodeBySectionItemGuard( + MENU_METRICS.sections.POPULAR_MENU.items.MENU_SALES_RANKING, + ); +export const isTimeBasedMenuOrderCountMetricCardCode = + createMetricCardCodeBySectionItemGuard( + MENU_METRICS.sections.MENU_SALES_PATTERN.items.TIME_BASED_MENU_ORDER_COUNT, + ); +export const isIngredientConsumptionRankMetricCardCode = + createMetricCardCodeBySectionItemGuard( + MENU_METRICS.sections.INGREDIENT_CONSUMPTION_RANK.items + .INGREDIENT_CONSUMPTION_RANK, + ); +export const isPopularMenuCombinationMetricCardCode = + createMetricCardCodeBySectionItemGuard( + MENU_METRICS.sections.POPULAR_MENU_COMBINATION.items + .POPULAR_MENU_COMBINATION, + ); diff --git a/frontend/src/constants/dashboard/index.ts b/frontend/src/constants/dashboard/index.ts index a91b39b82..f1eac2bc0 100644 --- a/frontend/src/constants/dashboard/index.ts +++ b/frontend/src/constants/dashboard/index.ts @@ -11,6 +11,23 @@ export { type ExtractCardCodesFromSection, type MetricTabs, type ExtractCardCodes, + isSalesMetricCardCode, + isMenuMetricCardCode, + isRealSalesMetricCardCode, + isOrderCountMetricCardCode, + isAveragePriceMetricCardCode, + isSalesTypeMetricCardCode, + isOrderChannelMetricCardCode, + isPaymentMethodMetricCardCode, + isDailySalesTrendMetricCardCode, + isWeeklySalesTrendMetricCardCode, + isMonthlySalesTrendMetricCardCode, + isPeakTimeMetricCardCode, + isWeekdaySalesPatternMetricCardCode, + isMenuSalesRankingMetricCardCode, + isIngredientConsumptionRankMetricCardCode, + isTimeBasedMenuOrderCountMetricCardCode, + isPopularMenuCombinationMetricCardCode, } from './dashboardMetric'; export { DASHBOARD_METRIC_CARDS, diff --git a/frontend/src/constants/sales/dashboard-sales-income/index.ts b/frontend/src/constants/sales/dashboard-sales-income/index.ts index 29a6b99f6..04aa7b2e2 100644 --- a/frontend/src/constants/sales/dashboard-sales-income/index.ts +++ b/frontend/src/constants/sales/dashboard-sales-income/index.ts @@ -1,3 +1,3 @@ -export { ORDER_METHOD } from './orderMethod'; +export { ORDER_CHANNEL } from './orderChannel'; export { PAYMENT_METHOD } from './paymentMethod'; export { SALES_TYPE } from './salesType'; diff --git a/frontend/src/constants/sales/dashboard-sales-income/orderChannel.ts b/frontend/src/constants/sales/dashboard-sales-income/orderChannel.ts new file mode 100644 index 000000000..22ae2fa96 --- /dev/null +++ b/frontend/src/constants/sales/dashboard-sales-income/orderChannel.ts @@ -0,0 +1,37 @@ +import type { GetIncomeStructureByOrderChannelResponseDto } from '@/types/sales'; + +import { SALES_SOURCE } from '../salesSource'; + +const ORDER_METHOD_KEYS = Object.keys( + SALES_SOURCE.ORDER_METHOD, +) as (keyof typeof SALES_SOURCE.ORDER_METHOD)[]; + +export const ORDER_CHANNEL = { + EXAMPLE_TOP_TYPE: 'KIOSK', + EXAMPLE_TOP_SHARE: 50, + EXAMPLE_DELTA_SHARE: 4, + EXAMPLE_ORDER_CHANNEL_DATA: [ + { + orderChannel: ORDER_METHOD_KEYS[0], + salesAmount: 2371000, + orderCount: 26, + share: 25, + deltaShare: 2.4, + }, + { + orderChannel: ORDER_METHOD_KEYS[1], + salesAmount: 5329000, + orderCount: 53, + share: 25, + deltaShare: 4, + }, + { + orderChannel: ORDER_METHOD_KEYS[2], + salesAmount: 1986000, + orderCount: 19, + share: 25, + deltaShare: -5.2, + }, + ] as const satisfies GetIncomeStructureByOrderChannelResponseDto['items'], + DOUGHNUT_CHART_TITLE: '주문수단별 매출 관련 도넛 차트', +} as const; diff --git a/frontend/src/constants/sales/dashboard-sales-income/orderMethod.ts b/frontend/src/constants/sales/dashboard-sales-income/orderMethod.ts deleted file mode 100644 index 4d2818f9d..000000000 --- a/frontend/src/constants/sales/dashboard-sales-income/orderMethod.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { GetIncomeStructureByOrderMethodResponseDto } from '@/types/sales'; - -export const ORDER_METHOD = { - EXAMPLE_TOP_TYPE: '키오스크', - EXAMPLE_TOP_SHARE: 50, - EXAMPLE_DELTA_SHARE: 4, - EXAMPLE_ORDER_METHOD_DATA: [ - { - orderChannel: 'POS', - salesAmount: 2371000, - orderCount: 26, - share: 25, - deltaShare: 2.4, - }, - { - orderChannel: '키오스크', - salesAmount: 5329000, - orderCount: 53, - share: 25, - deltaShare: 4, - }, - { - orderChannel: '배달앱', - salesAmount: 1986000, - orderCount: 19, - share: 25, - deltaShare: -5.2, - }, - { - orderChannel: '기타', - salesAmount: 954000, - orderCount: 24, - share: 25, - deltaShare: -1.8, - }, - ] as const satisfies GetIncomeStructureByOrderMethodResponseDto['items'], - DOUGHNUT_CHART_TITLE: '주문수단별 매출 관련 도넛 차트', -} as const; diff --git a/frontend/src/constants/sales/dashboard-sales-income/paymentMethod.ts b/frontend/src/constants/sales/dashboard-sales-income/paymentMethod.ts index 8f8ad018e..aa8a00a13 100644 --- a/frontend/src/constants/sales/dashboard-sales-income/paymentMethod.ts +++ b/frontend/src/constants/sales/dashboard-sales-income/paymentMethod.ts @@ -1,33 +1,39 @@ import type { GetIncomeStructureByPaymentMethodResponseDto } from '@/types/sales'; +import { SALES_SOURCE } from '../salesSource'; + +const PAYMENT_METHOD_KEYS = Object.keys( + SALES_SOURCE.PAYMENT_METHOD, +) as (keyof typeof SALES_SOURCE.PAYMENT_METHOD)[]; + export const PAYMENT_METHOD = { - EXAMPLE_TOP_TYPE: '현금', + EXAMPLE_TOP_TYPE: 'CASH' as const, EXAMPLE_TOP_SHARE: 46, EXAMPLE_DELTA_SHARE: 6.7, EXAMPLE_PAYMENT_METHOD_DATA: [ { - payMethod: '카드', + payMethod: PAYMENT_METHOD_KEYS[0], salesAmount: 2371000, orderCount: 26, share: 25, deltaShare: 4.4, }, { - payMethod: '현금', + payMethod: PAYMENT_METHOD_KEYS[1], salesAmount: 7531000, orderCount: 25, share: 25, deltaShare: 6.7, }, { - payMethod: '간편결제', + payMethod: PAYMENT_METHOD_KEYS[2], salesAmount: 2567000, orderCount: 75, share: 25, deltaShare: -5.2, }, { - payMethod: '기타', + payMethod: PAYMENT_METHOD_KEYS[3], salesAmount: 3894000, orderCount: 39, share: 25, diff --git a/frontend/src/constants/sales/dashboard-sales-income/salesType.ts b/frontend/src/constants/sales/dashboard-sales-income/salesType.ts index dcff66872..07b871de8 100644 --- a/frontend/src/constants/sales/dashboard-sales-income/salesType.ts +++ b/frontend/src/constants/sales/dashboard-sales-income/salesType.ts @@ -2,32 +2,36 @@ import type { GetIncomeStructureBySalesTypeResponseDto } from '@/types/sales'; import { SALES_SOURCE } from '../salesSource'; +const SALE_TYPE_KEYS = Object.keys( + SALES_SOURCE.SALE_TYPE, +) as (keyof typeof SALES_SOURCE.SALE_TYPE)[]; + export const SALES_TYPE = { - EXAMPLE_TOP_TYPE: '배달' as const, + EXAMPLE_TOP_TYPE: 'DELIVERY' as const, EXAMPLE_TOP_SHARE: 43, EXAMPLE_DELTA_SHARE: 6.8, EXAMPLE_SALES_SOURCE_DATA: [ { - salesType: SALES_SOURCE.SALE_TYPE.DINE_IN, + salesType: SALE_TYPE_KEYS[0], salesAmount: 2371000, orderCount: 26, share: 25, deltaShare: 4.4, }, { - salesType: SALES_SOURCE.SALE_TYPE.TAKEOUT, + salesType: SALE_TYPE_KEYS[2], salesAmount: 3255000, orderCount: 45, share: 45, deltaShare: -5.2, }, { - salesType: SALES_SOURCE.SALE_TYPE.DELIVERY, + salesType: SALE_TYPE_KEYS[1], salesAmount: 4255000, orderCount: 28, share: 30, deltaShare: 6.8, }, - ] as GetIncomeStructureBySalesTypeResponseDto['items'], + ] as const satisfies GetIncomeStructureBySalesTypeResponseDto['items'], DOUGHNUT_CHART_TITLE: '판매 유형 관련 도넛 차트', } as const; diff --git a/frontend/src/constants/sales/index.ts b/frontend/src/constants/sales/index.ts index d09fd3704..62941eec6 100644 --- a/frontend/src/constants/sales/index.ts +++ b/frontend/src/constants/sales/index.ts @@ -15,7 +15,7 @@ export { REAL_SALES, } from './dashboard-current-sales'; export { - ORDER_METHOD, + ORDER_CHANNEL, PAYMENT_METHOD, SALES_TYPE, } from './dashboard-sales-income'; diff --git a/frontend/src/constants/sales/salesSource.ts b/frontend/src/constants/sales/salesSource.ts index 6980fbe3c..fbf0a671b 100644 --- a/frontend/src/constants/sales/salesSource.ts +++ b/frontend/src/constants/sales/salesSource.ts @@ -5,7 +5,7 @@ export const SALES_SOURCE = { // 홀, 배달, 포장 DINE_IN: '홀', DELIVERY: '배달', - TAKEOUT: '포장', + TAKE_OUT: '포장', }, ORDER_METHOD: { POS: 'POS', @@ -15,7 +15,7 @@ export const SALES_SOURCE = { PAYMENT_METHOD: { CARD: '카드', CASH: '현금', - MOBILE: '간편결제', + EASY_PAY: '간편결제', ETC: '기타', }, } as const; @@ -25,13 +25,13 @@ export type SalesSourceType = DeepValueOf; export const SALES_SOURCE_COLORS = { [SALES_SOURCE.SALE_TYPE.DINE_IN]: 'var(--color-brand-500)', [SALES_SOURCE.SALE_TYPE.DELIVERY]: 'var(--color-grey-500)', - [SALES_SOURCE.SALE_TYPE.TAKEOUT]: 'var(--color-brand-50)', + [SALES_SOURCE.SALE_TYPE.TAKE_OUT]: 'var(--color-brand-50)', [SALES_SOURCE.ORDER_METHOD.POS]: 'var(--color-brand-500)', [SALES_SOURCE.ORDER_METHOD.KIOSK]: 'var(--color-grey-500)', [SALES_SOURCE.ORDER_METHOD.DELIVERY_APP]: 'var(--color-brand-200)', [SALES_SOURCE.PAYMENT_METHOD.CARD]: 'var(--color-brand-500)', [SALES_SOURCE.PAYMENT_METHOD.CASH]: 'var(--color-grey-500)', - [SALES_SOURCE.PAYMENT_METHOD.MOBILE]: 'var(--color-brand-200)', + [SALES_SOURCE.PAYMENT_METHOD.EASY_PAY]: 'var(--color-brand-200)', [SALES_SOURCE.PAYMENT_METHOD.ETC]: 'var(--color-brand-50)', }; diff --git a/frontend/src/hooks/dashboard/index.ts b/frontend/src/hooks/dashboard/index.ts index f7775c732..773449cff 100644 --- a/frontend/src/hooks/dashboard/index.ts +++ b/frontend/src/hooks/dashboard/index.ts @@ -1,6 +1,10 @@ export { useDashboardTabsContext } from './useDashboardTabsContext'; export { useDashboardTabsDialog } from './useDashboardTabsDialog'; export { useEditCard } from './useEditCard'; +export { useDashboardCardDetailQueryOption } from './useDashboardCardDetailQueryOption'; +export { useDashboardCardList } from './useDashboardCardList'; +export { useDashboardCardSubscription } from './useDashboardCardSubscription'; +export { useDashboardSseConnection } from './useDashboardSseConnection'; export { useEditCardContext } from './useEditCardContext'; export { useGridCellSize } from './useGridCellSize'; export { useDragAndDropCard } from './useDragAndDropCard'; diff --git a/frontend/src/hooks/dashboard/useDashboardCardDetailQueryOption.ts b/frontend/src/hooks/dashboard/useDashboardCardDetailQueryOption.ts new file mode 100644 index 000000000..b85a236a9 --- /dev/null +++ b/frontend/src/hooks/dashboard/useDashboardCardDetailQueryOption.ts @@ -0,0 +1,15 @@ +import { type MetricCardCode } from '@/constants/dashboard/dashboardMetricCards'; +import { useDashboardTabsContext } from '@/hooks/dashboard'; +import { dashboardOptions } from '@/services/dashboard'; + +export const useDashboardCardDetailQueryOption = () => { + const { currentDashboardId } = useDashboardTabsContext(); + + const createCardDetailQuery = (cardCode: MetricCardCode) => + dashboardOptions.cardDetail(currentDashboardId, { + analysisCardCode: cardCode, + customPeriod: false, + }); + + return { createCardDetailQuery }; +}; diff --git a/frontend/src/hooks/dashboard/useDashboardCardList.ts b/frontend/src/hooks/dashboard/useDashboardCardList.ts new file mode 100644 index 000000000..0ecf863d5 --- /dev/null +++ b/frontend/src/hooks/dashboard/useDashboardCardList.ts @@ -0,0 +1,15 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { dashboardOptions } from '@/services/dashboard'; + +import { useDashboardTabsContext } from './useDashboardTabsContext'; + +export const useDashboardCardList = () => { + const { currentDashboardId } = useDashboardTabsContext(); + + const { data: cardList } = useSuspenseQuery( + dashboardOptions.cardList(currentDashboardId), + ); + + return { cardList }; +}; diff --git a/frontend/src/hooks/dashboard/useDashboardCardSubscription.ts b/frontend/src/hooks/dashboard/useDashboardCardSubscription.ts new file mode 100644 index 000000000..a905456b7 --- /dev/null +++ b/frontend/src/hooks/dashboard/useDashboardCardSubscription.ts @@ -0,0 +1,45 @@ +import { useEffect } from 'react'; + +import { useMutation } from '@tanstack/react-query'; + +import { + deleteDashboardSseSubscription, + postDashboardSseSubscription, +} from '@/services/dashboard'; +import type { GetDashboardCardListResponseDto } from '@/types/dashboard'; + +interface UseDashboardCardSubscriptionProps { + cardList: GetDashboardCardListResponseDto; +} + +export const useDashboardCardSubscription = ({ + cardList, +}: UseDashboardCardSubscriptionProps) => { + const { mutate: subscribeDashboardCardList } = useMutation({ + mutationFn: postDashboardSseSubscription, + }); + + const { mutate: unsubscribeDashboardCardList } = useMutation({ + mutationFn: deleteDashboardSseSubscription, + }); + + useEffect(() => { + if (!cardList || cardList.length === 0) { + return; + } + + const topics = cardList.map((card) => card.cardCode); + + subscribeDashboardCardList({ + topics, + }); + + return () => { + if (cardList && cardList.length > 0) { + unsubscribeDashboardCardList({ + topics, + }); + } + }; + }, [cardList, subscribeDashboardCardList, unsubscribeDashboardCardList]); +}; diff --git a/frontend/src/hooks/dashboard/useDashboardSseConnection.ts b/frontend/src/hooks/dashboard/useDashboardSseConnection.ts new file mode 100644 index 000000000..27469350a --- /dev/null +++ b/frontend/src/hooks/dashboard/useDashboardSseConnection.ts @@ -0,0 +1,341 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useErrorBoundary } from 'react-error-boundary'; + +import { useQueryClient } from '@tanstack/react-query'; + +import { + isAveragePriceMetricCardCode, + isDailySalesTrendMetricCardCode, + isIngredientConsumptionRankMetricCardCode, + isMenuSalesRankingMetricCardCode, + isMetricCardCode, + isMonthlySalesTrendMetricCardCode, + isOrderChannelMetricCardCode, + isOrderCountMetricCardCode, + isPaymentMethodMetricCardCode, + isPeakTimeMetricCardCode, + isPopularMenuCombinationMetricCardCode, + isRealSalesMetricCardCode, + isSalesTypeMetricCardCode, + isTimeBasedMenuOrderCountMetricCardCode, + isWeekdaySalesPatternMetricCardCode, + isWeeklySalesTrendMetricCardCode, + type MetricCardCode, +} from '@/constants/dashboard'; +import { dashboardKeys } from '@/services/dashboard/keys'; +import { sseClient } from '@/services/shared'; +import type { + GetDashboardPopularMenuCombinationResponseDto, + GetDashboardTimeSlotMenuOrderCountResponseDto, + GetDetailTimeSlotMenuOrderCountResponseDto, + GetPopularMenuCombinationResponseDto, +} from '@/types/menu'; +import type { + GetDashboardPeakTimeResponseDto, + GetDashboardSalesByDayResponseDto, + GetDetailPeakTimeResponseDto, + GetDetailSalesByDayResponseDto, + GetSalesTrendResponseDto, + SalesTrendItem, +} from '@/types/sales'; +import type { EventSourceMessage } from '@/types/shared'; + +import { useDashboardTabsContext } from './useDashboardTabsContext'; + +export const useDashboardSseConnection = () => { + const { showBoundary } = useErrorBoundary(); + const { currentDashboardId } = useDashboardTabsContext(); + + const queryClient = useQueryClient(); + + const retryCountRef = useRef(0); + const currentDashboardIdRef = useRef(currentDashboardId); + + const getCardDetailQueryKey = useCallback((cardCode: MetricCardCode) => { + return dashboardKeys.cardDetail(currentDashboardIdRef.current, { + analysisCardCode: cardCode, + customPeriod: false, + }); + }, []); + + /** + * 매출 추이 쿼리 데이터 업데이트 함수 + */ + const updateSalesTrendData = useCallback( + (response: SalesTrendItem) => (oldData?: GetSalesTrendResponseDto) => { + if (!oldData) { + return oldData; + } + + return { + items: [...oldData.items.slice(0, -1), response], + }; + }, + [], + ); + + /** + * 피크타임 쿼리 데이터 업데이트 함수 + */ + const updatePeakTimeData = useCallback( + (response: GetDashboardPeakTimeResponseDto) => + (oldData?: GetDetailPeakTimeResponseDto) => { + if (!oldData) { + return oldData; + } + + const { + timeSlot2H, + orderCount, + netAmount, + todayPeak, + comparisonPeak, + diff, + shiftDirection, + beforeComparisonPeak, + } = response; + + return { + todayPeak, + comparisonPeak, + diff, + shiftDirection, + beforeComparisonPeak, + todayItems: [ + ...oldData.todayItems.map((item) => { + if (item.timeSlot2H === timeSlot2H) { + return { + ...item, + orderCount, + netAmount, + }; + } + return item; + }), + ], + week4Items: [...oldData.week4Items], + }; + }, + [], + ); + + /** + * 요일별 매출 패턴 쿼리 데이터 업데이트 함수 + */ + const updateWeekdaySalesPatternData = useCallback( + (response: GetDashboardSalesByDayResponseDto) => + (oldData?: GetDetailSalesByDayResponseDto) => { + if (!oldData) { + return oldData; + } + + const { topDay, isSignificant, day, avgNetAmount, orderCount } = + response; + return { + topDay, + isSignificant, + items: oldData.items.map((item) => { + if (item.day === day) { + return { + ...item, + avgNetAmount, + orderCount, + }; + } + return item; + }), + }; + }, + [], + ); + + /** + * 시간대별 메뉴 주문건수 쿼리 데이터 업데이트 함수 + */ + const updateTimeBasedMenuOrderCountData = useCallback( + (response: GetDashboardTimeSlotMenuOrderCountResponseDto) => + (oldData?: GetDetailTimeSlotMenuOrderCountResponseDto) => { + const { timeSlot2H, menuName } = response; + + if (!oldData) { + return oldData; + } + + // 깊은 복사 후 해당 객체 수정 + const newItems = structuredClone(oldData.items); + + const firstItem = newItems[0]; + if (!firstItem) { + return { + items: newItems, + }; + } + + firstItem.timeSlot2H = timeSlot2H; + + const firstMenu = firstItem.menus?.[0]; + if (firstMenu) { + firstMenu.menuName = menuName; + } + + return { + items: newItems, + }; + }, + [], + ); + + /** + * 인기 메뉴 조합 쿼리 데이터 업데이트 함수 + */ + const updatePopularMenuCombinationData = useCallback( + (response: GetDashboardPopularMenuCombinationResponseDto) => + (oldData?: GetPopularMenuCombinationResponseDto) => { + const { firstMenuName, secondMenuName } = response; + + if (!oldData) { + return oldData; + } + + // 깊은 복사 후 해당 객체 수정 + const newItems = structuredClone(oldData.items); + + const firstItem = newItems[0]; + if (!firstItem) { + return { + items: newItems, + }; + } + + firstItem.baseMenuName = firstMenuName; + + if (firstItem.pairedMenus) { + const newPairedMenus = [...firstItem.pairedMenus]; + const firstPairedMenu = newPairedMenus[0]; + + if (firstPairedMenu) { + firstPairedMenu.menuName = secondMenuName; + } + + firstItem.pairedMenus = newPairedMenus; + } + + return { + items: newItems, + }; + }, + [], + ); + + const handleSseMessage = useCallback( + (message: EventSourceMessage) => { + if (message.event === 'connect') { + retryCountRef.current = 0; + } + + if (isMetricCardCode(message.event)) { + try { + const response = JSON.parse(message.data); + // 매출 추이, 매출 패턴 제외 최신 데이터로 덮어쓰기 + if ( + isRealSalesMetricCardCode(message.event) || + isOrderCountMetricCardCode(message.event) || + isAveragePriceMetricCardCode(message.event) || + isMenuSalesRankingMetricCardCode(message.event) || + isIngredientConsumptionRankMetricCardCode(message.event) || + isSalesTypeMetricCardCode(message.event) || + isOrderChannelMetricCardCode(message.event) || + isPaymentMethodMetricCardCode(message.event) + ) { + queryClient.setQueryData( + getCardDetailQueryKey(message.event), + response, + ); + } + + // 매출 추이 제일 최근 데이터만 업데이트 + if ( + isDailySalesTrendMetricCardCode(message.event) || + isWeeklySalesTrendMetricCardCode(message.event) || + isMonthlySalesTrendMetricCardCode(message.event) + ) { + queryClient.setQueryData( + getCardDetailQueryKey(message.event), + updateSalesTrendData(response), + ); + } + + // 피크타임 현재 시간 데이터만 업데이트 + if (isPeakTimeMetricCardCode(message.event)) { + queryClient.setQueryData( + getCardDetailQueryKey(message.event), + updatePeakTimeData(response), + ); + } + + // 요일별 매출 패턴 제일 최근 데이터만 업데이트 및 최다 매출 요일 갱신 + if (isWeekdaySalesPatternMetricCardCode(message.event)) { + queryClient.setQueryData( + getCardDetailQueryKey(message.event), + updateWeekdaySalesPatternData(response), + ); + } + + if (isTimeBasedMenuOrderCountMetricCardCode(message.event)) { + queryClient.setQueryData( + getCardDetailQueryKey(message.event), + updateTimeBasedMenuOrderCountData(response), + ); + } + + if (isPopularMenuCombinationMetricCardCode(message.event)) { + queryClient.setQueryData( + getCardDetailQueryKey(message.event), + updatePopularMenuCombinationData(response), + ); + } + } catch (error) { + showBoundary(error); + } + } + }, + [ + queryClient, + getCardDetailQueryKey, + updatePeakTimeData, + updateTimeBasedMenuOrderCountData, + updateWeekdaySalesPatternData, + updateSalesTrendData, + updatePopularMenuCombinationData, + showBoundary, + ], + ); + + const handleSseError = useCallback(() => { + retryCountRef.current++; + // 지수 백오프 + const backoff = Math.min( + 1000 * Math.pow(2, retryCountRef.current - 1), + 30000, + ); + return backoff; + }, []); + + useEffect(() => { + currentDashboardIdRef.current = currentDashboardId; + }, [currentDashboardId]); + + useEffect(() => { + const abortController = new AbortController(); + + sseClient('/api/sse/connection', { + signal: abortController.signal, + onmessage: handleSseMessage, + onerror: handleSseError, + }); + + return () => { + abortController.abort(); + }; + }, [handleSseMessage, handleSseError]); +}; diff --git a/frontend/src/mocks/analysis/analysisHandler.ts b/frontend/src/mocks/analysis/analysisHandler.ts new file mode 100644 index 000000000..ae688362d --- /dev/null +++ b/frontend/src/mocks/analysis/analysisHandler.ts @@ -0,0 +1,102 @@ +import { HttpResponse, passthrough } from 'msw'; + +import { + isOrderChannelMetricCardCode, + isPaymentMethodMetricCardCode, + isSalesTypeMetricCardCode, + isWeekdaySalesPatternMetricCardCode, +} from '@/constants/dashboard'; +import { + ORDER_CHANNEL, + PAYMENT_METHOD, + SALES_BY_DAY, + SALES_TYPE, +} from '@/constants/sales'; +import type { SuccessResponse } from '@/services/shared'; +import type { + GetDetailSalesByDayResponseDto, + GetIncomeStructureByOrderChannelResponseDto, + GetIncomeStructureByPaymentMethodResponseDto, + GetIncomeStructureBySalesTypeResponseDto, +} from '@/types/sales'; + +import { mswHttp } from '../shared'; + +const getHandler = [ + mswHttp.get('/api/analysis/detail', ({ request }) => { + const url = new URL(request.url); + const cardCode = url.searchParams.get('analysisCardCode'); + + if (!cardCode) { + return passthrough(); + } + + if (isSalesTypeMetricCardCode(cardCode)) { + return HttpResponse.json< + SuccessResponse + >({ + success: true, + message: 'Success', + data: { + insight: { + topType: 'DINE_IN', + topShare: 43, + deltaShare: 6.8, + }, + items: SALES_TYPE.EXAMPLE_SALES_SOURCE_DATA, + }, + }); + } + + if (isOrderChannelMetricCardCode(cardCode)) { + return HttpResponse.json< + SuccessResponse + >({ + success: true, + message: 'Success', + data: { + insight: { + topType: 'KIOSK', + topShare: 50, + deltaShare: 4, + }, + items: ORDER_CHANNEL.EXAMPLE_ORDER_CHANNEL_DATA, + }, + }); + } + + if (isPaymentMethodMetricCardCode(cardCode)) { + return HttpResponse.json< + SuccessResponse + >({ + success: true, + message: 'Success', + data: { + insight: { + topType: 'CASH', + topShare: 46, + deltaShare: 6.7, + }, + items: PAYMENT_METHOD.EXAMPLE_PAYMENT_METHOD_DATA, + }, + }); + } + + if (isWeekdaySalesPatternMetricCardCode(cardCode)) { + return HttpResponse.json>( + { + success: true, + message: 'Success', + data: { + topDay: '금', + isSignificant: false, + items: SALES_BY_DAY.EXAMPLE_DATA, + }, + }, + ); + } + return passthrough(); + }), +]; + +export const analysisHandler = [...getHandler]; diff --git a/frontend/src/mocks/analysis/index.ts b/frontend/src/mocks/analysis/index.ts new file mode 100644 index 000000000..d38d158f0 --- /dev/null +++ b/frontend/src/mocks/analysis/index.ts @@ -0,0 +1 @@ +export { analysisHandler } from './analysisHandler'; diff --git a/frontend/src/mocks/auth/authHandler.ts b/frontend/src/mocks/auth/authHandler.ts index 02ce27534..b5842bcf4 100644 --- a/frontend/src/mocks/auth/authHandler.ts +++ b/frontend/src/mocks/auth/authHandler.ts @@ -11,6 +11,7 @@ import { mswHttp } from '../shared'; const getHandler = [ mswHttp.get('/auth/status', ({ request }) => { + return passthrough(); // access token 갱신 테스트를 위해 첫 요청은 401 응답 if (request.headers.get('Authorization') === null) { return HttpResponse.json( diff --git a/frontend/src/mocks/data/dashboard/index.ts b/frontend/src/mocks/data/dashboard/index.ts index ef182d1b2..21c924257 100644 --- a/frontend/src/mocks/data/dashboard/index.ts +++ b/frontend/src/mocks/data/dashboard/index.ts @@ -1 +1,5 @@ export { gridItems } from './gridItem'; +export { + dashboardMenuSalesRankItems, + dashboardMenuIngredientRankItems, +} from './menu'; diff --git a/frontend/src/mocks/data/dashboard/menu/dashboardMenuIngredientRankItems.ts b/frontend/src/mocks/data/dashboard/menu/dashboardMenuIngredientRankItems.ts new file mode 100644 index 000000000..6c030b6f3 --- /dev/null +++ b/frontend/src/mocks/data/dashboard/menu/dashboardMenuIngredientRankItems.ts @@ -0,0 +1,26 @@ +export const dashboardMenuIngredientRankItems = [ + { + rank: 1, + itemName: '우유', + amount: 3920, + unit: 'ml', + }, + { + rank: 2, + itemName: '케냐산 원두', + amount: 541200, + unit: 'g', + }, + { + rank: 3, + itemName: '생딸기', + amount: 820, + unit: 'g', + }, + { + rank: 4, + itemName: '바닐라 시럽', + amount: 400, + unit: 'ml', + }, +] as const; diff --git a/frontend/src/mocks/data/dashboard/menu/dashboardMenuSalesRank.ts b/frontend/src/mocks/data/dashboard/menu/dashboardMenuSalesRank.ts new file mode 100644 index 000000000..0d0395a2c --- /dev/null +++ b/frontend/src/mocks/data/dashboard/menu/dashboardMenuSalesRank.ts @@ -0,0 +1,26 @@ +export const dashboardMenuSalesRankItems = [ + { + rank: 1, + itemName: '제주 유기농 말차로 만든 부드러운 라떼 (ICE)', + amount: 999999999, + unit: '원', + }, + { + rank: 2, + itemName: '아메리카노(ICE)', + amount: 541200, + unit: '원', + }, + { + rank: 3, + itemName: '진한 초콜릿 칩 자바 칩 프라푸치노 휘핑 많이', + amount: 482000, + unit: '원', + }, + { + rank: 4, + itemName: '아메리카노(HOT)', + amount: 41200, + unit: '원', + }, +] as const; diff --git a/frontend/src/mocks/data/dashboard/menu/index.ts b/frontend/src/mocks/data/dashboard/menu/index.ts new file mode 100644 index 000000000..e174c7a52 --- /dev/null +++ b/frontend/src/mocks/data/dashboard/menu/index.ts @@ -0,0 +1,2 @@ +export { dashboardMenuIngredientRankItems } from './dashboardMenuIngredientRankItems'; +export { dashboardMenuSalesRankItems } from './dashboardMenuSalesRank'; diff --git a/frontend/src/mocks/data/sales/paymentMethod.ts b/frontend/src/mocks/data/sales/paymentMethod.ts index 1c30629a7..d2cb213a2 100644 --- a/frontend/src/mocks/data/sales/paymentMethod.ts +++ b/frontend/src/mocks/data/sales/paymentMethod.ts @@ -15,7 +15,7 @@ export const PAYMENT_METHOD_DATA: SalesSource[] = [ changeRate: 6.7, }, { - salesSourceType: SALES_SOURCE.PAYMENT_METHOD.MOBILE, + salesSourceType: SALES_SOURCE.PAYMENT_METHOD.EASY_PAY, revenue: 7531000, count: 75, changeRate: -5.2, diff --git a/frontend/src/mocks/data/sales/saleType.ts b/frontend/src/mocks/data/sales/saleType.ts index 66c12c4fe..4ed65f512 100644 --- a/frontend/src/mocks/data/sales/saleType.ts +++ b/frontend/src/mocks/data/sales/saleType.ts @@ -9,7 +9,7 @@ export const SALE_TYPE_DATA: SalesSource[] = [ changeRate: -4.4, }, { - salesSourceType: SALES_SOURCE.SALE_TYPE.TAKEOUT, + salesSourceType: SALES_SOURCE.SALE_TYPE.TAKE_OUT, revenue: 0, count: 0, changeRate: -6.7, diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index 28925648d..0c9205b31 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -1,4 +1,5 @@ import { storeRegisterHandler } from './onboarding/store-register'; +import { analysisHandler } from './analysis'; import { authHandler } from './auth'; import { aiIngredientRecommendHandler, ingredientHandler } from './ingredient'; import { settingHandler } from './setting'; @@ -9,4 +10,5 @@ export const handlers = [ ...authHandler, ...settingHandler, ...ingredientHandler, + ...analysisHandler, ]; diff --git a/frontend/src/mocks/onboarding/store-register/store-register-handler.ts b/frontend/src/mocks/onboarding/store-register/store-register-handler.ts index f536f4021..da5d0c6c1 100644 --- a/frontend/src/mocks/onboarding/store-register/store-register-handler.ts +++ b/frontend/src/mocks/onboarding/store-register/store-register-handler.ts @@ -24,6 +24,7 @@ const postHandler = [ ); }), mswHttp.post('/api/stores', () => { + return passthrough(); mockDb.hasStore = true; return HttpResponse.json( { diff --git a/frontend/src/services/analysis/get.ts b/frontend/src/services/analysis/get.ts new file mode 100644 index 000000000..a4283b442 --- /dev/null +++ b/frontend/src/services/analysis/get.ts @@ -0,0 +1,21 @@ +import type { GetAnalysisDetailQuery } from '@/types/analysis'; + +import { authorizedApi } from '../shared'; + +export const getAnalysisDetail = async (query: GetAnalysisDetailQuery) => { + const queryParams = new URLSearchParams(); + Object.entries(query).forEach(([key, value]) => { + if (value === undefined) { + return; + } + queryParams.set(key, String(value)); + }); + + const queryString = queryParams.toString(); + + const path = `/api/analysis/detail?${queryString}`; + + const { data } = await authorizedApi.get(path); + + return data; +}; diff --git a/frontend/src/services/analysis/index.ts b/frontend/src/services/analysis/index.ts new file mode 100644 index 000000000..d390bb856 --- /dev/null +++ b/frontend/src/services/analysis/index.ts @@ -0,0 +1 @@ +export { getAnalysisDetail } from './get'; diff --git a/frontend/src/services/dashboard/delete.ts b/frontend/src/services/dashboard/delete.ts index c7845ce46..11f144379 100644 --- a/frontend/src/services/dashboard/delete.ts +++ b/frontend/src/services/dashboard/delete.ts @@ -1,4 +1,7 @@ -import type { DeleteDashboardParam } from '@/types/dashboard'; +import type { + DeleteDashboardParam, + DeleteDashboardSseSubscriptionRequestDto, +} from '@/types/dashboard'; import { authorizedApi } from '../shared'; @@ -14,3 +17,11 @@ export const deleteDashboard = async (param: DeleteDashboardParam) => { return data; }; + +export const deleteDashboardSseSubscription = async ( + body: DeleteDashboardSseSubscriptionRequestDto, +) => { + await authorizedApi.delete('/api/sse/subscriptions', { + body: JSON.stringify(body), + }); +}; diff --git a/frontend/src/services/dashboard/index.ts b/frontend/src/services/dashboard/index.ts index 04cee248e..90e0e9916 100644 --- a/frontend/src/services/dashboard/index.ts +++ b/frontend/src/services/dashboard/index.ts @@ -1,6 +1,6 @@ export { getDashboardList, getDashboardCardList } from './get'; export { patchDashboardName } from './patch'; -export { postDashboard } from './post'; -export { deleteDashboard } from './delete'; +export { postDashboard, postDashboardSseSubscription } from './post'; +export { deleteDashboard, deleteDashboardSseSubscription } from './delete'; export { putDashboardCardList } from './put'; export { dashboardOptions } from './options'; diff --git a/frontend/src/services/dashboard/keys.ts b/frontend/src/services/dashboard/keys.ts index fef6d4248..e2e87e23e 100644 --- a/frontend/src/services/dashboard/keys.ts +++ b/frontend/src/services/dashboard/keys.ts @@ -1,3 +1,5 @@ +import type { GetAnalysisDetailQuery } from '@/types/analysis'; + export const dashboardKeys = { all: ['dashboard'] as const, list: () => [...dashboardKeys.all, 'list'] as const, @@ -5,4 +7,14 @@ export const dashboardKeys = { [...dashboardKeys.all, 'detail', dashboardId] as const, cards: (dashboardId: number | string) => [...dashboardKeys.name(dashboardId), 'cards'] as const, + cardDetail: (dashboardId: number | string, query: GetAnalysisDetailQuery) => + [ + ...dashboardKeys.cards(dashboardId), + query.analysisCardCode, + { + customPeriod: query.customPeriod, + from: query.from ?? null, + to: query.to ?? null, + }, + ] as const, }; diff --git a/frontend/src/services/dashboard/options.ts b/frontend/src/services/dashboard/options.ts index 25ed84db8..0c7718c25 100644 --- a/frontend/src/services/dashboard/options.ts +++ b/frontend/src/services/dashboard/options.ts @@ -1,5 +1,9 @@ import { queryOptions } from '@tanstack/react-query'; +import type { GetAnalysisDetailQuery } from '@/types/analysis'; + +import { getAnalysisDetail } from '../analysis'; + import { getDashboardCardList, getDashboardList } from './get'; import { dashboardKeys } from './keys'; @@ -15,4 +19,9 @@ export const dashboardOptions = { queryFn: () => getDashboardCardList({ dashboardId }), staleTime: 24 * 60 * 60 * 1000, // 24시간 }), + cardDetail: (dashboardId: number, query: GetAnalysisDetailQuery) => + queryOptions({ + queryKey: dashboardKeys.cardDetail(dashboardId, query), + queryFn: () => getAnalysisDetail(query), + }), }; diff --git a/frontend/src/services/dashboard/post.ts b/frontend/src/services/dashboard/post.ts index a0381c83b..a8203fe78 100644 --- a/frontend/src/services/dashboard/post.ts +++ b/frontend/src/services/dashboard/post.ts @@ -1,6 +1,7 @@ import type { PostDashboardRequestDto, PostDashboardResponseDto, + PostDashboardSseSubscriptionRequestDto, } from '@/types/dashboard'; import { authorizedApi } from '../shared'; @@ -17,3 +18,11 @@ export const postDashboard = async (body: PostDashboardRequestDto) => { ); return data; }; + +export const postDashboardSseSubscription = async ( + body: PostDashboardSseSubscriptionRequestDto, +) => { + await authorizedApi.post('/api/sse/subscriptions', { + body: JSON.stringify(body), + }); +}; diff --git a/frontend/src/services/shared/sseClient.ts b/frontend/src/services/shared/sseClient.ts index 05ada47e9..886b8e08b 100644 --- a/frontend/src/services/shared/sseClient.ts +++ b/frontend/src/services/shared/sseClient.ts @@ -149,7 +149,9 @@ export const sseClient = ( const dispose = () => { document.removeEventListener('visibilitychange', onVisibilitychange); window.clearTimeout(retryTimer); - currentRequestAbortController.abort(); + if (!currentRequestAbortController.signal.aborted) { + currentRequestAbortController.abort(); + } }; customSignal?.addEventListener('abort', () => { @@ -170,7 +172,8 @@ export const sseClient = ( * SSE 연결 생성 */ async function create() { - currentRequestAbortController = new AbortController(); + const currentController = new AbortController(); + currentRequestAbortController = currentController; try { const apiPath = `${API_BASE_URL}${url}`; diff --git a/frontend/src/services/sse/delete.ts b/frontend/src/services/sse/delete.ts new file mode 100644 index 000000000..ddcc8e8d5 --- /dev/null +++ b/frontend/src/services/sse/delete.ts @@ -0,0 +1,5 @@ +import { authorizedApi } from '../shared'; + +export const deleteSseConnection = async () => { + await authorizedApi.delete('/api/sse/connection'); +}; diff --git a/frontend/src/services/sse/index.ts b/frontend/src/services/sse/index.ts new file mode 100644 index 000000000..ff29d905b --- /dev/null +++ b/frontend/src/services/sse/index.ts @@ -0,0 +1 @@ +export { deleteSseConnection } from './delete'; diff --git a/frontend/src/types/analysis/index.ts b/frontend/src/types/analysis/index.ts new file mode 100644 index 000000000..d4cdbd878 --- /dev/null +++ b/frontend/src/types/analysis/index.ts @@ -0,0 +1 @@ +export type { GetAnalysisDetailQuery } from './query'; diff --git a/frontend/src/types/analysis/query/getAnalysisDetail.ts b/frontend/src/types/analysis/query/getAnalysisDetail.ts new file mode 100644 index 000000000..b2975ca2b --- /dev/null +++ b/frontend/src/types/analysis/query/getAnalysisDetail.ts @@ -0,0 +1,8 @@ +import type { MetricCardCode } from '@/constants/dashboard'; + +export interface GetAnalysisDetailQuery { + analysisCardCode: MetricCardCode; + customPeriod: boolean; + from?: string; + to?: string; +} diff --git a/frontend/src/types/analysis/query/index.ts b/frontend/src/types/analysis/query/index.ts new file mode 100644 index 000000000..b8a11e8b5 --- /dev/null +++ b/frontend/src/types/analysis/query/index.ts @@ -0,0 +1 @@ +export type { GetAnalysisDetailQuery } from './getAnalysisDetail'; diff --git a/frontend/src/types/dashboard/dto/deleteDashboardSseSubscriptionDto.ts b/frontend/src/types/dashboard/dto/deleteDashboardSseSubscriptionDto.ts new file mode 100644 index 000000000..91d7630a1 --- /dev/null +++ b/frontend/src/types/dashboard/dto/deleteDashboardSseSubscriptionDto.ts @@ -0,0 +1,5 @@ +import type { MetricCardCode } from '@/constants/dashboard'; + +export interface DeleteDashboardSseSubscriptionRequestDto { + topics: MetricCardCode[]; +} diff --git a/frontend/src/types/dashboard/dto/index.ts b/frontend/src/types/dashboard/dto/index.ts index 07bd9cffa..e4d832b88 100644 --- a/frontend/src/types/dashboard/dto/index.ts +++ b/frontend/src/types/dashboard/dto/index.ts @@ -9,3 +9,5 @@ export type { PutDashboardCardListRequestDto, PutDashboardCardListResponseDto, } from './putDashboardCardListDto'; +export type { PostDashboardSseSubscriptionRequestDto } from './postDashboardSseSubscriptionDto'; +export type { DeleteDashboardSseSubscriptionRequestDto } from './deleteDashboardSseSubscriptionDto'; diff --git a/frontend/src/types/dashboard/dto/postDashboardSseSubscriptionDto.ts b/frontend/src/types/dashboard/dto/postDashboardSseSubscriptionDto.ts new file mode 100644 index 000000000..637e5572a --- /dev/null +++ b/frontend/src/types/dashboard/dto/postDashboardSseSubscriptionDto.ts @@ -0,0 +1,5 @@ +import type { MetricCardCode } from '@/constants/dashboard'; + +export interface PostDashboardSseSubscriptionRequestDto { + topics: MetricCardCode[]; +} diff --git a/frontend/src/types/dashboard/index.ts b/frontend/src/types/dashboard/index.ts index 92b2767c9..8b3b27b52 100644 --- a/frontend/src/types/dashboard/index.ts +++ b/frontend/src/types/dashboard/index.ts @@ -8,6 +8,8 @@ export type { GetDashboardCardListResponseDto, PutDashboardCardListRequestDto, PutDashboardCardListResponseDto, + PostDashboardSseSubscriptionRequestDto, + DeleteDashboardSseSubscriptionRequestDto, } from './dto'; export type { PatchDashboardNameParam, diff --git a/frontend/src/types/menu/dto/getDetailTimeSlotMenuOrderCountDto.ts b/frontend/src/types/menu/dto/getDetailTimeSlotMenuOrderCountDto.ts new file mode 100644 index 000000000..c5c46a33d --- /dev/null +++ b/frontend/src/types/menu/dto/getDetailTimeSlotMenuOrderCountDto.ts @@ -0,0 +1,14 @@ +interface TimeSlotMenuOrderCountItem { + menuName: string; + orderCount: number; +} + +interface TimeSlotMenuGroupItem { + timeSlot2H: number; + totalOrderCount: number; + menus: TimeSlotMenuOrderCountItem[]; +} + +export interface GetDetailTimeSlotMenuOrderCountResponseDto { + items: TimeSlotMenuGroupItem[]; +} diff --git a/frontend/src/types/menu/dto/getPopularMenuCombinationDto.ts b/frontend/src/types/menu/dto/getPopularMenuCombinationDto.ts index fe305066b..0cb3db29d 100644 --- a/frontend/src/types/menu/dto/getPopularMenuCombinationDto.ts +++ b/frontend/src/types/menu/dto/getPopularMenuCombinationDto.ts @@ -2,7 +2,7 @@ export interface PairedMenu { menuName: string; count: number; } -// PoplularMenuCombination 예시 +// PopularMenuCombination 예시 // { // "baseMenuName": "불고기 버거", // "pairedMenus": [ @@ -10,11 +10,11 @@ export interface PairedMenu { // { "menuName": "콜라", "count": 70 } // ] // } -export interface PoplularMenuCombination { - baseMenuName: string; - pairedMenus: PairedMenu[]; +export interface PopularMenuCombination { + baseMenuName: string | null; + pairedMenus: PairedMenu[] | null; } export interface GetPopularMenuCombinationResponseDto { - items: PoplularMenuCombination[]; + items: PopularMenuCombination[]; } diff --git a/frontend/src/types/menu/dto/index.ts b/frontend/src/types/menu/dto/index.ts index b67587a98..db682ccd0 100644 --- a/frontend/src/types/menu/dto/index.ts +++ b/frontend/src/types/menu/dto/index.ts @@ -8,7 +8,8 @@ export type { } from './getIngredientUsageRankingDto'; export type { GetPopularMenuCombinationResponseDto, - PoplularMenuCombination, + PopularMenuCombination, } from './getPopularMenuCombinationDto'; export type { GetDashboardTimeSlotMenuOrderCountResponseDto } from './getDashboardTimeSlotMenuOrderCountDto'; export type { GetDashboardPopularMenuCombinationResponseDto } from './getDashboardPopularMenuCombinationDto'; +export type { GetDetailTimeSlotMenuOrderCountResponseDto } from './getDetailTimeSlotMenuOrderCountDto'; diff --git a/frontend/src/types/menu/index.ts b/frontend/src/types/menu/index.ts index 2caf694bb..8358d79d4 100644 --- a/frontend/src/types/menu/index.ts +++ b/frontend/src/types/menu/index.ts @@ -4,10 +4,13 @@ export type { MenuSalesRank } from './menuSalesRank'; export type { CategoriesRevenue } from './categoriesRevenue'; export type { DashboardRankItem } from './dashboard-menu-ranking'; export type { - GetDashboardPopularMenuCombinationResponseDto, - GetDashboardTimeSlotMenuOrderCountResponseDto, GetMenuSalesRankingResponseDto, - GetIngredientUsageRankingResponseDto, MenuSales, + GetIngredientUsageRankingResponseDto, IngredientUsage, + GetPopularMenuCombinationResponseDto, + PopularMenuCombination, + GetDashboardTimeSlotMenuOrderCountResponseDto, + GetDashboardPopularMenuCombinationResponseDto, + GetDetailTimeSlotMenuOrderCountResponseDto, } from './dto'; diff --git a/frontend/src/types/sales/dashboard-sales-income/salesIncomeStructureInsight.ts b/frontend/src/types/sales/dashboard-sales-income/salesIncomeStructureInsight.ts index 3612df04c..7d2c434db 100644 --- a/frontend/src/types/sales/dashboard-sales-income/salesIncomeStructureInsight.ts +++ b/frontend/src/types/sales/dashboard-sales-income/salesIncomeStructureInsight.ts @@ -1,17 +1,12 @@ -export type SalesIncomeStructureTopType = - | '홀' - | '포장' - | '배달' - | 'POS' - | '키오스크' - | '배달앱' - | '카드' - | '현금' - | '간편결제' - | '기타'; +import type { SALES_SOURCE } from '@/constants/sales'; -export interface SalesIncomeStructureInsight { - topType: SalesIncomeStructureTopType; +export type SalesIncomeStructureTopType = + keyof (typeof SALES_SOURCE)[T]; + +export interface SalesIncomeStructureInsight< + T extends keyof typeof SALES_SOURCE, +> { + topType: SalesIncomeStructureTopType; topShare: number; deltaShare: number; showDeltaText?: boolean; diff --git a/frontend/src/types/sales/dto/getIncomeStructureByOrderChannelDto.ts b/frontend/src/types/sales/dto/getIncomeStructureByOrderChannelDto.ts new file mode 100644 index 000000000..020e04666 --- /dev/null +++ b/frontend/src/types/sales/dto/getIncomeStructureByOrderChannelDto.ts @@ -0,0 +1,18 @@ +import type { SALES_SOURCE } from '@/constants/sales'; + +import type { SalesIncomeStructureInsight } from '../dashboard-sales-income'; + +interface OrderChannelItem { + orderChannel: keyof typeof SALES_SOURCE.ORDER_METHOD; + salesAmount: number; + orderCount: number; + share: number; + deltaShare: number; +} + +export interface GetIncomeStructureByOrderChannelResponseDto { + insight: SalesIncomeStructureInsight< + Extract + >; + items: OrderChannelItem[]; +} diff --git a/frontend/src/types/sales/dto/getIncomeStructureByOrderMethodDto.ts b/frontend/src/types/sales/dto/getIncomeStructureByOrderMethodDto.ts deleted file mode 100644 index 2266ac0c1..000000000 --- a/frontend/src/types/sales/dto/getIncomeStructureByOrderMethodDto.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { - SalesIncomeStructureInsight, - SalesIncomeStructureTopType, -} from '../dashboard-sales-income'; - -interface OrderMethodItem { - orderChannel: Extract< - SalesIncomeStructureTopType, - 'POS' | '키오스크' | '배달앱' | '기타' - >; - salesAmount: number; - orderCount: number; - share: number; - deltaShare: number; -} - -export interface GetIncomeStructureByOrderMethodResponseDto { - insight: SalesIncomeStructureInsight; - items: OrderMethodItem[]; -} diff --git a/frontend/src/types/sales/dto/getIncomeStructureByPaymentMethodDto.ts b/frontend/src/types/sales/dto/getIncomeStructureByPaymentMethodDto.ts index 31de5c183..219e8662f 100644 --- a/frontend/src/types/sales/dto/getIncomeStructureByPaymentMethodDto.ts +++ b/frontend/src/types/sales/dto/getIncomeStructureByPaymentMethodDto.ts @@ -1,13 +1,9 @@ -import type { - SalesIncomeStructureInsight, - SalesIncomeStructureTopType, -} from '../dashboard-sales-income'; +import { SALES_SOURCE } from '@/constants/sales'; + +import type { SalesIncomeStructureInsight } from '../dashboard-sales-income'; interface PaymentMethodItem { - payMethod: Extract< - SalesIncomeStructureTopType, - '카드' | '현금' | '간편결제' | '기타' - >; + payMethod: keyof typeof SALES_SOURCE.PAYMENT_METHOD; salesAmount: number; orderCount: number; share: number; @@ -15,6 +11,8 @@ interface PaymentMethodItem { } export interface GetIncomeStructureByPaymentMethodResponseDto { - insight: SalesIncomeStructureInsight; + insight: SalesIncomeStructureInsight< + Extract + >; items: PaymentMethodItem[]; } diff --git a/frontend/src/types/sales/dto/getIncomeStructureBySalesTypeDto.ts b/frontend/src/types/sales/dto/getIncomeStructureBySalesTypeDto.ts index 6aa77de18..98ed13bd3 100644 --- a/frontend/src/types/sales/dto/getIncomeStructureBySalesTypeDto.ts +++ b/frontend/src/types/sales/dto/getIncomeStructureBySalesTypeDto.ts @@ -1,10 +1,9 @@ -import type { - SalesIncomeStructureInsight, - SalesIncomeStructureTopType, -} from '../dashboard-sales-income'; +import type { SALES_SOURCE } from '@/constants/sales'; + +import type { SalesIncomeStructureInsight } from '../dashboard-sales-income'; interface SalesTypeItem { - salesType: Extract; + salesType: keyof typeof SALES_SOURCE.SALE_TYPE; salesAmount: number; orderCount: number; share: number; @@ -12,6 +11,8 @@ interface SalesTypeItem { } export interface GetIncomeStructureBySalesTypeResponseDto { - insight: SalesIncomeStructureInsight; + insight: SalesIncomeStructureInsight< + Extract + >; items: SalesTypeItem[]; } diff --git a/frontend/src/types/sales/dto/getRealTimeSalesDto.ts b/frontend/src/types/sales/dto/getRealSalesDto.ts similarity index 68% rename from frontend/src/types/sales/dto/getRealTimeSalesDto.ts rename to frontend/src/types/sales/dto/getRealSalesDto.ts index 411d5a155..ad63a0683 100644 --- a/frontend/src/types/sales/dto/getRealTimeSalesDto.ts +++ b/frontend/src/types/sales/dto/getRealSalesDto.ts @@ -1,4 +1,4 @@ -export interface GetRealTimeSalesResponseDto { +export interface GetRealSalesResponseDto { netAmount: number; differenceAmount: number; changeRate: number; diff --git a/frontend/src/types/sales/dto/index.ts b/frontend/src/types/sales/dto/index.ts index 2e7cec780..b200bd9b8 100644 --- a/frontend/src/types/sales/dto/index.ts +++ b/frontend/src/types/sales/dto/index.ts @@ -1,8 +1,8 @@ -export type { GetRealTimeSalesResponseDto } from './getRealTimeSalesDto'; +export type { GetRealSalesResponseDto } from './getRealSalesDto'; export type { GetOrderCountResponseDto } from './getOrderCountDto'; export type { GetAveragePriceResponseDto } from './getAveragePriceDto'; export type { GetIncomeStructureBySalesTypeResponseDto } from './getIncomeStructureBySalesTypeDto'; -export type { GetIncomeStructureByOrderMethodResponseDto } from './getIncomeStructureByOrderMethodDto'; +export type { GetIncomeStructureByOrderChannelResponseDto } from './getIncomeStructureByOrderChannelDto'; export type { GetIncomeStructureByPaymentMethodResponseDto } from './getIncomeStructureByPaymentMethodDto'; export type { GetDetailPeakTimeResponseDto, diff --git a/frontend/src/types/sales/index.ts b/frontend/src/types/sales/index.ts index de423677a..b690a821d 100644 --- a/frontend/src/types/sales/index.ts +++ b/frontend/src/types/sales/index.ts @@ -1,10 +1,10 @@ export type { SalesSource } from './salesSource'; export type { - GetRealTimeSalesResponseDto, + GetRealSalesResponseDto, GetOrderCountResponseDto, GetAveragePriceResponseDto, GetIncomeStructureBySalesTypeResponseDto, - GetIncomeStructureByOrderMethodResponseDto, + GetIncomeStructureByOrderChannelResponseDto, GetIncomeStructureByPaymentMethodResponseDto, GetDetailPeakTimeResponseDto, GetDashboardPeakTimeResponseDto, @@ -18,3 +18,4 @@ export type { SalesByDaySummary, SalesByDayItem, } from './dashboard-sales-pattern'; +export type { SalesTrendItem } from './dashboard-sales-trend'; diff --git a/frontend/src/utils/sales/dashboard-current-sales/getSalesCurrentComparisonMessage.ts b/frontend/src/utils/sales/dashboard-current-sales/getSalesCurrentComparisonMessage.ts index 5579c97e6..f8212c6f8 100644 --- a/frontend/src/utils/sales/dashboard-current-sales/getSalesCurrentComparisonMessage.ts +++ b/frontend/src/utils/sales/dashboard-current-sales/getSalesCurrentComparisonMessage.ts @@ -21,7 +21,7 @@ export const getSalesCurrentComparisonMessage = ({ comparisonAmount, unit, }: GetSalesCurrentComparisonMessageArgs): MessageToken[] => { - const weekday = DAY_OF_WEEK_LIST[new Date().getDay()]; + const weekday = DAY_OF_WEEK_LIST[(new Date().getDay() + 6) % 7]; const PERIOD_TEXT = { [PERIOD_PRESETS.dayWeekMonth.today]: `지난주 ${weekday}요일`, diff --git a/frontend/src/utils/sales/dashboard-sales-income/getSalesIncomeStructureComparisonMessage.ts b/frontend/src/utils/sales/dashboard-sales-income/getSalesIncomeStructureComparisonMessage.ts index d8406a238..52d2436da 100644 --- a/frontend/src/utils/sales/dashboard-sales-income/getSalesIncomeStructureComparisonMessage.ts +++ b/frontend/src/utils/sales/dashboard-sales-income/getSalesIncomeStructureComparisonMessage.ts @@ -1,3 +1,4 @@ +import type { SALES_SOURCE, SalesSourceType } from '@/constants/sales'; import { PERIOD_PRESETS } from '@/constants/shared'; import type { SalesIncomeStructureInsight } from '@/types/sales/dashboard-sales-income/salesIncomeStructureInsight'; import { formatNumber, type ValueOf } from '@/utils/shared'; @@ -7,15 +8,16 @@ import { createMessageToken, type MessageToken } from '../dashboard'; const DELTA_SHARE_THRESHOLD = 3; interface GetSalesIncomeStructureComparisonMessageArgs extends Omit< - SalesIncomeStructureInsight, - 'showDeltaText' | 'showFocusText' + SalesIncomeStructureInsight, + 'showDeltaText' | 'showFocusText' | 'topType' > { periodType: ValueOf; + topTypeLabel: SalesSourceType; } export const getSalesIncomeStructureComparisonMessage = ({ periodType, - topType, + topTypeLabel, topShare, deltaShare, }: GetSalesIncomeStructureComparisonMessageArgs): MessageToken[] => { @@ -26,7 +28,7 @@ export const getSalesIncomeStructureComparisonMessage = ({ return [ createMessageToken('최근 7일 대비 '), createMessageToken( - `${topType} 비중이 ${deltaShare >= 0 ? '+' : ''}${formatNumber(deltaShare)}%p `, + `${topTypeLabel} 비중이 ${deltaShare >= 0 ? '+' : ''}${formatNumber(deltaShare)}%p `, true, deltaShare >= 0 ? 'primary' : 'negative', ), @@ -37,13 +39,13 @@ export const getSalesIncomeStructureComparisonMessage = ({ if (topShare >= 60) { return [ createMessageToken('매출이 '), - createMessageToken(`${topType}(${formatNumber(topShare)}%)`, true), + createMessageToken(`${topTypeLabel}(${formatNumber(topShare)}%)`, true), createMessageToken('에 집중돼 있어요.'), ]; } return [ - createMessageToken(`${topType}(${formatNumber(topShare)}%) `, true), + createMessageToken(`${topTypeLabel}(${formatNumber(topShare)}%) `, true), createMessageToken('매출이 가장 많아요.'), ]; }; diff --git a/frontend/src/utils/sales/dashboard-sales-pattern/getSalesPatternByDayMessage.ts b/frontend/src/utils/sales/dashboard-sales-pattern/getSalesPatternByDayMessage.ts index 7c0f65e85..aad09a939 100644 --- a/frontend/src/utils/sales/dashboard-sales-pattern/getSalesPatternByDayMessage.ts +++ b/frontend/src/utils/sales/dashboard-sales-pattern/getSalesPatternByDayMessage.ts @@ -14,6 +14,14 @@ export const getSalesPatternByDayMessage = ({ topDay, isSignificant, }: GetSalesPatternByDayMessageArgs): MessageToken[] => { + if (!topDay) { + return [ + createMessageToken('데이터가 더 쌓이면 '), + createMessageToken('가장 매출이 높은 요일', true, 'primary'), + createMessageToken('을 알려드릴게요.'), + ]; + } + if (isSignificant) { return [ createMessageToken(`${topDay}요일`, true, 'primary'), diff --git a/frontend/src/utils/sales/dashboard-sales-pattern/getSalesPatternPeakTimeMessage.ts b/frontend/src/utils/sales/dashboard-sales-pattern/getSalesPatternPeakTimeMessage.ts index 0b5f9ea0b..f573730e0 100644 --- a/frontend/src/utils/sales/dashboard-sales-pattern/getSalesPatternPeakTimeMessage.ts +++ b/frontend/src/utils/sales/dashboard-sales-pattern/getSalesPatternPeakTimeMessage.ts @@ -13,6 +13,14 @@ export const getSalesPatternPeakTimeMessage = ({ comparisonPeak, beforeComparisonPeak, }: GetSalesPatternPeakTimeMessageArgs): MessageToken[] => { + if (!todayPeak || !comparisonPeak) { + return [ + createMessageToken('데이터가 더 쌓이면 '), + createMessageToken('가장 바쁜 시간', true, 'primary'), + createMessageToken('을 알려드릴게요.'), + ]; + } + if (beforeComparisonPeak) { return [ createMessageToken('오늘은 '), diff --git a/frontend/src/utils/sales/sales-overview/getPeriodComparisonMessage.ts b/frontend/src/utils/sales/sales-overview/getPeriodComparisonMessage.ts index 6cd4add20..271f3a9da 100644 --- a/frontend/src/utils/sales/sales-overview/getPeriodComparisonMessage.ts +++ b/frontend/src/utils/sales/sales-overview/getPeriodComparisonMessage.ts @@ -4,7 +4,7 @@ import type { ValueOf } from '@/utils/shared'; export const getPeriodComparisonMessage = ( type: ValueOf, ) => { - const weekday = DAY_OF_WEEK_LIST[new Date().getDay()]; + const weekday = DAY_OF_WEEK_LIST[(new Date().getDay() + 6) % 7]; switch (type) { case PERIOD_PRESETS.dayWeekMonth.today: diff --git a/frontend/src/utils/shared/bar-chart/getBarHeight.ts b/frontend/src/utils/shared/bar-chart/getBarHeight.ts index 3c21e45e5..4fdc1d839 100644 --- a/frontend/src/utils/shared/bar-chart/getBarHeight.ts +++ b/frontend/src/utils/shared/bar-chart/getBarHeight.ts @@ -1,6 +1,7 @@ import { BAR_CHART } from '@/constants/shared'; -// 바 전체 높이 계산 (바의 상단 y좌표 부터 x축 또는 svg 하단까지의 거리) +// 막대 높이 계산: +// `y`(막대 상단)부터 x축 또는 SVG 하단까지의 세로 거리 export const getBarHeight = ({ y, hasXAxis, @@ -12,8 +13,10 @@ export const getBarHeight = ({ }) => { const { XAXIS_Y_OFFSET, XAXIS_STROKE_WIDTH } = BAR_CHART; if (hasXAxis) { - // x축이 있을 때는 x축의 y위치 만큼을 빼고 축 높이의 0.5배 만큼 더 빼줘야 함 - return viewBoxHeight - XAXIS_Y_OFFSET - y - XAXIS_STROKE_WIDTH / 2; // x축이 있을 때 바 높이는 y좌표 ~ x 축까지 거리 + // x축이 있으면 바닥 기준은 SVG 하단이 아니라 x축 위치. + // x축 선 두께가 중심 기준으로 그려지므로, 실제 높이 보정을 위해 stroke의 절반을 추가로 차감. + return viewBoxHeight - XAXIS_Y_OFFSET - y - XAXIS_STROKE_WIDTH / 2; } - return viewBoxHeight - y; // x축이 없을 떄 바 높이는 y좌표 ~ svg 최하단 까지 거리 + // x축이 없으면 SVG 하단(viewBoxHeight)을 바닥 기준으로 사용. + return viewBoxHeight - y; }; diff --git a/frontend/src/utils/shared/index.ts b/frontend/src/utils/shared/index.ts index 61da95c5c..d8ff7d10b 100644 --- a/frontend/src/utils/shared/index.ts +++ b/frontend/src/utils/shared/index.ts @@ -39,8 +39,9 @@ export { } from './doughnut-chart'; export { createPeriodTypeProvider } from './period-select'; -export { getNextHour } from './getNextHour'; export { assertNever } from './assertNever'; +export type { Nullable } from './nullable'; +export { getNextHour } from './getNextHour'; export { getCoordinate } from './getCoordinate'; export { getBarSegmentInfoList, diff --git a/frontend/src/utils/shared/nullable.ts b/frontend/src/utils/shared/nullable.ts new file mode 100644 index 000000000..4b1276e32 --- /dev/null +++ b/frontend/src/utils/shared/nullable.ts @@ -0,0 +1,3 @@ +export type Nullable = { + [P in keyof T]?: T[P]; +};