Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/components/common/PageHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import { memo } from "react";
import { twMerge } from "tailwind-merge";

interface IPageHeaderProps {
Expand All @@ -8,7 +9,7 @@ interface IPageHeaderProps {
className?: string;
}

export default function PageHeader({
const PageHeader = memo(function PageHeader({
title,
description,
actions,
Expand All @@ -25,4 +26,6 @@ export default function PageHeader({
{actions && <div>{actions}</div>}
</header>
);
}
});

export default PageHeader;
9 changes: 6 additions & 3 deletions src/components/common/card/Card.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { HTMLAttributes, ReactNode } from "react";
import { memo } from "react";
import { twMerge } from "tailwind-merge";

export interface ICardProps extends HTMLAttributes<HTMLDivElement> {
Expand All @@ -7,7 +8,7 @@ export interface ICardProps extends HTMLAttributes<HTMLDivElement> {
RightElement?: ReactNode;
}

export default function Card({
const Card = memo(function Card({
title,
description,
RightElement,
Expand All @@ -20,7 +21,7 @@ export default function Card({
return (
<div
className={twMerge(
"bg-white/80 backdrop-blur-sm rounded-[24px] shadow-[0_4px_20px_rgba(0,0,0,0.03)] p-7 border border-white/40 transition-all duration-300 hover:shadow-[0_12px_45px_rgba(0,0,0,0.06)] relative",
"bg-white/80 backdrop-blur-sm rounded-[24px] shadow-[0_4px_20px_rgba(0,0,0,0.03)] p-7 border border-white/40 transition-shadow duration-300 hover:shadow-[0_12px_45px_rgba(0,0,0,0.06)] relative",
className,
)}
{...rest}
Expand All @@ -43,4 +44,6 @@ export default function Card({
{children}
</div>
);
}
});

export default Card;
16 changes: 11 additions & 5 deletions src/components/common/card/StatCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { HTMLAttributes } from "react";
import { memo } from "react";
import { twMerge } from "tailwind-merge";

import TrendDownIcon from "@/assets/icon/chevron/trend-down.svg?react";
Expand All @@ -20,7 +21,10 @@ const trendClasses: Record<ITrend["direction"], string> = {
down: "bg-status-blue/[0.08] text-status-blue font-bold",
};

export function TrendBadge({ direction, value }: ITrend) {
export const TrendBadge = memo(function TrendBadge({
direction,
value,
}: ITrend) {
return (
<span
aria-label={`${value} ${direction === "up" ? "상승" : "하락"}`}
Expand All @@ -37,9 +41,9 @@ export function TrendBadge({ direction, value }: ITrend) {
{value}
</span>
);
}
});

export default function StatCard({
const StatCard = memo(function StatCard({
title,
value,
trend,
Expand All @@ -49,7 +53,7 @@ export default function StatCard({
return (
<div
className={twMerge(
"bg-white/80 backdrop-blur-sm rounded-[24px] shadow-[0_4px_20px_rgba(0,0,0,0.03)] p-7 flex flex-col gap-4 border border-white/40 transition-all duration-300 hover:shadow-[0_12px_40px_rgba(33,130,246,0.08)]",
"bg-white/80 backdrop-blur-sm rounded-[24px] shadow-[0_4px_20px_rgba(0,0,0,0.03)] p-7 flex flex-col gap-4 border border-white/40 transition-shadow duration-300 hover:shadow-[0_12px_40px_rgba(33,130,246,0.08)]",
className,
)}
{...rest}
Expand All @@ -61,4 +65,6 @@ export default function StatCard({
{trend && <TrendBadge {...trend} />}
</div>
);
}
});

export default StatCard;
10 changes: 8 additions & 2 deletions src/components/common/chart/ChartLegend.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { memo } from "react";
import { twMerge } from "tailwind-merge";

export interface IChartLegendItem {
Expand All @@ -10,7 +11,10 @@ export interface IChartLegendProps {
className?: string;
}

export default function ChartLegend({ items, className }: IChartLegendProps) {
const ChartLegend = memo(function ChartLegend({
items,
className,
}: IChartLegendProps) {
return (
<div className={twMerge("flex items-center gap-4", className)}>
{items.map((item) => (
Expand All @@ -25,4 +29,6 @@ export default function ChartLegend({ items, className }: IChartLegendProps) {
))}
</div>
);
}
});

export default ChartLegend;
88 changes: 76 additions & 12 deletions src/components/common/drawer/Drawer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import type { ReactNode, TouchEvent } from "react";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { twMerge } from "tailwind-merge";

Expand All @@ -19,6 +19,9 @@ export interface IDrawerProps {
disableScroll?: boolean;
}

const TABLET_MQ = "(max-width: 1024px)";
const SWIPE_CLOSE_THRESHOLD = 80;

export default function Drawer({
isOpen,
onClose,
Expand All @@ -30,6 +33,9 @@ export default function Drawer({
disableScroll = false,
}: IDrawerProps) {
const [isMounted, setIsMounted] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const touchStartY = useRef(0);
const isDragging = useRef(false);

useEffect(() => {
setIsMounted(true);
Expand All @@ -46,13 +52,50 @@ export default function Drawer({
};
}, [isOpen]);

useEffect(() => {
const panel = panelRef.current;
if (!panel) return;
panel.style.transform = "";
panel.style.transition = "";
}, [isOpen]);

const handleTouchStart = (e: TouchEvent) => {
if (!window.matchMedia(TABLET_MQ).matches) return;
touchStartY.current = e.touches[0].clientY;
isDragging.current = true;
if (panelRef.current) {
panelRef.current.style.transition = "none";
}
};

const handleTouchMove = (e: TouchEvent) => {
if (!isDragging.current || !panelRef.current) return;
const delta = e.touches[0].clientY - touchStartY.current;
if (delta < 0) return;
panelRef.current.style.transform = `translateY(${delta}px)`;
};

const handleTouchEnd = (e: TouchEvent) => {
if (!isDragging.current || !panelRef.current) return;
isDragging.current = false;
const delta = e.changedTouches[0].clientY - touchStartY.current;
panelRef.current.style.transition = "";

if (delta > SWIPE_CLOSE_THRESHOLD) {
panelRef.current.style.transform = "";
onClose();
} else {
panelRef.current.style.transform = "";
}
};

if (!isMounted) return null;

return createPortal(
<div
data-drawer-portal
className={twMerge(
"fixed inset-0 z-50 flex justify-end transition-opacity duration-300",
"fixed inset-0 z-50 flex justify-end tablet:items-end transition-opacity duration-300",
isOpen
? "opacity-100 pointer-events-auto"
: "opacity-0 pointer-events-none",
Expand All @@ -65,12 +108,31 @@ export default function Drawer({
aria-hidden="true"
/>
<div
ref={panelRef}
className={twMerge(
// [데스크톱] 우측 슬라이드
"relative w-full rounded-l-4xl overflow-hidden max-w-md bg-white shadow-2xl h-full flex flex-col transform transition-transform duration-300 ease-in-out",
isOpen ? "translate-x-0" : "translate-x-full",
// [태블릿] 하단 bottom sheet
"tablet:rounded-t-4xl tablet:h-auto tablet:max-h-[85vh] tablet:max-w-none tablet:border-t tablet:border-bg-disabled",
isOpen
? "translate-x-0 tablet:translate-y-0"
: "translate-x-full tablet:translate-x-0 tablet:translate-y-full",
className,
)}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* 태블릿 드래그 핸들 */}
<div
className="hidden tablet:flex justify-center pt-3 pb-1 shrink-0 cursor-grab active:cursor-grabbing"
role="slider"
aria-label="아래로 드래그하여 닫기"
aria-orientation="vertical"
tabIndex={0}
>
<div className="w-20 h-1 rounded-full bg-bg-disabled" />
</div>
Comment thread
Seojegyeong marked this conversation as resolved.
{!hideHeader && (
<div
data-print-hide
Expand All @@ -96,7 +158,7 @@ export default function Drawer({
)}
<button
onClick={onClose}
className="h-10 w-10 cursor-pointer rounded-component-sm hover:bg-gray-100 transition-colors flex items-center justify-center outline-none"
className="tablet:hidden h-10 w-10 cursor-pointer rounded-component-sm hover:bg-gray-100 transition-colors flex items-center justify-center outline-none"
aria-label="닫기"
>
<CloseIcon className="text-text-disabled" />
Expand All @@ -107,18 +169,20 @@ export default function Drawer({
<>
<button
onClick={onClose}
className="h-10 w-10 cursor-pointer rounded-component-sm hover:bg-gray-100 transition-colors flex items-center justify-center outline-none"
className="tablet:hidden h-10 w-10 cursor-pointer rounded-component-sm hover:bg-gray-100 transition-colors flex items-center justify-center outline-none"
aria-label="닫기"
>
<CloseIcon className="text-text-disabled" />
</button>
{dropdownItems && (
<DropdownMenu
trigger={<MoreIcon className="text-text-disabled" />}
aria-label="더보기"
className="h-10 w-10 cursor-pointer rounded-component-sm hover:bg-gray-100 transition-colors flex items-center justify-center"
items={dropdownItems}
/>
<div className="ml-auto">
<DropdownMenu
trigger={<MoreIcon className="text-text-disabled" />}
aria-label="더보기"
className="h-10 w-10 cursor-pointer rounded-component-sm hover:bg-gray-100 transition-colors flex items-center justify-center"
items={dropdownItems}
/>
</div>
)}
</>
)}
Expand Down
32 changes: 20 additions & 12 deletions src/components/dashboard/charts/BudgetGaugeChart.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { memo, useMemo } from "react";
import { twMerge } from "tailwind-merge";

import { useIsMounted } from "@/hooks/common/useIsMounted";
Expand Down Expand Up @@ -37,7 +38,7 @@ const statusPointClasses: Record<TBudgetStatus, string> = {
위험: "bg-status-red",
};

export default function BudgetGaugeChart({
const BudgetGaugeChart = memo(function BudgetGaugeChart({
totalBudget,
spent,
warningThreshold,
Expand All @@ -50,16 +51,21 @@ export default function BudgetGaugeChart({
const isOverBudget = spent > totalBudget;
const remaining = isOverBudget ? spent - totalBudget : totalBudget - spent;

const now = new Date();
const periodElapsedDays = now.getDate();
const periodTotalDays = new Date(
now.getFullYear(),
now.getMonth() + 1,
0,
).getDate();
const periodElapsedRate = Math.round(
(periodElapsedDays / periodTotalDays) * 100,
);
const { periodElapsedDays, periodTotalDays, periodElapsedRate } =
useMemo(() => {
const now = new Date();
const elapsed = now.getDate();
const total = new Date(
now.getFullYear(),
now.getMonth() + 1,
0,
).getDate();
return {
periodElapsedDays: elapsed,
periodTotalDays: total,
periodElapsedRate: Math.round((elapsed / total) * 100),
};
}, []);

const status = getBudgetStatus(percentage, warningThreshold, dangerThreshold);

Expand Down Expand Up @@ -183,4 +189,6 @@ export default function BudgetGaugeChart({
</div>
</div>
);
}
});

export default BudgetGaugeChart;
Loading
Loading