From 78b96e61e606206aad526d2ba1e074a779b88ccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EC=9B=90?= Date: Sun, 8 Feb 2026 21:48:33 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=B9=B4=EB=93=9C=20=ED=88=AC?= =?UTF-8?q?=EB=91=90=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/todo-card/TodoCard.stories.tsx | 121 ++++++++++++++++++ src/components/todo-card/TodoCard.tsx | 57 +++++++++ .../todo-card/constants/todoCardConstants.ts | 5 + src/components/todo-card/index.ts | 2 + .../todo-card/styles/TodoCard.module.css | 73 +++++++++++ src/components/todo-card/types/types.ts | 23 ++++ 6 files changed, 281 insertions(+) create mode 100644 src/components/todo-card/TodoCard.stories.tsx create mode 100644 src/components/todo-card/TodoCard.tsx create mode 100644 src/components/todo-card/constants/todoCardConstants.ts create mode 100644 src/components/todo-card/index.ts create mode 100644 src/components/todo-card/styles/TodoCard.module.css create mode 100644 src/components/todo-card/types/types.ts diff --git a/src/components/todo-card/TodoCard.stories.tsx b/src/components/todo-card/TodoCard.stories.tsx new file mode 100644 index 0000000..aeb26a6 --- /dev/null +++ b/src/components/todo-card/TodoCard.stories.tsx @@ -0,0 +1,121 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; + +import { useState } from 'react'; +import { fn } from 'storybook/test'; + +import TodoCard from './TodoCard'; +import type { TodoItem } from './types/types'; + +const meta = { + title: 'Components/TodoCard', + component: TodoCard, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + args: { + onKebabClick: fn(), + }, + argTypes: { + expanded: { control: 'boolean' }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const sampleItems: TodoItem[] = [ + { id: '1', text: '법인 설립 안내 드리기', checked: false }, + { id: '2', text: '법인 설립 혹은 변경 등기 비용 안내 드리기', checked: false }, + { id: '3', text: '입력해주신 정보를 바탕으로 등기신청서 제...', checked: true }, +]; + +const completedItems: TodoItem[] = [ + { id: '1', text: '법인 설립 안내 드리기', checked: true }, + { id: '2', text: '법인 설립 혹은 변경 등기 비용 안내 드리기', checked: true }, + { id: '3', text: '입력해주신 정보를 바탕으로 등기신청서 제...', checked: true }, +]; + +const ControlledTodoCard = ({ + items: initialItems, + ...args +}: { + items: TodoItem[]; + title: string; +}) => { + const [items, setItems] = useState(initialItems); + + const handleCheckedChange = (id: string, checked: boolean) => { + setItems((prev) => prev.map((item) => (item.id === id ? { ...item, checked } : item))); + }; + + return ; +}; + +export const Default: Story = { + render: (args) => , + args: { + title: '법인 설립', + }, +}; + +export const AllCompleted: Story = { + render: (args) => , + args: { + title: '법인 설립', + }, +}; + +export const Collapsed: Story = { + render: (args) => , + args: { + title: '법인 설립', + expanded: false, + }, +}; + +export const CollapsedCompleted: Story = { + render: (args) => , + args: { + title: '법인 설립', + expanded: false, + }, +}; + +export const Overview: Story = { + render: () => { + const [items1, setItems1] = useState(sampleItems); + const [items2, setItems2] = useState(completedItems); + + return ( +
+ + setItems1((prev) => prev.map((item) => (item.id === id ? { ...item, checked } : item))) + } + /> + + setItems2((prev) => prev.map((item) => (item.id === id ? { ...item, checked } : item))) + } + /> + + +
+ ); + }, + parameters: { + controls: { disable: true }, + }, +}; diff --git a/src/components/todo-card/TodoCard.tsx b/src/components/todo-card/TodoCard.tsx new file mode 100644 index 0000000..0065b2c --- /dev/null +++ b/src/components/todo-card/TodoCard.tsx @@ -0,0 +1,57 @@ +'use client'; + +import clsx from 'clsx'; +import Image from 'next/image'; + +import Badge from '@/components/badge/Badge'; +import CheckBox from '@/components/checkbox/CheckBox'; + +import styles from './styles/TodoCard.module.css'; +import { TODO_CARD_ICONS } from './constants/todoCardConstants'; +import type { TodoCardProps } from './types/types'; + +/** + * 할일 카드 컴포넌트. + * 제목, 진행 상태 뱃지, 체크박스 리스트를 포함합니다. + * expanded가 false이면 헤더만 표시됩니다. + */ +export default function TodoCard({ + title, + items, + onItemCheckedChange, + onKebabClick, + expanded = true, + className, +}: TodoCardProps) { + const checkedCount = items.filter((item) => item.checked).length; + const totalCount = items.length; + const badgeLabel = `${checkedCount}/${totalCount}`; + const badgeState = checkedCount === totalCount && totalCount > 0 ? 'done' : 'ongoing'; + + return ( +
+
+ {title} + + +
+ + {expanded && items.length > 0 && ( +
+ {items.map((item) => ( +
+ {item.text}} + onCheckedChange={(checked) => onItemCheckedChange?.(item.id, checked)} + /> +
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/todo-card/constants/todoCardConstants.ts b/src/components/todo-card/constants/todoCardConstants.ts new file mode 100644 index 0000000..de08e04 --- /dev/null +++ b/src/components/todo-card/constants/todoCardConstants.ts @@ -0,0 +1,5 @@ +import kebabSmall from '@/assets/icons/kebab/kebabSmall.svg'; + +export const TODO_CARD_ICONS = { + kebab: kebabSmall, +} as const; diff --git a/src/components/todo-card/index.ts b/src/components/todo-card/index.ts new file mode 100644 index 0000000..a5756de --- /dev/null +++ b/src/components/todo-card/index.ts @@ -0,0 +1,2 @@ +export { default as TodoCard } from './TodoCard'; +export type { TodoCardProps, TodoItem } from './types/types'; diff --git a/src/components/todo-card/styles/TodoCard.module.css b/src/components/todo-card/styles/TodoCard.module.css new file mode 100644 index 0000000..933cafc --- /dev/null +++ b/src/components/todo-card/styles/TodoCard.module.css @@ -0,0 +1,73 @@ +.card { + display: flex; + flex-direction: column; + gap: 16px; + padding: 12px; + border-radius: 12px; + background-color: var(--color-background-inverse); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.06), + 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.header { + display: flex; + align-items: center; + gap: 8px; +} + +.title { + flex: 1; + min-width: 0; + font-size: 14px; + font-weight: 600; + line-height: 1.4; + color: var(--color-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.kebab { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + background: none; + border-radius: 4px; + cursor: pointer; +} + +.kebab:hover { + background-color: var(--color-background-tertiary); +} + +.body { + display: flex; + flex-direction: column; + gap: 6px; +} + +.item { + display: flex; + align-items: center; +} + +.itemLabel { + font-size: 12px; + font-weight: 400; + line-height: 1.4; + color: var(--color-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.itemChecked .itemLabel { + color: var(--color-text-disabled); + text-decoration: line-through; +} diff --git a/src/components/todo-card/types/types.ts b/src/components/todo-card/types/types.ts new file mode 100644 index 0000000..45e1fa7 --- /dev/null +++ b/src/components/todo-card/types/types.ts @@ -0,0 +1,23 @@ +export type TodoItem = { + /** 항목 고유 ID */ + id: string; + /** 항목 텍스트 */ + text: string; + /** 체크 여부 */ + checked: boolean; +}; + +export type TodoCardProps = { + /** 카드 제목 */ + title: string; + /** 체크박스 항목 목록 */ + items: TodoItem[]; + /** 항목 체크 상태 변경 시 호출되는 콜백 */ + onItemCheckedChange?: (id: string, checked: boolean) => void; + /** 케밥(⋮) 버튼 클릭 시 호출되는 콜백 */ + onKebabClick?: () => void; + /** 체크리스트 펼침 여부 (기본값: true) */ + expanded?: boolean; + /** 추가 CSS 클래스 */ + className?: string; +}; From 3f8230b4ae37b06d14dbc1bfb783db215b60fca3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EC=9B=90?= Date: Sun, 8 Feb 2026 22:07:13 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=A0=9C=EB=AF=B8=EB=82=98=EC=9D=B4?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/todo-card/TodoCard.stories.tsx | 61 +++++++++---------- src/components/todo-card/TodoCard.tsx | 6 +- 2 files changed, 33 insertions(+), 34 deletions(-) diff --git a/src/components/todo-card/TodoCard.stories.tsx b/src/components/todo-card/TodoCard.stories.tsx index aeb26a6..f051a8e 100644 --- a/src/components/todo-card/TodoCard.stories.tsx +++ b/src/components/todo-card/TodoCard.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import type { ComponentProps } from 'react'; import { useState } from 'react'; import { fn } from 'storybook/test'; @@ -6,6 +7,18 @@ import { fn } from 'storybook/test'; import TodoCard from './TodoCard'; import type { TodoItem } from './types/types'; +const sampleItems: TodoItem[] = [ + { id: '1', text: '법인 설립 안내 드리기', checked: false }, + { id: '2', text: '법인 설립 혹은 변경 등기 비용 안내 드리기', checked: false }, + { id: '3', text: '입력해주신 정보를 바탕으로 등기신청서 제...', checked: true }, +]; + +const completedItems: TodoItem[] = [ + { id: '1', text: '법인 설립 안내 드리기', checked: true }, + { id: '2', text: '법인 설립 혹은 변경 등기 비용 안내 드리기', checked: true }, + { id: '3', text: '입력해주신 정보를 바탕으로 등기신청서 제...', checked: true }, +]; + const meta = { title: 'Components/TodoCard', component: TodoCard, @@ -14,6 +27,8 @@ const meta = { }, tags: ['autodocs'], args: { + title: '법인 설립', + items: sampleItems, onKebabClick: fn(), }, argTypes: { @@ -31,26 +46,8 @@ const meta = { export default meta; type Story = StoryObj; -const sampleItems: TodoItem[] = [ - { id: '1', text: '법인 설립 안내 드리기', checked: false }, - { id: '2', text: '법인 설립 혹은 변경 등기 비용 안내 드리기', checked: false }, - { id: '3', text: '입력해주신 정보를 바탕으로 등기신청서 제...', checked: true }, -]; - -const completedItems: TodoItem[] = [ - { id: '1', text: '법인 설립 안내 드리기', checked: true }, - { id: '2', text: '법인 설립 혹은 변경 등기 비용 안내 드리기', checked: true }, - { id: '3', text: '입력해주신 정보를 바탕으로 등기신청서 제...', checked: true }, -]; - -const ControlledTodoCard = ({ - items: initialItems, - ...args -}: { - items: TodoItem[]; - title: string; -}) => { - const [items, setItems] = useState(initialItems); +const ControlledTodoCard = (args: ComponentProps) => { + const [items, setItems] = useState(args.items); const handleCheckedChange = (id: string, checked: boolean) => { setItems((prev) => prev.map((item) => (item.id === id ? { ...item, checked } : item))); @@ -60,43 +57,40 @@ const ControlledTodoCard = ({ }; export const Default: Story = { - render: (args) => , - args: { - title: '법인 설립', - }, + render: (args) => , }; export const AllCompleted: Story = { - render: (args) => , + render: (args) => , args: { - title: '법인 설립', + items: completedItems, }, }; export const Collapsed: Story = { - render: (args) => , + render: (args) => , args: { - title: '법인 설립', expanded: false, }, }; export const CollapsedCompleted: Story = { - render: (args) => , + render: (args) => , args: { - title: '법인 설립', + items: completedItems, expanded: false, }, }; export const Overview: Story = { - render: () => { + render: (args) => { const [items1, setItems1] = useState(sampleItems); const [items2, setItems2] = useState(completedItems); return (
@@ -104,14 +98,15 @@ export const Overview: Story = { } /> setItems2((prev) => prev.map((item) => (item.id === id ? { ...item, checked } : item))) } /> - - + +
); }, diff --git a/src/components/todo-card/TodoCard.tsx b/src/components/todo-card/TodoCard.tsx index 0065b2c..f52657f 100644 --- a/src/components/todo-card/TodoCard.tsx +++ b/src/components/todo-card/TodoCard.tsx @@ -46,7 +46,11 @@ export default function TodoCard({ checked={item.checked} size="small" label={{item.text}} - onCheckedChange={(checked) => onItemCheckedChange?.(item.id, checked)} + onCheckedChange={ + onItemCheckedChange + ? (checked) => onItemCheckedChange(item.id, checked) + : undefined + } /> ))}