diff --git a/web-app/app/shared/components/button/Button.tsx b/web-app/app/shared/components/button/Button.tsx
new file mode 100644
index 0000000..d9eda45
--- /dev/null
+++ b/web-app/app/shared/components/button/Button.tsx
@@ -0,0 +1,147 @@
+import type { ButtonProps as AriaButtonProps } from 'react-aria-components';
+import { Button as AriaButton, composeRenderProps } from 'react-aria-components';
+
+import { tv } from 'tailwind-variants';
+import { LoadingSpinnerIcon } from '../icons/LoadingSpinner';
+
+export interface ButtonProps extends AriaButtonProps {
+ color?: 'primary' | 'light' | 'green' | 'lightGreen' | 'black' | 'gray' | 'orange' | 'navy' | 'lightNavy';
+ size?: 'small' | 'medium' | 'large';
+ stopPropagation?: boolean;
+}
+
+const button = tv({
+ base: 'relative inline-flex items-center justify-center gap-2 border cursor-pointer box-border data-focus-visible:ring-2 ring-offset-2',
+ variants: {
+ size: {
+ small: 'h-6 px-3 text-xs font-medium',
+ medium: 'h-9 px-3 text-xs font-medium',
+ large: 'min-w-30 h-13 px-5 text-sm font-semibold',
+ },
+ color: {
+ primary: 'text-white bg-primary border-primary disabled:bg-kc-gray-be disabled:border-kc-gray-be ring-primary',
+ light:
+ 'text-primary border-primary bg-primary-tertiary02 disabled:text-kc-gray-99 disabled:bg-kc-gray-eb disabled:border-kc-gray-be ring-primary',
+ green:
+ 'text-white bg-kc-green-main border-kc-green-main disabled:bg-kc-gray-be disabled:border-kc-gray-be ring-kc-green-main',
+ lightGreen:
+ 'text-kc-green-main border-kc-green-main bg-kc-green-tertiary02 disabled:text-kc-gray-99 disabled:bg-kc-gray-eb disabled:border-kc-gray-be ring-kc-green-main',
+ black:
+ 'text-white bg-kc-black-34 border-kc-black-34 disabled:bg-kc-gray-be disabled:border-kc-gray-be ring-kc-black-34',
+ gray: 'text-kc-black-34 bg-kc-gray-eb border-kc-black-34 disabled:text-kc-gray-99 disabled:bg-kc-gray-eb disabled:border-kc-gray-be ring-kc-black-34',
+ orange:
+ 'text-kc-orange-main bg-kc-orange-tertiary01 border-kc-orange-main disabled:text-kc-gray-99 disabled:bg-kc-gray-eb disabled:border-kc-gray-be ring-kc-orange-main',
+ navy: 'text-white bg-kc-navy-main border-kc-navy-main disabled:bg-kc-gray-be disabled:border-kc-gray-be ring-kc-navy-main',
+ lightNavy:
+ 'text-kc-navy-main border-kc-navy-main bg-kc-navy-tertiary02 disabled:text-kc-gray-99 disabled:bg-kc-gray-eb disabled:border-kc-gray-be ring-kc-navy-main',
+ },
+ isDisabled: {
+ true: 'cursor-not-allowed',
+ },
+ isPending: {
+ true: 'text-transparent cursor-progress',
+ },
+ },
+ compoundVariants: [
+ {
+ color: 'primary',
+ isDisabled: false,
+ isPending: false,
+ className:
+ 'hover:border-primary-tertiary hover:bg-primary-tertiary active:bg-primary-secondary active:border-primary-secondary',
+ },
+ {
+ color: 'light',
+ isDisabled: false,
+ isPending: false,
+ className:
+ 'hover:bg-primary-tertiary01 active:text-primary-secondary active:bg-primary-tertiary01 active:border-primary-secondary',
+ },
+ {
+ color: 'green',
+ isDisabled: false,
+ isPending: false,
+ className:
+ 'hover:bg-kc-green-tertiary hover:border-kc-green-tertiary active:bg-kc-green-secondary active:border-kc-green-secondary',
+ },
+ {
+ color: 'lightGreen',
+ isDisabled: false,
+ isPending: false,
+ className:
+ 'hover:bg-kc-green-tertiary01 active:text-kc-green-secondary active:bg-kc-green-tertiary01 active:border-kc-green-secondary',
+ },
+ {
+ color: 'black',
+ isDisabled: false,
+ isPending: false,
+ className: 'hover:bg-kc-black-47 hover:border-kc-black-47 active:bg-kc-black-22 active:border-kc-black-22',
+ },
+ {
+ color: 'gray',
+ isDisabled: false,
+ isPending: false,
+ className:
+ 'hover:bg-kc-gray-da hover:border-kc-black-47 active:text-kc-black-22 active:bg-kc-gray-da active:border-kc-black-22',
+ },
+ {
+ color: 'orange',
+ isDisabled: false,
+ isPending: false,
+ className:
+ 'hover:bg-kc-orange-tertiary02 active:text-kc-orange-secondary active:bg-kc-orange-tertiary02 active:border-kc-orange-secondary',
+ },
+ {
+ color: 'navy',
+ isDisabled: false,
+ isPending: false,
+ className:
+ 'hover:border-kc-navy-tertiary hover:bg-kc-navy-tertiary active:bg-kc-navy-secondary active:border-kc-navy-secondary',
+ },
+ {
+ color: 'lightNavy',
+ isDisabled: false,
+ isPending: false,
+ className:
+ 'hover:bg-kc-navy-tertiary01 active:text-kc-navy-secondary active:bg-kc-navy-tertiary01 active:border-kc-navy-secondary',
+ },
+ ],
+});
+
+export const Button = (props: ButtonProps) => {
+ const {
+ className,
+ onClick,
+ color = 'primary',
+ size = 'medium',
+ children,
+ stopPropagation = false,
+ ...restProps
+ } = props;
+
+ return (
+ {
+ return button({ ...renderProps, color, size, className });
+ })}
+ onClick={(e) => {
+ if (stopPropagation) {
+ e.stopPropagation();
+ }
+ onClick?.(e);
+ }}
+ {...restProps}
+ >
+ {composeRenderProps(children, (children, { isPending }) => (
+ <>
+ {children}
+ {isPending && (
+
+
+
+ )}
+ >
+ ))}
+
+ );
+};
diff --git a/web-app/app/shared/components/calendar/Calendar.tsx b/web-app/app/shared/components/calendar/Calendar.tsx
new file mode 100644
index 0000000..d63e673
--- /dev/null
+++ b/web-app/app/shared/components/calendar/Calendar.tsx
@@ -0,0 +1,182 @@
+import {
+ Button as AriaButton,
+ Calendar as AriaCalendar,
+ CalendarGridHeader as AriaCalendarGridHeader,
+ CalendarCell,
+ CalendarGrid,
+ CalendarGridBody,
+ CalendarHeaderCell,
+ Heading,
+} from 'react-aria-components';
+
+import { getLocalTimeZone, today } from '@internationalized/date';
+import { tv } from 'tailwind-variants';
+
+import { ChevronLeftIcon, ChevronRightIcon } from '@/components/icons';
+
+import { calendarDateToDate, dateToCalendarDate } from './utils';
+
+const cellStyles = tv({
+ base: 'flex h-[30px] w-[30px] cursor-default items-center justify-center rounded-full text-xs outline-none',
+ variants: {
+ isSelected: {
+ true: 'bg-primary font-bold text-white',
+ false: '',
+ },
+ isDisabled: {
+ true: 'cursor-default text-kc-gray-be',
+ },
+ isUnavailable: {
+ true: 'cursor-default text-kc-gray-be line-through',
+ },
+ isOutsideMonth: {
+ true: 'text-kc-gray-be',
+ },
+ isSunday: {
+ true: 'text-[#e48686]',
+ },
+ isSaturday: {
+ true: 'text-[#7b8cc8]',
+ },
+ isToday: {
+ true: 'font-bold text-primary',
+ },
+ isHovered: {
+ true: 'bg-primary-tertiary01',
+ },
+ isFocusVisible: {
+ true: 'ring-2 ring-offset-2 ring-primary',
+ },
+ },
+ compoundVariants: [
+ {
+ isSelected: true,
+ isSunday: true,
+ className: 'text-white',
+ },
+ {
+ isSelected: true,
+ isSaturday: true,
+ className: 'text-white',
+ },
+ {
+ isSelected: true,
+ isToday: true,
+ className: 'text-white',
+ },
+ {
+ isSelected: true,
+ isHovered: true,
+ className: 'bg-primary',
+ },
+ ],
+});
+
+export interface CalendarProps {
+ value?: Date | null;
+ defaultValue?: Date | null;
+ onChange?: (date: Date | null) => void;
+ maxValue?: Date | null;
+ minValue?: Date | null;
+ isDisabled?: boolean;
+ className?: string;
+}
+
+export function Calendar({ value, defaultValue, onChange, maxValue, minValue, isDisabled, className }: CalendarProps) {
+ const todayDate = today(getLocalTimeZone());
+
+ return (
+ onChange?.(calendarDateToDate(val))}
+ maxValue={maxValue !== undefined ? dateToCalendarDate(maxValue) : undefined}
+ minValue={minValue !== undefined ? dateToCalendarDate(minValue) : undefined}
+ isDisabled={isDisabled}
+ firstDayOfWeek="sun"
+ className={className}
+ >
+
+
+
+
+ {(date) => {
+ const dayOfWeek = date.toDate('UTC').getDay();
+ const isSunday = dayOfWeek === 0;
+ const isSaturday = dayOfWeek === 6;
+ const isToday = date.compare(todayDate) === 0;
+
+ return (
+
+ cellStyles({
+ isSelected,
+ isDisabled: isCellDisabled,
+ isUnavailable,
+ isOutsideMonth,
+ isSunday: !isSelected && !isDisabled && !isOutsideMonth ? isSunday : false,
+ isSaturday: !isSelected && !isDisabled && !isOutsideMonth ? isSaturday : false,
+ isToday: !isSelected ? isToday : false,
+ isHovered: !isSelected ? isHovered : false,
+ isFocusVisible,
+ })
+ }
+ />
+ );
+ }}
+
+
+
+ );
+}
+
+const navButton = tv({
+ base: 'flex h-7 w-7 items-center justify-center rounded-sm border-none bg-transparent outline-none',
+ variants: {
+ isDisabled: {
+ true: 'cursor-default text-kc-gray-be',
+ false: 'cursor-pointer text-kc-black-34 hover:bg-kc-gray-eb',
+ },
+ isFocusVisible: {
+ true: 'ring-2 ring-offset-2 ring-primary',
+ },
+ },
+ defaultVariants: {
+ isDisabled: false,
+ },
+});
+
+export function CalendarHeader() {
+ return (
+
+ navButton(renderProps)}>
+
+
+
+ navButton(renderProps)}>
+
+
+
+ );
+}
+
+export function CalendarGridHeader() {
+ return (
+
+ {(day) => (
+
+ {day}
+
+ )}
+
+ );
+}
diff --git a/web-app/app/shared/components/calendar/RangeCalendar.tsx b/web-app/app/shared/components/calendar/RangeCalendar.tsx
new file mode 100644
index 0000000..987cab1
--- /dev/null
+++ b/web-app/app/shared/components/calendar/RangeCalendar.tsx
@@ -0,0 +1,195 @@
+import {
+ RangeCalendar as AriaRangeCalendar,
+ CalendarCell,
+ CalendarGrid,
+ CalendarGridBody,
+ type DateValue,
+} from 'react-aria-components';
+
+import { getLocalTimeZone, today } from '@internationalized/date';
+import { tv } from 'tailwind-variants';
+
+import { CalendarGridHeader, CalendarHeader } from './Calendar';
+import { calendarDateToDate, dateToCalendarDate } from './utils';
+
+const rangeCell = tv({
+ base: 'flex h-full w-full items-center justify-center rounded-full text-xs',
+ variants: {
+ selectionState: {
+ none: '',
+ middle: '',
+ cap: 'bg-primary font-bold text-white',
+ },
+ isDisabled: {
+ true: 'text-kc-gray-be',
+ },
+ isOutsideMonth: {
+ true: 'text-kc-gray-be',
+ },
+ isSunday: {
+ true: 'text-[#e48686]',
+ },
+ isSaturday: {
+ true: 'text-[#7b8cc8]',
+ },
+ isToday: {
+ true: 'font-bold text-primary',
+ },
+ isHovered: {
+ true: '',
+ },
+ isFocusVisible: {
+ true: 'ring-2 ring-offset-2 ring-primary',
+ },
+ },
+ compoundVariants: [
+ {
+ selectionState: 'cap',
+ isSunday: true,
+ className: 'text-white',
+ },
+ {
+ selectionState: 'cap',
+ isSaturday: true,
+ className: 'text-white',
+ },
+ {
+ selectionState: 'cap',
+ isToday: true,
+ className: 'text-white',
+ },
+ {
+ selectionState: 'none',
+ isHovered: true,
+ className: 'bg-primary-tertiary01 rounded-full',
+ },
+ {
+ selectionState: 'middle',
+ isHovered: true,
+ className: 'bg-primary-tertiary01',
+ },
+ ],
+});
+
+export interface RangeCalendarProps {
+ value?: { start: Date; end: Date } | null;
+ defaultValue?: { start: Date; end: Date } | null;
+ onChange?: (value: { start: Date; end: Date } | null) => void;
+ maxValue?: Date | null;
+ minValue?: Date | null;
+ isDisabled?: boolean;
+ className?: string;
+}
+
+export function RangeCalendar({
+ value,
+ defaultValue,
+ onChange,
+ maxValue,
+ minValue,
+ isDisabled,
+ className,
+}: RangeCalendarProps) {
+ const todayDate = today(getLocalTimeZone());
+
+ const ariaValue =
+ value !== undefined
+ ? value
+ ? { start: dateToCalendarDate(value.start)!, end: dateToCalendarDate(value.end)! }
+ : null
+ : undefined;
+
+ const ariaDefaultValue =
+ defaultValue !== undefined
+ ? defaultValue
+ ? { start: dateToCalendarDate(defaultValue.start)!, end: dateToCalendarDate(defaultValue.end)! }
+ : null
+ : undefined;
+
+ return (
+
+ aria-label="날짜 범위 선택"
+ value={ariaValue}
+ defaultValue={ariaDefaultValue}
+ onChange={(range) => {
+ if (!range) {
+ onChange?.(null);
+ } else {
+ onChange?.({
+ start: calendarDateToDate(range.start)!,
+ end: calendarDateToDate(range.end)!,
+ });
+ }
+ }}
+ maxValue={maxValue !== undefined ? dateToCalendarDate(maxValue) : undefined}
+ minValue={minValue !== undefined ? dateToCalendarDate(minValue) : undefined}
+ isDisabled={isDisabled}
+ firstDayOfWeek="sun"
+ className={className}
+ >
+
+
+
+
+ {(date) => {
+ const dayOfWeek = date.toDate('UTC').getDay();
+ const isSunday = dayOfWeek === 0;
+ const isSaturday = dayOfWeek === 6;
+ const isToday = date.compare(todayDate) === 0;
+
+ return (
+ {
+ const classes = ['group h-[30px] w-[30px] cursor-default text-sm outline-none'];
+ if (isSelected) {
+ classes.push('bg-primary/10');
+ if (isSelectionStart) classes.push('rounded-s-full');
+ if (isSelectionEnd) classes.push('rounded-e-full');
+ }
+ return classes.join(' ');
+ }}
+ >
+ {({
+ formattedDate,
+ isSelected,
+ isSelectionStart,
+ isSelectionEnd,
+ isDisabled: isCellDisabled,
+ isOutsideMonth,
+ isHovered,
+ isFocusVisible,
+ }) => {
+ const selectionState =
+ isSelected && (isSelectionStart || isSelectionEnd)
+ ? ('cap' as const)
+ : isSelected
+ ? ('middle' as const)
+ : ('none' as const);
+ const showDayColor = selectionState !== 'cap' && !isCellDisabled && !isOutsideMonth;
+
+ return (
+
+ {formattedDate}
+
+ );
+ }}
+
+ );
+ }}
+
+
+
+ );
+}
diff --git a/web-app/app/shared/components/calendar/index.ts b/web-app/app/shared/components/calendar/index.ts
new file mode 100644
index 0000000..3113718
--- /dev/null
+++ b/web-app/app/shared/components/calendar/index.ts
@@ -0,0 +1,4 @@
+export { Calendar } from './Calendar';
+export type { CalendarProps } from './Calendar';
+export { RangeCalendar } from './RangeCalendar';
+export type { RangeCalendarProps } from './RangeCalendar';
diff --git a/web-app/app/shared/components/calendar/utils.ts b/web-app/app/shared/components/calendar/utils.ts
new file mode 100644
index 0000000..a944d82
--- /dev/null
+++ b/web-app/app/shared/components/calendar/utils.ts
@@ -0,0 +1,13 @@
+import { CalendarDate, type DateValue, fromDate, toCalendarDate } from '@internationalized/date';
+
+const TIMEZONE = 'Asia/Seoul';
+
+export function dateToCalendarDate(date: Date | null): CalendarDate | null {
+ if (!date) return null;
+ return toCalendarDate(fromDate(date, TIMEZONE));
+}
+
+export function calendarDateToDate(value: DateValue | null): Date | null {
+ if (!value) return null;
+ return value.toDate(TIMEZONE);
+}
diff --git a/web-app/app/shared/components/datePicker/DatePicker.tsx b/web-app/app/shared/components/datePicker/DatePicker.tsx
new file mode 100644
index 0000000..f3b1987
--- /dev/null
+++ b/web-app/app/shared/components/datePicker/DatePicker.tsx
@@ -0,0 +1,79 @@
+import { DatePicker as AriaDatePicker, Button, Dialog, Group, OverlayArrow, Popover } from 'react-aria-components';
+
+import dayjs from 'dayjs';
+import { tv } from 'tailwind-variants';
+
+import { Calendar } from '../calendar';
+import { calendarDateToDate, dateToCalendarDate } from '../calendar/utils';
+import { CalendarIcon } from '../icons';
+
+const trigger = tv({
+ base: 'flex h-9 w-[156px] cursor-pointer items-center border border-kc-gray-be bg-white text-left outline-none transition',
+ variants: {
+ isHovered: {
+ true: 'border-kc-black-34',
+ },
+ isFocused: {
+ true: 'border-kc-black-34',
+ },
+ isDisabled: {
+ true: 'cursor-default border-kc-gray-99 bg-kc-gray-eb',
+ },
+ isFocusVisible: {
+ true: 'ring-2 ring-offset-2 ring-primary',
+ },
+ },
+});
+
+export type DatePickerProps = {
+ value: Date | null;
+ onChange: (date: Date | null) => void;
+ maxValue?: Date | null;
+ minValue?: Date | null;
+ isDisabled?: boolean;
+ className?: string;
+ name?: string;
+};
+
+export function DatePicker({ value, onChange, maxValue, minValue, isDisabled, className, name }: DatePickerProps) {
+ return (
+ onChange(calendarDateToDate(val))}
+ maxValue={maxValue !== undefined ? dateToCalendarDate(maxValue) : undefined}
+ minValue={minValue !== undefined ? dateToCalendarDate(minValue) : undefined}
+ isDisabled={isDisabled}
+ granularity="day"
+ firstDayOfWeek="sun"
+ className={className}
+ name={name}
+ >
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web-app/app/shared/components/datePicker/DateRangePicker.tsx b/web-app/app/shared/components/datePicker/DateRangePicker.tsx
new file mode 100644
index 0000000..c88f176
--- /dev/null
+++ b/web-app/app/shared/components/datePicker/DateRangePicker.tsx
@@ -0,0 +1,113 @@
+import {
+ DateRangePicker as AriaDateRangePicker,
+ Button,
+ Dialog,
+ Group,
+ OverlayArrow,
+ Popover,
+} from 'react-aria-components';
+
+import dayjs from 'dayjs';
+import { tv } from 'tailwind-variants';
+
+import { CalendarIcon } from '@/components/icons';
+
+import { RangeCalendar } from '../calendar/RangeCalendar';
+import { calendarDateToDate, dateToCalendarDate } from '../calendar/utils';
+
+const trigger = tv({
+ base: 'flex h-9 w-[260px] cursor-pointer items-center border border-kc-gray-be bg-white text-left outline-none transition',
+ variants: {
+ isHovered: {
+ true: 'border-kc-black-34',
+ },
+ isFocused: {
+ true: 'border-kc-black-34',
+ },
+ isDisabled: {
+ true: 'cursor-default border-kc-gray-99 bg-kc-gray-eb',
+ },
+ isFocusVisible: {
+ true: 'ring-2 ring-offset-2 ring-primary',
+ },
+ },
+});
+
+export type DateRangePickerProps = {
+ value: { start: Date; end: Date } | null;
+ onChange: (value: { start: Date; end: Date } | null) => void;
+ maxValue?: Date | null;
+ minValue?: Date | null;
+ isDisabled?: boolean;
+ className?: string;
+ startName?: string;
+ endName?: string;
+};
+
+export function DateRangePicker({
+ value,
+ onChange,
+ maxValue,
+ minValue,
+ isDisabled,
+ className,
+ startName,
+ endName,
+}: DateRangePickerProps) {
+ const ariaValue = value ? { start: dateToCalendarDate(value.start)!, end: dateToCalendarDate(value.end)! } : null;
+
+ return (
+ {
+ if (!range) {
+ onChange(null);
+ } else {
+ onChange({
+ start: calendarDateToDate(range.start)!,
+ end: calendarDateToDate(range.end)!,
+ });
+ }
+ }}
+ maxValue={maxValue !== undefined ? dateToCalendarDate(maxValue) : undefined}
+ minValue={minValue !== undefined ? dateToCalendarDate(minValue) : undefined}
+ isDisabled={isDisabled}
+ granularity="day"
+ firstDayOfWeek="sun"
+ className={className}
+ startName={startName}
+ endName={endName}
+ >
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web-app/app/shared/components/datePicker/index.ts b/web-app/app/shared/components/datePicker/index.ts
new file mode 100644
index 0000000..419227f
--- /dev/null
+++ b/web-app/app/shared/components/datePicker/index.ts
@@ -0,0 +1,4 @@
+export { DatePicker } from './DatePicker';
+export type { DatePickerProps } from './DatePicker';
+export { DateRangePicker } from './DateRangePicker';
+export type { DateRangePickerProps } from './DateRangePicker';
diff --git a/web-app/app/shared/components/icons/Calendar.tsx b/web-app/app/shared/components/icons/Calendar.tsx
new file mode 100644
index 0000000..2d15f4d
--- /dev/null
+++ b/web-app/app/shared/components/icons/Calendar.tsx
@@ -0,0 +1,23 @@
+import { type SVGProps } from 'react';
+
+export function CalendarIcon(props: SVGProps) {
+ return (
+
+ );
+}
diff --git a/web-app/app/shared/components/icons/ChevronLeft.tsx b/web-app/app/shared/components/icons/ChevronLeft.tsx
new file mode 100644
index 0000000..eb93fad
--- /dev/null
+++ b/web-app/app/shared/components/icons/ChevronLeft.tsx
@@ -0,0 +1,20 @@
+import { SVGProps } from 'react';
+
+export function ChevronLeftIcon(props: SVGProps) {
+ return (
+
+ );
+}
diff --git a/web-app/app/shared/components/icons/ChevronRight.tsx b/web-app/app/shared/components/icons/ChevronRight.tsx
new file mode 100644
index 0000000..3166602
--- /dev/null
+++ b/web-app/app/shared/components/icons/ChevronRight.tsx
@@ -0,0 +1,20 @@
+import { SVGProps } from 'react';
+
+export function ChevronRightIcon(props: SVGProps) {
+ return (
+
+ );
+}
diff --git a/web-app/app/shared/components/icons/LoadingSpinner.tsx b/web-app/app/shared/components/icons/LoadingSpinner.tsx
new file mode 100644
index 0000000..b682e35
--- /dev/null
+++ b/web-app/app/shared/components/icons/LoadingSpinner.tsx
@@ -0,0 +1,20 @@
+import { type SVGProps } from 'react';
+
+export function LoadingSpinnerIcon(props: SVGProps) {
+ return (
+
+ );
+}
diff --git a/web-app/app/shared/components/icons/index.ts b/web-app/app/shared/components/icons/index.ts
new file mode 100644
index 0000000..7df7603
--- /dev/null
+++ b/web-app/app/shared/components/icons/index.ts
@@ -0,0 +1,5 @@
+export * from './Calendar';
+export * from './ChevronLeft';
+export * from './ChevronRight';
+export * from './LoadingSpinner';
+
diff --git a/web-app/app/shared/components/inputGroup/Input.tsx b/web-app/app/shared/components/inputGroup/Input.tsx
new file mode 100644
index 0000000..785b164
--- /dev/null
+++ b/web-app/app/shared/components/inputGroup/Input.tsx
@@ -0,0 +1,16 @@
+import type { Ref } from 'react';
+import type { InputProps as AriaInputProps } from 'react-aria-components';
+import { Input as AriaInput } from 'react-aria-components';
+
+import { twMerge } from 'tailwind-merge';
+
+import style from './style';
+
+export interface InputProps extends AriaInputProps {
+ className?: string;
+ ref?: Ref;
+}
+export default function Input(props: InputProps) {
+ const { className, ...restProps } = props;
+ return ;
+}
diff --git a/web-app/app/shared/components/inputGroup/InputGroup.tsx b/web-app/app/shared/components/inputGroup/InputGroup.tsx
new file mode 100644
index 0000000..04f7ac5
--- /dev/null
+++ b/web-app/app/shared/components/inputGroup/InputGroup.tsx
@@ -0,0 +1,51 @@
+import { type PropsWithChildren } from 'react';
+
+import { twMerge } from 'tailwind-merge';
+import { tv } from 'tailwind-variants';
+
+export interface InputGroupProps extends PropsWithChildren {
+ className?: string;
+ innerClassName?: string;
+ isReadOnly?: boolean;
+}
+const style = tv({
+ slots: {
+ container: 'relative flex flex-col justify-center items-start bg-white',
+ base: [
+ 'h-9',
+ 'flex',
+ 'items-center',
+ 'text-sm',
+ 'text-kc-black-34',
+ 'leading-[18px]',
+ 'border',
+ 'border-kc-gray-be',
+ 'has-data-focused:border-kc-black-34',
+ 'has-data-hovered:border-kc-black-34',
+ 'has-data-disabled:bg-kc-gray-eb',
+ 'has-data-disabled:text-kc-gray-99',
+ 'has-data-disabled:border-kc-gray-be',
+ 'has-data-invalid:border-kc-red',
+ 'has-data-invalid:has-data-focused:border-kc-red',
+ 'has-data-invalid:has-data-hovered:border-kc-red',
+ 'w-full',
+ 'has-[[type=number]]:leading-6',
+ ],
+ input: ['w-full', 'px-3', 'outline-0', '[&[type=number]]:pr-1.5'],
+ },
+ variants: {
+ isReadOnly: {
+ true: {
+ base: 'bg-kc-yellow-secondary has-data-hovered:border-kc-gray-be has-data-focused:border-kc-gray-be',
+ },
+ },
+ },
+});
+export const InputGroup = ({ className, innerClassName, isReadOnly = false, children }: InputGroupProps) => {
+ const styles = style({ isReadOnly });
+ return (
+
+ );
+};
diff --git a/web-app/app/shared/components/inputGroup/index.ts b/web-app/app/shared/components/inputGroup/index.ts
new file mode 100644
index 0000000..0e22a81
--- /dev/null
+++ b/web-app/app/shared/components/inputGroup/index.ts
@@ -0,0 +1,6 @@
+import InternalInput from './Input';
+import { InputGroup as InternalInputGroup } from './InputGroup';
+
+export const InputGroup = Object.assign(InternalInputGroup, {
+ Input: InternalInput,
+});
diff --git a/web-app/app/shared/components/pagination/Pagination.tsx b/web-app/app/shared/components/pagination/Pagination.tsx
new file mode 100644
index 0000000..294555f
--- /dev/null
+++ b/web-app/app/shared/components/pagination/Pagination.tsx
@@ -0,0 +1,121 @@
+import { useCallback, useMemo } from 'react';
+import { Button as AriaButton } from 'react-aria-components';
+
+type PaginationProps = {
+ totalPages: number;
+ currentPage: number;
+ pageCount?: number;
+ onPageChange: (page: number) => void;
+};
+
+// https://design-system.w3.org/components/pagination.html
+export const Pagination = ({ totalPages, currentPage, pageCount = 10, onPageChange }: PaginationProps) => {
+ const start = useMemo(() => {
+ return Math.floor(currentPage / pageCount) * pageCount;
+ }, [currentPage, pageCount]);
+
+ const handlePageClick = useCallback(
+ (page: number) => () => {
+ if (typeof onPageChange === 'function' && page >= 0 && page < totalPages) {
+ onPageChange(page);
+ }
+ },
+ [totalPages, onPageChange],
+ );
+
+ const pageArray = useMemo(() => {
+ const arr: number[] = [];
+
+ // totalPage가 0이면 아무것도 안나와서 최소 1이 되도록 수정
+ const _totalPages = totalPages > 0 ? totalPages : 1;
+ if (Number.isNaN(start)) {
+ return arr;
+ }
+
+ for (let i = 0; i < pageCount; i++) {
+ const pageNumber = start + i;
+
+ if (pageNumber >= _totalPages) {
+ continue;
+ }
+
+ arr.push(pageNumber);
+ }
+
+ return arr;
+ }, [start, pageCount, totalPages]);
+
+ const noPrev = start === 0;
+ const noNext = start + pageCount >= totalPages;
+
+ return (
+
+ );
+};
diff --git a/web-app/package.json b/web-app/package.json
index 97dc105..5e23bba 100644
--- a/web-app/package.json
+++ b/web-app/package.json
@@ -22,6 +22,8 @@
"react-dom": "^19.2.4",
"react-hook-form": "^7.72.1",
"react-router": "7.14.0",
+ "tailwind-merge": "^3.5.0",
+ "tailwind-variants": "^3.2.2",
"zod": "^4.3.6"
},
"devDependencies": {
diff --git a/web-app/pnpm-lock.yaml b/web-app/pnpm-lock.yaml
index 280b6a9..3bffe80 100644
--- a/web-app/pnpm-lock.yaml
+++ b/web-app/pnpm-lock.yaml
@@ -41,6 +41,12 @@ importers:
react-router:
specifier: 7.14.0
version: 7.14.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ tailwind-merge:
+ specifier: ^3.5.0
+ version: 3.5.0
+ tailwind-variants:
+ specifier: ^3.2.2
+ version: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.2)
zod:
specifier: ^4.3.6
version: 4.3.6
@@ -1170,42 +1176,36 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.12':
resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12':
resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12':
resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.12':
resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.12':
resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-rc.12':
resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==}
@@ -1267,79 +1267,66 @@ packages:
resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.60.1':
resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==}
cpu: [arm]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.60.1':
resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.60.1':
resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.60.1':
resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==}
cpu: [loong64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.60.1':
resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==}
cpu: [loong64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.60.1':
resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.60.1':
resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==}
cpu: [ppc64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.60.1':
resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.60.1':
resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==}
cpu: [riscv64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.60.1':
resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.60.1':
resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.60.1':
resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==}
cpu: [x64]
os: [linux]
- libc: [musl]
'@rollup/rollup-openbsd-x64@4.60.1':
resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==}
@@ -1418,28 +1405,24 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.2':
resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.2':
resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.2':
resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.2':
resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==}
@@ -2076,28 +2059,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -2464,6 +2443,19 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
+ tailwind-merge@3.5.0:
+ resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
+
+ tailwind-variants@3.2.2:
+ resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==}
+ engines: {node: '>=16.x', pnpm: '>=7.x'}
+ peerDependencies:
+ tailwind-merge: '>=3.0.0'
+ tailwindcss: '*'
+ peerDependenciesMeta:
+ tailwind-merge:
+ optional: true
+
tailwindcss@4.2.2:
resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==}
@@ -5570,6 +5562,14 @@ snapshots:
statuses@2.0.2: {}
+ tailwind-merge@3.5.0: {}
+
+ tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.2):
+ dependencies:
+ tailwindcss: 4.2.2
+ optionalDependencies:
+ tailwind-merge: 3.5.0
+
tailwindcss@4.2.2: {}
tapable@2.3.2: {}