feat(WEB): CEO 대시보드 캘린더 강화 및 validation 모듈 분리

- CalendarSection 일정 CRUD 기능 확장 (상세 모달 연동)
- ScheduleDetailModal 개선
- CEO 대시보드 섹션별 API 키 통일
- validation.ts → validation/ 모듈 분리 (item-schemas, utils)
- formatters.ts 확장
- date.ts 유틸 추가
- SignupPage/EmployeeForm/AddCompanyDialog 등 소규모 개선
- PaymentHistory/PopupManagement utils 정리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-24 13:01:41 +09:00
parent 6a469181cd
commit b8dfa3d887
24 changed files with 726 additions and 502 deletions

View File

@@ -25,6 +25,7 @@ import {
} from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { extractDigits } from '@/lib/formatters';
export function SignupPage() {
const router = useRouter();
@@ -64,7 +65,7 @@ export function SignupPage() {
// 사업자등록번호 자동 포맷팅 (000-00-00000)
const formatBusinessNumber = (value: string) => {
// 숫자만 추출
const numbers = value.replace(/[^\d]/g, '');
const numbers = extractDigits(value);
// 최대 10자리까지만
const limited = numbers.slice(0, 10);
@@ -87,7 +88,7 @@ export function SignupPage() {
// 핸드폰 번호 자동 포맷팅 (010-1111-1111 or 010-111-1111)
const formatPhoneNumber = (value: string) => {
// 숫자만 추출
const numbers = value.replace(/[^\d]/g, '');
const numbers = extractDigits(value);
// 최대 11자리까지만
const limited = numbers.slice(0, 11);

View File

@@ -889,7 +889,7 @@ export function DashboardSettingsDialog({
))}
</div>
<DialogFooter className="flex gap-3 p-4 border-t border-gray-200 sm:justify-center">
<DialogFooter className="flex flex-row gap-3 p-4 border-t border-gray-200 justify-center">
<Button
variant="outline"
onClick={handleCancel}

View File

@@ -126,35 +126,30 @@ export function ScheduleDetailModal({
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="w-[95vw] max-w-[480px] sm:max-w-[480px] p-6">
<DialogContent className="w-[95vw] max-w-[480px] sm:max-w-[480px] p-4 sm:p-6 max-h-[90vh] overflow-y-auto">
<DialogHeader className="pb-2">
<DialogTitle className="text-lg font-bold"> </DialogTitle>
</DialogHeader>
<div className="space-y-6 py-4">
<div className="space-y-4 py-2">
{/* 제목 */}
<div className="flex items-center gap-6">
<label className="w-10 text-sm font-medium text-gray-700 shrink-0">
</label>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-700"></label>
<Input
value={formData.title}
onChange={(e) => handleFieldChange('title', e.target.value)}
placeholder="제목"
className="flex-1"
/>
</div>
{/* 대상 (부서) */}
<div className="flex items-center gap-6">
<label className="w-10 text-sm font-medium text-gray-700 shrink-0">
</label>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-700"></label>
<Select
value={formData.department}
onValueChange={(value) => handleFieldChange('department', value)}
>
<SelectTrigger className="flex-1">
<SelectTrigger>
<SelectValue placeholder="부서명" />
</SelectTrigger>
<SelectContent>
@@ -168,33 +163,31 @@ export function ScheduleDetailModal({
</div>
{/* 기간 */}
<div className="flex items-center gap-6">
<label className="w-10 text-sm font-medium text-gray-700 shrink-0">
</label>
<div className="flex-1 flex items-center gap-2">
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-700"></label>
<div className="flex flex-col gap-2">
<DatePicker
value={formData.startDate}
onChange={(value) => handleFieldChange('startDate', value)}
size="sm"
className="flex-1"
/>
<span className="text-gray-400 px-1">~</span>
<DatePicker
value={formData.endDate}
onChange={(value) => handleFieldChange('endDate', value)}
size="sm"
className="flex-1"
className="w-full"
/>
<div className="flex items-center gap-2">
<span className="text-gray-400 text-xs">~</span>
<DatePicker
value={formData.endDate}
onChange={(value) => handleFieldChange('endDate', value)}
size="sm"
className="w-full"
/>
</div>
</div>
</div>
{/* 시간 */}
<div className="flex items-start gap-6">
<label className="w-10 text-sm font-medium text-gray-700 shrink-0 pt-2">
</label>
<div className="flex-1 space-y-3">
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-700"></label>
<div className="space-y-3">
{/* 종일 체크박스 */}
<div className="flex items-center gap-2">
<Checkbox
@@ -215,15 +208,15 @@ export function ScheduleDetailModal({
value={formData.startTime}
onChange={(value) => handleFieldChange('startTime', value)}
placeholder="시작 시간"
className="flex-1"
className="flex-1 min-w-0"
minuteStep={5}
/>
<span className="text-gray-400 px-1">~</span>
<span className="text-gray-400 shrink-0">~</span>
<TimePicker
value={formData.endTime}
onChange={(value) => handleFieldChange('endTime', value)}
placeholder="종료 시간"
className="flex-1"
className="flex-1 min-w-0"
minuteStep={5}
/>
</div>
@@ -232,11 +225,9 @@ export function ScheduleDetailModal({
</div>
{/* 색상 */}
<div className="flex items-center gap-6">
<label className="w-10 text-sm font-medium text-gray-700 shrink-0">
</label>
<div className="flex gap-3">
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-700"></label>
<div className="flex gap-3 flex-wrap">
{COLOR_OPTIONS.map((color) => (
<button
key={color.value}
@@ -254,20 +245,18 @@ export function ScheduleDetailModal({
</div>
{/* 내용 */}
<div className="flex items-start gap-6">
<label className="w-10 text-sm font-medium text-gray-700 shrink-0 pt-2">
</label>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-700"></label>
<Textarea
value={formData.content}
onChange={(e) => handleFieldChange('content', e.target.value)}
placeholder="내용"
className="flex-1 min-h-[120px] resize-none"
className="min-h-[100px] resize-none"
/>
</div>
</div>
<DialogFooter className="flex gap-2 pt-2">
<DialogFooter className="flex flex-row gap-2 pt-2">
{isEditMode && onDelete && (
<Button
variant="outline"

View File

@@ -12,7 +12,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus, ExternalLink } from 'lucide-react';
import { Plus, ExternalLink, ChevronLeft, ChevronRight } from 'lucide-react';
import { ScheduleCalendar } from '@/components/common/ScheduleCalendar';
import type { ScheduleEvent } from '@/components/common/ScheduleCalendar/types';
import { getCalendarEventsForYear, type CalendarEvent } from '@/constants/calendarEvents';
@@ -243,50 +243,238 @@ export function CalendarSection({
setCurrentDate(date);
};
// 모바일 리스트뷰: 현재 월의 모든 날짜와 이벤트
const monthDaysWithEvents = useMemo(() => {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
const today = new Date();
today.setHours(0, 0, 0, 0);
const days: Array<{
date: Date;
dateStr: string;
label: string;
isToday: boolean;
isWeekend: boolean;
events: ScheduleEvent[];
}> = [];
for (let d = 1; d <= daysInMonth; d++) {
const date = new Date(year, month, d);
const mm = String(month + 1).padStart(2, '0');
const dd = String(d).padStart(2, '0');
const dateStr = `${year}-${mm}-${dd}`;
const dayOfWeek = date.getDay();
const dayEvents = calendarEvents.filter(
(ev) => ev.startDate <= dateStr && ev.endDate >= dateStr
);
days.push({
date,
dateStr,
label: `${d}${dayNames[dayOfWeek]}요일`,
isToday: date.getTime() === today.getTime(),
isWeekend: dayOfWeek === 0 || dayOfWeek === 6,
events: dayEvents,
});
}
return days;
}, [currentDate, calendarEvents]);
const handleMobilePrevMonth = () => {
const prev = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1);
setCurrentDate(prev);
};
const handleMobileNextMonth = () => {
const next = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1);
setCurrentDate(next);
};
return (
<Card>
<CardContent className="p-6">
{/* 섹션 헤더: 타이틀 + 필터들 */}
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold"></h3>
<div className="flex items-center gap-2">
{/* 부서 필터 */}
<Select
value={deptFilter}
onValueChange={(value) => setDeptFilter(value as CalendarDeptFilterType)}
>
<SelectTrigger className="min-w-[80px] w-auto h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DEPT_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 모바일: 스티키 헤더 (타이틀+필터+월네비) */}
<div className="lg:hidden sticky top-12 z-10 bg-white -mx-6 px-6 pt-2 pb-3 border-b border-gray-100">
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
<h3 className="text-lg font-semibold shrink-0"></h3>
<div className="flex items-center gap-2">
{/* 부서 필터 */}
<Select
value={deptFilter}
onValueChange={(value) => setDeptFilter(value as CalendarDeptFilterType)}
>
<SelectTrigger className="min-w-[80px] w-auto h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DEPT_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 업무 필터 */}
<Select
value={taskFilter}
onValueChange={(value) => setTaskFilter(value as CalendarTaskFilterType)}
>
<SelectTrigger className="min-w-[80px] w-auto h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TASK_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 업무 필터 */}
<Select
value={taskFilter}
onValueChange={(value) => setTaskFilter(value as CalendarTaskFilterType)}
>
<SelectTrigger className="min-w-[80px] w-auto h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TASK_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 월 네비게이션 */}
<div className="flex items-center justify-center gap-4">
<Button variant="outline" size="sm" className="h-8 px-2" onClick={handleMobilePrevMonth}>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm font-semibold whitespace-nowrap">
{currentDate.getFullYear()} {currentDate.getMonth() + 1}
</span>
<Button variant="outline" size="sm" className="h-8 px-2" onClick={handleMobileNextMonth}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 데스크탑: 일반 헤더 */}
<div className="hidden lg:flex items-center justify-between mb-4 flex-wrap gap-2">
<h3 className="text-lg font-semibold shrink-0"></h3>
<div className="flex items-center gap-2">
<Select
value={deptFilter}
onValueChange={(value) => setDeptFilter(value as CalendarDeptFilterType)}
>
<SelectTrigger className="min-w-[80px] w-auto h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DEPT_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={taskFilter}
onValueChange={(value) => setTaskFilter(value as CalendarTaskFilterType)}
>
<SelectTrigger className="min-w-[80px] w-auto h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TASK_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 모바일: 리스트뷰 */}
<div className="lg:hidden pt-3">
{/* 일별 리스트 */}
<div className="space-y-1">
{monthDaysWithEvents.map((day) => {
const hasEvents = day.events.length > 0;
const isSelected = selectedDate && day.date.getTime() === selectedDate.getTime();
return (
<div
key={day.dateStr}
className={`rounded-lg px-3 py-2.5 cursor-pointer transition-colors ${
isSelected ? 'bg-blue-50 ring-1 ring-blue-200' :
day.isToday ? 'bg-amber-50' : ''
} ${!hasEvents && !day.isToday && !isSelected ? 'opacity-50' : ''}`}
onClick={() => handleDateClick(day.date)}
>
{/* 날짜 + 일정등록 버튼 */}
<div className="flex items-center justify-between mb-1">
<div className={`text-sm font-medium ${
day.isWeekend ? 'text-red-500' : 'text-gray-900'
} ${day.isToday ? 'font-bold' : ''}`}>
{day.label}
{day.isToday && <span className="ml-1 text-amber-600 text-xs font-semibold">()</span>}
</div>
{isSelected && (
<Button
variant="outline"
size="sm"
className="h-6 text-xs gap-0.5 px-2"
onClick={(e) => {
e.stopPropagation();
onScheduleEdit?.({
id: '',
title: '',
startDate: day.dateStr,
endDate: day.dateStr,
type: 'schedule',
});
}}
>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
{/* 이벤트 목록 (날짜 아래) */}
{hasEvents ? (
<div className="space-y-1 pl-1">
{day.events.slice(0, 3).map((ev) => {
const evData = ev.data as Record<string, unknown>;
const evType = evData?._type as string;
const colorMap: Record<string, string> = {
holiday: 'bg-red-500',
tax: 'bg-orange-500',
schedule: 'bg-blue-500',
order: 'bg-green-500',
construction: 'bg-purple-500',
issue: 'bg-red-400',
};
const dotColor = colorMap[evType] || 'bg-gray-400';
const title = evData?.name as string || evData?.title as string || ev.title;
const cleanTitle = title?.replace(/^[🔴🟠]\s*/, '') || '';
return (
<div key={ev.id} className="flex items-center gap-1.5 text-xs text-gray-700">
<span className={`w-2 h-2 rounded-full shrink-0 ${dotColor}`} />
<span className="truncate">{cleanTitle}</span>
</div>
);
})}
{day.events.length > 3 && (
<div className="text-xs text-gray-400 pl-3.5">+{day.events.length - 3}</div>
)}
</div>
) : null}
</div>
);
})}
</div>
</div>
{/* 데스크탑: 기존 캘린더 + 상세 */}
<div className="hidden lg:grid lg:grid-cols-2 gap-6">
{/* 캘린더 영역 */}
<div>
<ScheduleCalendar
@@ -297,25 +485,22 @@ export function CalendarSection({
onEventClick={handleEventClick}
onMonthChange={handleMonthChange}
maxEventsPerDay={4}
weekStartsOn={1} // 월요일 시작 (기획서)
weekStartsOn={1}
className="[&_.weekend]:bg-yellow-50"
/>
</div>
{/* 선택된 날짜 일정 + 이슈 목록 */}
<div className="border rounded-lg p-4">
{/* 헤더: 날짜 + 일정등록 버튼 */}
<div className="flex items-center justify-between mb-2">
<h4 className="text-lg font-semibold">
{selectedDate ? formatSelectedDate(selectedDate) : '날짜를 선택하세요'}
</h4>
{/* 일정등록 버튼 */}
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1"
onClick={() => {
// 선택된 날짜 기준으로 새 일정 등록
const year = selectedDate?.getFullYear() || new Date().getFullYear();
const month = String((selectedDate?.getMonth() || new Date().getMonth()) + 1).padStart(2, '0');
const day = String(selectedDate?.getDate() || new Date().getDate()).padStart(2, '0');
@@ -334,7 +519,6 @@ export function CalendarSection({
</Button>
</div>
{/* 총 N건 */}
<div className="text-sm text-muted-foreground mb-4">
{totalItemCount}
</div>
@@ -345,7 +529,6 @@ export function CalendarSection({
</div>
) : (
<div className="space-y-3 max-h-[calc(100vh-400px)] overflow-y-auto pr-1">
{/* 공휴일/세금일정 목록 */}
{selectedDateItems.staticEvents.map((event) => {
const eventData = event.data as CalendarEvent & { _type: string };
const isHoliday = eventData.type === 'holiday';
@@ -369,36 +552,25 @@ export function CalendarSection({
);
})}
{/* 일정 목록 */}
{selectedDateItems.schedules.map((schedule) => (
<div
key={schedule.id}
className="p-3 border rounded-lg hover:bg-gray-50 transition-colors cursor-pointer"
onClick={() => onScheduleClick?.(schedule)}
>
{/* 제목 */}
<div className="font-medium text-base mb-1">
{schedule.title}
</div>
{/* 부서명 | 날짜 | 시간 */}
<div className="text-sm text-muted-foreground">
{formatScheduleDetail(schedule)}
</div>
<div className="font-medium text-base mb-1">{schedule.title}</div>
<div className="text-sm text-muted-foreground">{formatScheduleDetail(schedule)}</div>
</div>
))}
{/* 이슈 목록 */}
{selectedDateItems.issues.map((issue) => (
<div
key={issue.id}
className="p-3 border border-red-200 rounded-lg hover:bg-red-50 transition-colors cursor-pointer"
onClick={() => {
if (issue.path) {
router.push(`/ko${issue.path}`);
}
if (issue.path) router.push(`/ko${issue.path}`);
}}
>
{/* 뱃지 + 제목 */}
<div className="flex items-start gap-2 mb-1">
<Badge
variant="secondary"
@@ -406,11 +578,8 @@ export function CalendarSection({
>
{issue.badge}
</Badge>
<span className="font-medium text-sm flex-1">
{issue.content}
</span>
<span className="font-medium text-sm flex-1">{issue.content}</span>
</div>
{/* 시간 + 상세보기 */}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{issue.time}</span>
{issue.path && (

View File

@@ -120,7 +120,7 @@ export function DailyAttendanceSection({ data }: DailyAttendanceSectionProps) {
</Select>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<table className="w-full text-sm min-w-[400px]">
<thead>
<tr className="bg-gray-50 border-b">
<th className="px-4 py-2 text-center text-gray-600 font-medium w-12">No</th>

View File

@@ -33,7 +33,7 @@ export function DebtCollectionSection({ data }: DebtCollectionSectionProps) {
colorTheme="red"
/>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 mb-4">
{data.cards.map((card, idx) => (
<AmountCardItem
key={card.id}

View File

@@ -94,7 +94,7 @@ export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyRepor
{/* 카드 내용 */}
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
<div className="grid grid-cols-1 xs:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{data.cards.map((card, idx) => {
const style = CARD_STYLES[idx] || CARD_STYLES[0];
const CardIcon = style.Icon;
@@ -240,7 +240,7 @@ export function EnhancedStatusBoardSection({ items, itemSettings }: EnhancedStat
</div>
{/* 카드 그리드 */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
{filteredItems.map((item) => {
const isHighlighted = item.isHighlighted;
const style = ITEM_STYLES[item.label] || DEFAULT_STYLE;
@@ -325,7 +325,7 @@ export function EnhancedMonthlyExpenseSection({ data, onCardClick }: EnhancedMon
</div>
{/* 카드 그리드 */}
<div className="grid grid-cols-1 xs:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{/* 카드 1: 매입 */}
<div
style={{ backgroundColor: '#f5f3ff', borderColor: '#ddd6fe' }}

View File

@@ -229,7 +229,7 @@ export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<table className="w-full text-sm min-w-[500px]">
<thead>
<tr className="bg-gray-50 border-b">
<th className="px-4 py-2 text-left text-gray-600 font-medium"></th>

View File

@@ -234,7 +234,7 @@ export function SalesStatusSection({ data }: SalesStatusSectionProps) {
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<table className="w-full text-sm min-w-[500px]">
<thead>
<tr className="bg-gray-50 border-b">
<th className="px-4 py-2 text-left text-gray-600 font-medium"></th>

View File

@@ -39,22 +39,12 @@ export function StatusBoardSection({ items, itemSettings }: StatusBoardSectionPr
})
: items;
// 아이템 개수에 따른 동적 그리드 클래스 (xs: 344px Galaxy Fold 지원)
const getGridColsClass = () => {
const count = filteredItems.length;
if (count <= 1) return 'grid-cols-1';
if (count === 2) return 'grid-cols-1 xs:grid-cols-2';
if (count === 3) return 'grid-cols-1 xs:grid-cols-2 md:grid-cols-3';
// 4개 이상: 최대 4열, 넘치면 아래로
return 'grid-cols-1 xs:grid-cols-2 md:grid-cols-4';
};
return (
<Card>
<CardContent className="p-6">
<SectionTitle title="현황판" badge="warning" />
<div className={`grid ${getGridColsClass()} gap-3`}>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3">
{filteredItems.map((item) => (
<IssueCardItem
key={item.id}

View File

@@ -293,11 +293,11 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
{/* 날짜 네비게이션 (이전 이슈 탭일 때만) */}
{activeTab === 'past' && (
<div className="flex items-center gap-1 shrink-0">
<div className="flex items-center gap-1 min-w-0">
<Button
variant="outline"
size="sm"
className="h-8 px-2"
className="h-8 px-1.5 shrink-0"
onClick={handlePrevDate}
>
<ChevronLeft className="h-4 w-4" />
@@ -308,14 +308,14 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
onChange={handleDatePickerChange}
size="sm"
displayFormat="yyyy년 MM월 dd일"
className="w-[170px]"
className="min-w-0 flex-1"
align="start"
sideOffset={4}
/>
<Button
variant="outline"
size="sm"
className="h-8 px-2"
className="h-8 px-1.5 shrink-0"
onClick={handleNextDate}
disabled={isNextDisabled}
>
@@ -326,7 +326,7 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
{/* 필터 */}
<Select value={filter} onValueChange={setFilter}>
<SelectTrigger className="w-44 h-9 ml-auto">
<SelectTrigger className="w-44 h-9">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>

View File

@@ -78,7 +78,7 @@ export function UnshippedSection({ data }: UnshippedSectionProps) {
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<table className="w-full text-sm min-w-[550px]">
<thead>
<tr className="bg-gray-50 border-b">
<th className="px-4 py-2 text-center text-gray-600 font-medium w-12">No</th>

View File

@@ -15,7 +15,7 @@ export function VatSection({ data, onClick }: VatSectionProps) {
<CardContent className="p-6">
<SectionTitle title="부가세 현황" badge="warning" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 mb-4">
{data.cards.map((card) => (
<AmountCardItem
key={card.id}

View File

@@ -46,6 +46,7 @@ import {
} from './types';
import { getPositions, getDepartments, uploadProfileImage, type PositionItem, type DepartmentItem } from './actions';
import { getProfileImageUrl } from './utils';
import { extractDigits } from '@/lib/formatters';
// 부서 트리 구조 타입
interface DepartmentTreeNode extends DepartmentItem {
@@ -272,7 +273,7 @@ export function EmployeeForm({
// 휴대폰 번호 자동 하이픈 포맷팅
const formatPhoneNumber = (value: string): string => {
const numbers = value.replace(/[^0-9]/g, '');
const numbers = extractDigits(value);
if (numbers.length <= 3) return numbers;
if (numbers.length <= 7) return `${numbers.slice(0, 3)}-${numbers.slice(3)}`;
return `${numbers.slice(0, 3)}-${numbers.slice(3, 7)}-${numbers.slice(7, 11)}`;
@@ -280,7 +281,7 @@ export function EmployeeForm({
// 주민등록번호 자동 하이픈 포맷팅
const formatResidentNumber = (value: string): string => {
const numbers = value.replace(/[^0-9]/g, '');
const numbers = extractDigits(value);
if (numbers.length <= 6) return numbers;
return `${numbers.slice(0, 6)}-${numbers.slice(6, 13)}`;
};

View File

@@ -20,6 +20,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { extractDigits } from '@/lib/formatters';
interface AddCompanyDialogProps {
open: boolean;
@@ -36,7 +37,7 @@ export function AddCompanyDialog({ open, onOpenChange }: AddCompanyDialogProps)
// 숫자만 입력 가능 (10자리 제한)
const handleBusinessNumberChange = (value: string) => {
const numbersOnly = value.replace(/[^0-9]/g, '');
const numbersOnly = extractDigits(value);
if (numbersOnly.length <= 10) {
setBusinessNumber(numbersOnly);
}

View File

@@ -1,6 +1,6 @@
import type { PaymentApiData, PaymentHistory, PaymentStatus } from './types';
import { PAYMENT_METHOD_LABELS } from './types';
import { formatDate } from '@/lib/utils/date';
import { formatDate, toDateString } from '@/lib/utils/date';
// ===== API → Frontend 변환 =====
export function transformApiToFrontend(apiData: PaymentApiData): PaymentHistory {
@@ -11,12 +11,12 @@ export function transformApiToFrontend(apiData: PaymentApiData): PaymentHistory
const paymentMethodLabel = PAYMENT_METHOD_LABELS[apiData.payment_method] || apiData.payment_method;
// 구독 기간
const periodStart = subscription?.started_at?.split('T')[0] || '';
const periodEnd = subscription?.ended_at?.split('T')[0] || '';
const periodStart = toDateString(subscription?.started_at);
const periodEnd = toDateString(subscription?.ended_at);
return {
id: String(apiData.id),
paymentDate: apiData.paid_at?.split('T')[0] || formatDate(apiData.created_at),
paymentDate: toDateString(apiData.paid_at) || formatDate(apiData.created_at),
subscriptionName: plan?.name || '구독',
paymentMethod: paymentMethodLabel,
subscriptionPeriod: {

View File

@@ -3,6 +3,7 @@
*/
import type { Popup, PopupFormData, PopupTarget, PopupStatus } from './types';
import { toDateString } from '@/lib/utils/date';
// ============================================
// API 응답 타입 정의
@@ -55,9 +56,9 @@ export function transformApiToFrontend(apiData: PopupApiData): Popup {
status: apiData.status as PopupStatus,
author: apiData.creator?.name || '관리자',
authorId: apiData.created_by ? String(apiData.created_by) : '',
createdAt: apiData.created_at?.split('T')[0] || '',
startDate: apiData.started_at?.split('T')[0] || '',
endDate: apiData.ended_at?.split('T')[0] || '',
createdAt: toDateString(apiData.created_at),
startDate: toDateString(apiData.started_at),
endDate: toDateString(apiData.ended_at),
};
}