feat(WEB): CEO 대시보드 리팩토링, 캘린더 강화, validation 모듈 분리, Git Workflow 정립

- CEO 대시보드 전 섹션 공통 컴포넌트 기반 리팩토링 (SectionCard, StatItem 등)
- CalendarSection 일정 CRUD 기능 확장
- validation.ts → validation/ 모듈 분리 (item-schemas, form-schemas, common, utils)
- CLAUDE.md Git Workflow 섹션 추가 (develop/main 플로우 정의)
- Jenkinsfile CI/CD 파이프라인 정비 (Slack 알림 추가)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-24 21:55:15 +09:00
parent bd7cb4c301
commit 49d07914fd
35 changed files with 1648 additions and 1501 deletions

View File

@@ -17,6 +17,68 @@ sam_project:
---
## Git Workflow
**Priority**: 🔴
### 브랜치 구조
| 브랜치 | 역할 | 커밋 상태 |
|--------|------|-----------|
| `develop` | 평소 작업 브랜치 (자유롭게) | 지저분해도 OK |
| `stage` | QA/테스트 환경 | 기능별 squash 정리 |
| `main` | 배포용 (기본 브랜치) | 검증된 것만 |
| `feature/*` | 큰 기능/실험적 작업 시 | 선택적 사용 |
### "git 올려줘" 단축 명령어
`git 올려줘` 입력 시 **develop에 push**:
1. `git status` → 2. `git diff --stat` → 3. `git add -A` → 4. `git commit` (자동 메시지) → 5. `git push origin develop`
- `snapshot.txt`, `.DS_Store` 파일은 항상 제외
- develop에서 자유롭게 커밋 (커밋 메시지 정리 불필요)
### main에 올리기 (기능별 squash merge)
사용자가 "main에 올려줘" 또는 특정 기능을 main에 올리라고 지시할 때만 실행.
**절대 자동으로 main에 push하지 않음.**
```bash
# 기능별로 squash merge
git checkout main
git merge --squash develop # 또는 cherry-pick으로 특정 커밋만 선별
git commit -m "feat: [기능명]"
git push origin main
git checkout develop
```
기능별로 나눠서 올리는 경우:
```bash
# 예: "대시보드랑 거래처 main에 올려줘"
git checkout main
git cherry-pick --no-commit <대시보드커밋1> <대시보드커밋2>
git commit -m "feat: CEO 대시보드 캘린더 기능 구현"
git cherry-pick --no-commit <거래처커밋1> <거래처커밋2>
git commit -m "feat: 거래처 관리 개선"
git push origin main
git checkout develop
```
**핵심: main에는 기능 단위 커밋만 → 문제 시 `git revert`로 해당 기능만 롤백 가능**
### feature 브랜치 사용 기준
| 상황 | 방법 |
|------|------|
| 일반 작업 | develop에서 바로 |
| 1주일+ 걸리는 큰 기능 | feature/* 따서 작업 |
| 실험적 시도 | feature/* 따서 작업 |
| 백엔드와 동시 수정 건 | 각자 feature/* 권장 |
### 금지 사항
- ❌ main에 직접 커밋/push
-`git push --force` (main/develop)
- ❌ 사용자 지시 없이 main에 merge
---
## Client Component 사용 원칙
**Priority**: 🔴

31
Jenkinsfile vendored
View File

@@ -8,13 +8,18 @@ pipeline {
stages {
stage('Checkout') {
steps { checkout scm }
steps {
slackSend channel: '#product_infra', color: '#439FE0',
message: "🚀 *react* 빌드 시작 (`${env.BRANCH_NAME}`)\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
checkout scm
}
}
stage('Prepare Env') {
steps {
script {
if (env.BRANCH_NAME == 'main') {
// main: Stage 빌드 먼저 (승인 후 Production 재빌드)
sh "cp /var/lib/jenkins/env-files/react/.env.stage .env.local"
} else {
def envFile = "/var/lib/jenkins/env-files/react/.env.${env.BRANCH_NAME}"
@@ -39,14 +44,10 @@ pipeline {
sshagent(credentials: ['deploy-ssh-key']) {
sh """
rsync -az --delete \
--exclude='.git' \
--exclude='.env*' \
--exclude='ecosystem.config.*' \
--exclude='.git' --exclude='.env*' --exclude='ecosystem.config.*' \
.next package.json next.config.ts public node_modules \
${DEPLOY_USER}@114.203.209.83:/home/webservice/react/
scp .env.local ${DEPLOY_USER}@114.203.209.83:/home/webservice/react/.env.local
ssh ${DEPLOY_USER}@114.203.209.83 'cd /home/webservice/react && pm2 restart sam-react'
"""
}
@@ -60,13 +61,10 @@ pipeline {
sshagent(credentials: ['deploy-ssh-key']) {
sh """
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/react-stage/releases/${RELEASE_ID}'
rsync -az --delete \
.next package.json next.config.ts public node_modules \
${DEPLOY_USER}@211.117.60.189:/home/webservice/react-stage/releases/${RELEASE_ID}/
scp .env.local ${DEPLOY_USER}@211.117.60.189:/home/webservice/react-stage/releases/${RELEASE_ID}/.env.local
ssh ${DEPLOY_USER}@211.117.60.189 '
ln -sfn /home/webservice/react-stage/releases/${RELEASE_ID} /home/webservice/react-stage/current &&
cd /home/webservice && pm2 reload sam-front-stage 2>/dev/null || pm2 start react-stage/current/node_modules/.bin/next --name sam-front-stage -- start -p 3100 &&
@@ -104,13 +102,10 @@ pipeline {
sshagent(credentials: ['deploy-ssh-key']) {
sh """
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/react/releases/${RELEASE_ID}'
rsync -az --delete \
.next package.json next.config.ts public node_modules \
${DEPLOY_USER}@211.117.60.189:/home/webservice/react/releases/${RELEASE_ID}/
scp .env.local ${DEPLOY_USER}@211.117.60.189:/home/webservice/react/releases/${RELEASE_ID}/.env.local
ssh ${DEPLOY_USER}@211.117.60.189 '
ln -sfn /home/webservice/react/releases/${RELEASE_ID} /home/webservice/react/current &&
cd /home/webservice && pm2 reload sam-front &&
@@ -123,7 +118,13 @@ pipeline {
}
post {
success { echo '✅ react 배포 완료 (' + env.BRANCH_NAME + ')' }
failure { echo '❌ react 배포 실패 (' + env.BRANCH_NAME + ')' }
success {
slackSend channel: '#product_infra', color: 'good', tokenCredentialId: 'slack-token',
message: "✅ *react* 배포 성공 (`${env.BRANCH_NAME}`)\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
failure {
slackSend channel: '#product_infra', color: 'danger', tokenCredentialId: 'slack-token',
message: "❌ *react* 배포 실패 (`${env.BRANCH_NAME}`)\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
}
}
}

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

@@ -1,5 +1,6 @@
'use client';
import { useState } from 'react';
import {
Check,
AlertTriangle,
@@ -7,6 +8,7 @@ import {
AlertCircle,
TrendingUp,
TrendingDown,
ChevronDown,
type LucideIcon,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
@@ -18,18 +20,18 @@ import type { CheckPoint, CheckPointType, AmountCard, HighlightColor } from './t
// 섹션별 컬러 테마 타입
export type SectionColorTheme = 'blue' | 'purple' | 'orange' | 'green' | 'red' | 'amber' | 'cyan' | 'pink' | 'emerald' | 'indigo';
// 컬러 테마별 스타일
export const SECTION_THEME_STYLES: Record<SectionColorTheme, { bg: string; border: string; iconBg: string; labelColor: string; accentColor: string }> = {
blue: { bg: '#eff6ff', border: '#bfdbfe', iconBg: '#3b82f6', labelColor: '#1d4ed8', accentColor: '#3b82f6' },
purple: { bg: '#faf5ff', border: '#e9d5ff', iconBg: '#a855f7', labelColor: '#7c3aed', accentColor: '#a855f7' },
orange: { bg: '#fff7ed', border: '#fed7aa', iconBg: '#f97316', labelColor: '#ea580c', accentColor: '#f97316' },
green: { bg: '#f0fdf4', border: '#bbf7d0', iconBg: '#22c55e', labelColor: '#16a34a', accentColor: '#22c55e' },
red: { bg: '#fef2f2', border: '#fecaca', iconBg: '#ef4444', labelColor: '#dc2626', accentColor: '#ef4444' },
amber: { bg: '#fffbeb', border: '#fde68a', iconBg: '#f59e0b', labelColor: '#d97706', accentColor: '#f59e0b' },
cyan: { bg: '#ecfeff', border: '#a5f3fc', iconBg: '#06b6d4', labelColor: '#0891b2', accentColor: '#06b6d4' },
pink: { bg: '#fdf2f8', border: '#fbcfe8', iconBg: '#ec4899', labelColor: '#db2777', accentColor: '#ec4899' },
emerald: { bg: '#ecfdf5', border: '#a7f3d0', iconBg: '#10b981', labelColor: '#059669', accentColor: '#10b981' },
indigo: { bg: '#eef2ff', border: '#c7d2fe', iconBg: '#6366f1', labelColor: '#4f46e5', accentColor: '#6366f1' },
// 컬러 테마별 스타일 (다크모드 지원 Tailwind 클래스)
export const SECTION_THEME_STYLES: Record<SectionColorTheme, { bgClass: string; borderClass: string; iconBg: string; labelClass: string; accentColor: string }> = {
blue: { bgClass: 'bg-blue-50 dark:bg-blue-900/30', borderClass: 'border-blue-200 dark:border-blue-800', iconBg: '#3b82f6', labelClass: 'text-blue-700 dark:text-blue-300', accentColor: '#3b82f6' },
purple: { bgClass: 'bg-purple-50 dark:bg-purple-900/30', borderClass: 'border-purple-200 dark:border-purple-800', iconBg: '#a855f7', labelClass: 'text-purple-700 dark:text-purple-300', accentColor: '#a855f7' },
orange: { bgClass: 'bg-orange-50 dark:bg-orange-900/30', borderClass: 'border-orange-200 dark:border-orange-800', iconBg: '#f97316', labelClass: 'text-orange-700 dark:text-orange-300', accentColor: '#f97316' },
green: { bgClass: 'bg-green-50 dark:bg-green-900/30', borderClass: 'border-green-200 dark:border-green-800', iconBg: '#22c55e', labelClass: 'text-green-700 dark:text-green-300', accentColor: '#22c55e' },
red: { bgClass: 'bg-red-50 dark:bg-red-900/30', borderClass: 'border-red-200 dark:border-red-800', iconBg: '#ef4444', labelClass: 'text-red-700 dark:text-red-300', accentColor: '#ef4444' },
amber: { bgClass: 'bg-amber-50 dark:bg-amber-900/30', borderClass: 'border-amber-200 dark:border-amber-800', iconBg: '#f59e0b', labelClass: 'text-amber-700 dark:text-amber-300', accentColor: '#f59e0b' },
cyan: { bgClass: 'bg-cyan-50 dark:bg-cyan-900/30', borderClass: 'border-cyan-200 dark:border-cyan-800', iconBg: '#06b6d4', labelClass: 'text-cyan-700 dark:text-cyan-300', accentColor: '#06b6d4' },
pink: { bgClass: 'bg-pink-50 dark:bg-pink-900/30', borderClass: 'border-pink-200 dark:border-pink-800', iconBg: '#ec4899', labelClass: 'text-pink-700 dark:text-pink-300', accentColor: '#ec4899' },
emerald: { bgClass: 'bg-emerald-50 dark:bg-emerald-900/30', borderClass: 'border-emerald-200 dark:border-emerald-800', iconBg: '#10b981', labelClass: 'text-emerald-700 dark:text-emerald-300', accentColor: '#10b981' },
indigo: { bgClass: 'bg-indigo-50 dark:bg-indigo-900/30', borderClass: 'border-indigo-200 dark:border-indigo-800', iconBg: '#6366f1', labelClass: 'text-indigo-700 dark:text-indigo-300', accentColor: '#6366f1' },
};
/**
@@ -249,31 +251,21 @@ export const AmountCardItem = ({
return formatKoreanAmount(amount);
};
// 테마 적용 시 스타일
const cardStyle = themeStyle && !card.isHighlighted ? {
backgroundColor: themeStyle.bg,
borderColor: themeStyle.border,
} : undefined;
return (
<Card
className={cn(
'relative',
'relative border',
onClick && 'cursor-pointer hover:shadow-lg transition-all hover:scale-[1.02]',
card.isHighlighted && 'border-red-300 bg-red-50',
!themeStyle && !card.isHighlighted && 'border',
card.isHighlighted && 'border-red-300 bg-red-50 dark:border-red-700 dark:bg-red-900/30',
themeStyle && !card.isHighlighted && cn(themeStyle.bgClass, themeStyle.borderClass),
className
)}
style={cardStyle}
>
{/* 건수 뱃지 (오른쪽 상단) */}
{showCountBadge && card.subLabel && (
<div
className="absolute top-3 right-3 px-2 py-0.5 rounded-full text-xs font-bold"
style={{
backgroundColor: themeStyle ? themeStyle.iconBg : '#ef4444',
color: '#ffffff'
}}
className="absolute top-3 right-3 px-2 py-0.5 rounded-full text-xs font-bold text-white"
style={{ backgroundColor: themeStyle ? themeStyle.iconBg : '#ef4444' }}
>
{card.subLabel}
</div>
@@ -299,9 +291,8 @@ export const AmountCardItem = ({
<p
className={cn(
"text-sm font-medium truncate",
card.isHighlighted ? 'text-red-600' : 'text-muted-foreground'
card.isHighlighted ? 'text-red-600' : themeStyle ? themeStyle.labelClass : 'text-muted-foreground'
)}
style={themeStyle && !card.isHighlighted ? { color: themeStyle.labelColor } : undefined}
>
{card.label}
</p>
@@ -309,7 +300,7 @@ export const AmountCardItem = ({
{/* 금액 */}
<p className={cn(
"text-2xl font-bold",
"text-2xl font-bold text-foreground",
card.isHighlighted && 'text-red-600'
)}>
{formatCardAmount(card.amount)}
@@ -318,11 +309,12 @@ export const AmountCardItem = ({
{/* 트렌드 표시 (pill 형태, 금액 아래에 배치) */}
{showTrend && trendValue && (
<div
className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit"
style={{
backgroundColor: trendDirection === 'up' ? '#dcfce7' : '#fee2e2',
color: trendDirection === 'up' ? '#16a34a' : '#dc2626'
}}
className={cn(
"flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit",
trendDirection === 'up'
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
)}
>
{trendDirection === 'up' ? (
<TrendingUp className="h-3 w-3 shrink-0" />
@@ -360,10 +352,12 @@ export const AmountCardItem = ({
{card.subLabel && card.subAmount === undefined && !card.previousLabel && (
subLabelAsBadge && themeStyle ? (
<span
className="text-xs font-medium px-2 py-0.5 rounded-full border truncate w-fit max-w-full"
className={cn(
"text-xs font-medium px-2 py-0.5 rounded-full border truncate w-fit max-w-full",
themeStyle.labelClass
)}
style={{
backgroundColor: `${themeStyle.iconBg}15`,
color: themeStyle.labelColor,
borderColor: `${themeStyle.iconBg}30`,
}}
>
@@ -431,4 +425,69 @@ export const IssueCardItem = ({
</CardContent>
</Card>
);
};
};
/**
* 접기/펼치기 가능한 대시보드 카드
* - 다크 헤더 + 흰색 바디 패턴의 공통 컴포넌트
* - 헤더 클릭 시 바디 토글
*/
interface CollapsibleDashboardCardProps {
icon: React.ReactNode;
title: string;
subtitle?: string;
rightElement?: React.ReactNode;
children: React.ReactNode;
defaultOpen?: boolean;
bodyClassName?: string;
}
export function CollapsibleDashboardCard({
icon,
title,
subtitle,
rightElement,
children,
defaultOpen = true,
bodyClassName,
}: CollapsibleDashboardCardProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className="rounded-xl border border-border lg:overflow-hidden">
<div
className={cn(
'bg-slate-800 dark:bg-slate-700 px-6 py-4 sticky top-12 z-10 lg:static cursor-pointer select-none',
isOpen ? 'rounded-t-xl' : 'rounded-xl'
)}
onClick={() => setIsOpen(!isOpen)}
>
<div className="flex flex-col items-center gap-2 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col items-center gap-2 lg:flex-row lg:gap-3">
<div className="bg-white/10 p-2 rounded-lg">
{icon}
</div>
<div className="text-center lg:text-left">
<h3 className="text-lg font-semibold text-white">{title}</h3>
{subtitle && <p className="text-sm text-slate-300">{subtitle}</p>}
</div>
</div>
<div className="flex items-center gap-2">
{rightElement}
<ChevronDown
className={cn(
'h-4 w-4 text-white/70 transition-transform duration-200',
!isOpen && '-rotate-90'
)}
/>
</div>
</div>
</div>
{isOpen && (
<div className={cn('bg-card text-card-foreground', bodyClassName ?? 'p-6')}>
{children}
</div>
)}
</div>
);
}

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

@@ -2,7 +2,6 @@
import { useState, useMemo, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
@@ -12,11 +11,12 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus, ExternalLink } from 'lucide-react';
import { Plus, ExternalLink, ChevronLeft, ChevronRight, CalendarDays } from 'lucide-react';
import { ScheduleCalendar } from '@/components/common/ScheduleCalendar';
import type { ScheduleEvent } from '@/components/common/ScheduleCalendar/types';
import { getCalendarEventsForYear, type CalendarEvent } from '@/constants/calendarEvents';
import { useCalendarScheduleStore } from '@/stores/useCalendarScheduleStore';
import { CollapsibleDashboardCard } from '../components';
import type {
CalendarScheduleItem,
CalendarViewType,
@@ -46,16 +46,16 @@ const SCHEDULE_TYPE_COLORS: Record<string, string> = {
// 이슈 뱃지별 색상
const ISSUE_BADGE_COLORS: Record<TodayIssueListBadgeType, string> = {
'수주등록': 'bg-blue-100 text-blue-700',
'추심이슈': 'bg-purple-100 text-purple-700',
'안전재고': 'bg-orange-100 text-orange-700',
'지출 승인대기': 'bg-green-100 text-green-700',
'세금 신고': 'bg-red-100 text-red-700',
'결재 요청': 'bg-yellow-100 text-yellow-700',
'신규거래처': 'bg-emerald-100 text-emerald-700',
'입금': 'bg-teal-100 text-teal-700',
'출금': 'bg-pink-100 text-pink-700',
'기타': 'bg-gray-100 text-gray-700',
'수주등록': 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
'추심이슈': 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
'안전재고': 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
'지출 승인대기': 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
'세금 신고': 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
'결재 요청': 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300',
'신규거래처': 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
'입금': 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
'출금': 'bg-pink-100 text-pink-700 dark:bg-pink-900/40 dark:text-pink-300',
'기타': 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
};
// 부서 필터 옵션
@@ -243,14 +243,117 @@ 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">
{/* 부서 필터 */}
<CollapsibleDashboardCard
icon={<CalendarDays style={{ color: '#ffffff' }} className="h-5 w-5" />}
title="캘린더"
subtitle="일정 관리"
>
{/* 모바일: 필터+월네비 */}
<div className="lg:hidden mb-3">
<div className="flex items-center gap-2 mb-3">
{/* 부서 필터 */}
<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 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 text-foreground 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="hidden lg:flex items-center justify-end mb-4 gap-2">
<Select
value={deptFilter}
onValueChange={(value) => setDeptFilter(value as CalendarDeptFilterType)}
@@ -267,7 +370,6 @@ export function CalendarSection({
</SelectContent>
</Select>
{/* 업무 필터 */}
<Select
value={taskFilter}
onValueChange={(value) => setTaskFilter(value as CalendarTaskFilterType)}
@@ -283,10 +385,93 @@ export function CalendarSection({
))}
</SelectContent>
</Select>
</div>
{/* 모바일: 리스트뷰 */}
<div className="lg:hidden pt-3">
{/* 일별 리스트 */}
<div className="divide-y divide-border">
{monthDaysWithEvents.map((day) => {
const hasEvents = day.events.length > 0;
const isSelected = selectedDate && day.date.getTime() === selectedDate.getTime();
return (
<div
key={day.dateStr}
className={`px-3 py-2.5 cursor-pointer transition-colors ${
isSelected ? 'bg-blue-50 dark:bg-blue-900/30' :
day.isToday ? 'bg-amber-50 dark:bg-amber-900/30' : ''
} ${!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-foreground'
} ${day.isToday ? 'font-bold' : ''}`}>
{day.label}
{day.isToday && <span className="ml-1 text-amber-600 dark:text-amber-400 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">
{(isSelected ? day.events : 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-muted-foreground">
<span className={`w-2 h-2 rounded-full shrink-0 ${dotColor}`} />
<span className={isSelected ? '' : 'truncate'}>{cleanTitle}</span>
</div>
);
})}
{!isSelected && day.events.length > 3 && (
<div className="text-xs text-muted-foreground pl-3.5">+{day.events.length - 3}</div>
)}
</div>
) : null}
</div>
);
})}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 데스크탑: 기존 캘린더 + 상세 */}
<div className="hidden lg:grid lg:grid-cols-2 gap-6">
{/* 캘린더 영역 */}
<div>
<ScheduleCalendar
@@ -297,25 +482,22 @@ export function CalendarSection({
onEventClick={handleEventClick}
onMonthChange={handleMonthChange}
maxEventsPerDay={4}
weekStartsOn={1} // 월요일 시작 (기획서)
className="[&_.weekend]:bg-yellow-50"
weekStartsOn={1}
className="[&_.weekend]:bg-yellow-50 dark:[&_.weekend]:bg-yellow-900/20"
/>
</div>
{/* 선택된 날짜 일정 + 이슈 목록 */}
<div className="border rounded-lg p-4">
{/* 헤더: 날짜 + 일정등록 버튼 */}
<div className="border border-border rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<h4 className="text-lg font-semibold">
<h4 className="text-lg font-semibold text-foreground">
{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 +516,6 @@ export function CalendarSection({
</Button>
</div>
{/* 총 N건 */}
<div className="text-sm text-muted-foreground mb-4">
{totalItemCount}
</div>
@@ -345,7 +526,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';
@@ -354,13 +534,13 @@ export function CalendarSection({
key={event.id}
className={`p-3 rounded-lg ${
isHoliday
? 'bg-red-50 border border-red-200'
: 'bg-orange-50 border border-orange-200'
? 'bg-red-50 border border-red-200 dark:bg-red-900/30 dark:border-red-800'
: 'bg-orange-50 border border-orange-200 dark:bg-orange-900/30 dark:border-orange-800'
}`}
>
<div className="flex items-center gap-2">
<span className="text-lg">{isHoliday ? '🔴' : '🟠'}</span>
<span className="font-medium">{eventData.name}</span>
<span className="font-medium text-foreground">{eventData.name}</span>
</div>
<div className="text-xs text-muted-foreground mt-1">
{isHoliday ? '공휴일' : '세금 신고 마감일'}
@@ -369,36 +549,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"
className="p-3 border border-border rounded-lg hover:bg-muted/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 text-foreground 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"
className="p-3 border border-red-200 dark:border-red-800 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 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,15 +575,12 @@ export function CalendarSection({
>
{issue.badge}
</Badge>
<span className="font-medium text-sm flex-1">
{issue.content}
</span>
<span className="font-medium text-sm text-foreground flex-1">{issue.content}</span>
</div>
{/* 시간 + 상세보기 */}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{issue.time}</span>
{issue.path && (
<span className="flex items-center gap-1 text-blue-600 hover:underline">
<span className="flex items-center gap-1 text-blue-600 dark:text-blue-400 hover:underline">
<ExternalLink className="h-3 w-3" />
</span>
@@ -426,7 +592,6 @@ export function CalendarSection({
)}
</div>
</div>
</CardContent>
</Card>
</CollapsibleDashboardCard>
);
}
}

View File

@@ -2,8 +2,7 @@
import { useRouter } from 'next/navigation';
import { CreditCard, Wallet, Receipt, AlertTriangle } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { SectionTitle, AmountCardItem, CheckPointItem, type SectionColorTheme } from '../components';
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
import type { CardManagementData } from '../types';
// 카드별 아이콘 매핑
@@ -27,45 +26,40 @@ export function CardManagementSection({ data, onCardClick }: CardManagementSecti
};
return (
<Card>
<CardContent className="p-6">
<SectionTitle
title="카드/가지급금 관리"
badge="warning"
icon={CreditCard}
colorTheme="blue"
/>
<CollapsibleDashboardCard
icon={<CreditCard style={{ color: '#ffffff' }} className="h-5 w-5" />}
title="카드/가지급금 관리"
subtitle="카드가지급금 현황"
>
{data.warningBanner && (
<div className="bg-red-500 text-white text-sm font-medium px-4 py-2 rounded-lg mb-4 flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
{data.warningBanner}
</div>
)}
{data.warningBanner && (
<div className="bg-red-500 text-white text-sm font-medium px-4 py-2 rounded-lg mb-4 flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
{data.warningBanner}
</div>
)}
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4 mb-4">
{data.cards.map((card, idx) => (
<AmountCardItem
key={card.id}
card={card}
onClick={() => handleClick(card.id)}
icon={CARD_ICONS[idx] || CreditCard}
colorTheme={CARD_THEMES[idx] || 'blue'}
showTrend={!!card.previousLabel}
trendValue={card.previousLabel}
trendDirection="up"
/>
))}
</div>
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4 mb-4">
{data.cards.map((card, idx) => (
<AmountCardItem
key={card.id}
card={card}
onClick={() => handleClick(card.id)}
icon={CARD_ICONS[idx] || CreditCard}
colorTheme={CARD_THEMES[idx] || 'blue'}
showTrend={!!card.previousLabel}
trendValue={card.previousLabel}
trendDirection="up"
/>
{data.checkPoints.length > 0 && (
<div className="border-t pt-4 space-y-1">
{data.checkPoints.map((cp) => (
<CheckPointItem key={cp.id} checkpoint={cp} />
))}
</div>
{data.checkPoints.length > 0 && (
<div className="border-t pt-4 space-y-1">
{data.checkPoints.map((cp) => (
<CheckPointItem key={cp.id} checkpoint={cp} />
))}
</div>
)}
</CardContent>
</Card>
)}
</CollapsibleDashboardCard>
);
}
}

View File

@@ -6,6 +6,7 @@ import {
Building2,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { CollapsibleDashboardCard } from '../components';
import type { ConstructionData } from '../types';
interface ConstructionSectionProps {
@@ -14,75 +15,64 @@ interface ConstructionSectionProps {
export function ConstructionSection({ data }: ConstructionSectionProps) {
return (
<div className="rounded-xl border overflow-hidden">
{/* 다크 헤더 */}
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div style={{ backgroundColor: 'rgba(255,255,255,0.1)' }} className="p-2 rounded-lg">
<HardHat style={{ color: '#ffffff' }} className="h-5 w-5" />
</div>
<div>
<h3 style={{ color: '#ffffff' }} className="text-lg font-semibold"> </h3>
<p style={{ color: '#cbd5e1' }} className="text-sm"> </p>
</div>
</div>
<Badge
style={{ backgroundColor: '#3b82f6', color: '#ffffff', border: 'none' }}
className="hover:opacity-90"
>
{data.thisMonth}
</Badge>
</div>
</div>
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
<CollapsibleDashboardCard
icon={<HardHat style={{ color: '#ffffff' }} className="h-5 w-5" />}
title="시공 현황"
subtitle="이달 시공 진행 현황"
rightElement={
<Badge
className="bg-blue-500 text-white border-none hover:opacity-90"
>
{data.thisMonth}
</Badge>
}
>
{/* 시공 요약 카드 */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
<div className="rounded-lg border p-4" style={{ backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }}>
<div className="rounded-lg border p-4 bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-2 mb-2">
<HardHat className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium text-blue-700"> (7 )</span>
<span className="text-sm font-medium text-blue-700 dark:text-blue-300"> (7 )</span>
</div>
<span className="text-2xl font-bold text-gray-900">{data.thisMonth}</span>
<span className="text-2xl font-bold text-foreground">{data.thisMonth}</span>
</div>
<div className="rounded-lg border p-4" style={{ backgroundColor: '#f0fdf4', borderColor: '#bbf7d0' }}>
<div className="rounded-lg border p-4 bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-800">
<div className="flex items-center gap-2 mb-2">
<Building2 className="h-4 w-4 text-green-500" />
<span className="text-sm font-medium text-green-700"> (7 )</span>
<span className="text-sm font-medium text-green-700 dark:text-green-300"> (7 )</span>
</div>
<span className="text-2xl font-bold text-gray-900">{data.completed}</span>
<span className="text-2xl font-bold text-foreground">{data.completed}</span>
</div>
</div>
{/* 시공 상세 카드 목록 */}
<div className="space-y-3">
{data.items.map((item) => (
<div key={item.id} className="border rounded-lg p-4 hover:bg-gray-50 transition-colors">
<div key={item.id} className="border border-border rounded-lg p-4 hover:bg-muted/50 transition-colors">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-900">{item.siteName}</span>
<span className="text-sm font-semibold text-foreground">{item.siteName}</span>
<Badge
variant="outline"
className={
item.status === '진행중'
? 'text-blue-600 border-blue-200 bg-blue-50'
? 'text-blue-600 border-blue-200 bg-blue-50 dark:text-blue-400 dark:border-blue-800 dark:bg-blue-900/30'
: item.status === '예정'
? 'text-gray-600 border-gray-200 bg-gray-50'
: 'text-green-600 border-green-200 bg-green-50'
? 'text-muted-foreground border-border bg-muted/50'
: 'text-green-600 border-green-200 bg-green-50 dark:text-green-400 dark:border-green-800 dark:bg-green-900/30'
}
>
{item.status}
</Badge>
</div>
<span className="text-sm text-gray-500">{item.client}</span>
<span className="text-sm text-muted-foreground">{item.client}</span>
</div>
<div className="flex items-center gap-2 text-xs text-gray-500 mb-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
<Calendar className="h-3 w-3" />
<span>{item.startDate} ~ {item.endDate}</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full rounded-full"
style={{
@@ -91,12 +81,11 @@ export function ConstructionSection({ data }: ConstructionSectionProps) {
}}
/>
</div>
<span className="text-xs font-medium text-gray-600 min-w-[36px] text-right">{item.progress}%</span>
<span className="text-xs font-medium text-muted-foreground min-w-[36px] text-right">{item.progress}%</span>
</div>
</div>
))}
</div>
</div>
</div>
</CollapsibleDashboardCard>
);
}

View File

@@ -19,6 +19,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { CollapsibleDashboardCard } from '../components';
import type { DailyAttendanceData } from '../types';
interface DailyAttendanceSectionProps {
@@ -35,79 +36,69 @@ export function DailyAttendanceSection({ data }: DailyAttendanceSectionProps) {
const statusBadgeClass = (status: string) => {
switch (status) {
case '출근': return 'text-green-600 border-green-200 bg-green-50';
case '휴가': return 'text-blue-600 border-blue-200 bg-blue-50';
case '지각': return 'text-orange-600 border-orange-200 bg-orange-50';
case '결근': return 'text-red-600 border-red-200 bg-red-50';
default: return 'text-gray-600 border-gray-200 bg-gray-50';
case '출근': return 'text-green-600 border-green-200 bg-green-50 dark:text-green-400 dark:border-green-800 dark:bg-green-900/30';
case '휴가': return 'text-blue-600 border-blue-200 bg-blue-50 dark:text-blue-400 dark:border-blue-800 dark:bg-blue-900/30';
case '지각': return 'text-orange-600 border-orange-200 bg-orange-50 dark:text-orange-400 dark:border-orange-800 dark:bg-orange-900/30';
case '결근': return 'text-red-600 border-red-200 bg-red-50 dark:text-red-400 dark:border-red-800 dark:bg-red-900/30';
default: return 'text-muted-foreground border-border bg-muted/50';
}
};
return (
<div className="rounded-xl border overflow-hidden">
{/* 다크 헤더 */}
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div style={{ backgroundColor: 'rgba(255,255,255,0.1)' }} className="p-2 rounded-lg">
<Users style={{ color: '#ffffff' }} className="h-5 w-5" />
</div>
<div>
<h3 style={{ color: '#ffffff' }} className="text-lg font-semibold"> </h3>
<p style={{ color: '#cbd5e1' }} className="text-sm"> </p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => router.push('/ko/hr/attendance')}
className="text-white hover:bg-white/10 gap-1 text-xs"
>
<ChevronRight className="h-3 w-3" />
</Button>
</div>
</div>
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
<CollapsibleDashboardCard
icon={<Users style={{ color: '#ffffff' }} className="h-5 w-5" />}
title="당일 근태 현황"
subtitle="오늘의 출근 현황"
rightElement={
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); router.push('/ko/hr/attendance'); }}
className="text-white hover:bg-white/10 gap-1 text-xs"
>
<ChevronRight className="h-3 w-3" />
</Button>
}
>
{/* 요약 카드 4개 */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<div className="rounded-lg border p-3 text-center" style={{ backgroundColor: '#f0fdf4', borderColor: '#bbf7d0' }}>
<div className="rounded-lg border p-3 text-center bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-800">
<div className="flex items-center justify-center gap-1 mb-1">
<UserCheck className="h-3.5 w-3.5 text-green-500" />
<span className="text-xs font-medium text-green-600"></span>
<span className="text-xs font-medium text-green-600 dark:text-green-400"></span>
</div>
<span className="text-xl font-bold text-gray-900">{data.present}</span>
<span className="text-xl font-bold text-foreground">{data.present}</span>
</div>
<div className="rounded-lg border p-3 text-center" style={{ backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }}>
<div className="rounded-lg border p-3 text-center bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800">
<div className="flex items-center justify-center gap-1 mb-1">
<Palmtree className="h-3.5 w-3.5 text-blue-500" />
<span className="text-xs font-medium text-blue-600"></span>
<span className="text-xs font-medium text-blue-600 dark:text-blue-400"></span>
</div>
<span className="text-xl font-bold text-gray-900">{data.onLeave}</span>
<span className="text-xl font-bold text-foreground">{data.onLeave}</span>
</div>
<div className="rounded-lg border p-3 text-center" style={{ backgroundColor: '#fff7ed', borderColor: '#fed7aa' }}>
<div className="rounded-lg border p-3 text-center bg-orange-50 dark:bg-orange-900/30 border-orange-200 dark:border-orange-800">
<div className="flex items-center justify-center gap-1 mb-1">
<Clock className="h-3.5 w-3.5 text-orange-500" />
<span className="text-xs font-medium text-orange-600"></span>
<span className="text-xs font-medium text-orange-600 dark:text-orange-400"></span>
</div>
<span className="text-xl font-bold text-gray-900">{data.late}</span>
<span className="text-xl font-bold text-foreground">{data.late}</span>
</div>
<div className="rounded-lg border p-3 text-center" style={{ backgroundColor: '#fef2f2', borderColor: '#fecaca' }}>
<div className="rounded-lg border p-3 text-center bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-800">
<div className="flex items-center justify-center gap-1 mb-1">
<UserX className="h-3.5 w-3.5 text-red-500" />
<span className="text-xs font-medium text-red-600"></span>
<span className="text-xs font-medium text-red-600 dark:text-red-400"></span>
</div>
<span className="text-xl font-bold text-gray-900">{data.absent}</span>
<span className="text-xl font-bold text-foreground">{data.absent}</span>
</div>
</div>
{/* 테이블 */}
<div className="border rounded-lg overflow-hidden">
<div className="flex items-center justify-between p-4 bg-gray-50 border-b">
<h4 className="text-sm font-semibold text-gray-700"> </h4>
<div className="border border-border rounded-lg overflow-hidden">
<div className="p-3 bg-muted/50 border-b border-border space-y-2">
<h4 className="text-sm font-semibold text-foreground"> </h4>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[100px] h-8 text-xs">
<SelectTrigger className="w-full h-8 text-xs">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
@@ -120,23 +111,23 @@ 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>
<th className="px-4 py-2 text-left text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-left text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-left text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-center text-gray-600 font-medium"></th>
<tr className="bg-muted/50 border-b border-border">
<th className="px-4 py-2 text-center text-muted-foreground font-medium w-12">No</th>
<th className="px-4 py-2 text-left text-muted-foreground font-medium"></th>
<th className="px-4 py-2 text-left text-muted-foreground font-medium"></th>
<th className="px-4 py-2 text-left text-muted-foreground font-medium"></th>
<th className="px-4 py-2 text-center text-muted-foreground font-medium"></th>
</tr>
</thead>
<tbody>
{filteredEmployees.map((emp, idx) => (
<tr key={emp.id} className="border-b last:border-b-0 hover:bg-gray-50">
<td className="px-4 py-2 text-center text-gray-500">{idx + 1}</td>
<td className="px-4 py-2 text-gray-700">{emp.department}</td>
<td className="px-4 py-2 text-gray-700">{emp.position}</td>
<td className="px-4 py-2 text-gray-900 font-medium">{emp.name}</td>
<tr key={emp.id} className="border-b border-border last:border-b-0 hover:bg-muted/50">
<td className="px-4 py-2 text-center text-muted-foreground">{idx + 1}</td>
<td className="px-4 py-2 text-muted-foreground">{emp.department}</td>
<td className="px-4 py-2 text-muted-foreground">{emp.position}</td>
<td className="px-4 py-2 text-foreground font-medium">{emp.name}</td>
<td className="px-4 py-2 text-center">
<Badge variant="outline" className={statusBadgeClass(emp.status)}>
{emp.status}
@@ -148,7 +139,6 @@ export function DailyAttendanceSection({ data }: DailyAttendanceSectionProps) {
</table>
</div>
</div>
</div>
</div>
</CollapsibleDashboardCard>
);
}

View File

@@ -17,6 +17,7 @@ import {
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { formatKoreanAmount } from '@/lib/utils/amount';
import { CollapsibleDashboardCard } from '../components';
import type { DailyProductionData } from '../types';
// 출고 현황 독립 섹션
@@ -26,41 +27,30 @@ interface ShipmentSectionProps {
export function ShipmentSection({ data }: ShipmentSectionProps) {
return (
<div className="rounded-xl border overflow-hidden">
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div style={{ backgroundColor: 'rgba(255,255,255,0.1)' }} className="p-2 rounded-lg">
<Truck style={{ color: '#ffffff' }} className="h-5 w-5" />
</div>
<div>
<h3 style={{ color: '#ffffff' }} className="text-lg font-semibold"> </h3>
<p style={{ color: '#cbd5e1' }} className="text-sm"> </p>
</div>
</div>
</div>
</div>
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
<CollapsibleDashboardCard
icon={<Truck style={{ color: '#ffffff' }} className="h-5 w-5" />}
title="출고 현황"
subtitle="예상 출고 정보"
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="rounded-xl border p-4" style={{ backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }}>
<div className="rounded-xl border p-4 bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-2 mb-3">
<Package className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium text-gray-700"> (7 )</span>
<span className="text-sm font-medium text-muted-foreground"> (7 )</span>
</div>
<p className="text-xl font-bold text-gray-900 mb-1">{formatKoreanAmount(data.shipment.expectedAmount)}</p>
<p className="text-xs text-gray-500">{data.shipment.expectedCount}</p>
<p className="text-xl font-bold text-foreground mb-1">{formatKoreanAmount(data.shipment.expectedAmount)}</p>
<p className="text-xs text-muted-foreground">{data.shipment.expectedCount}</p>
</div>
<div className="rounded-xl border p-4" style={{ backgroundColor: '#f0fdf4', borderColor: '#bbf7d0' }}>
<div className="rounded-xl border p-4 bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-800">
<div className="flex items-center gap-2 mb-3">
<Truck className="h-4 w-4 text-green-500" />
<span className="text-sm font-medium text-gray-700"> (30 )</span>
<span className="text-sm font-medium text-muted-foreground"> (30 )</span>
</div>
<p className="text-xl font-bold text-gray-900 mb-1">{formatKoreanAmount(data.shipment.actualAmount)}</p>
<p className="text-xs text-gray-500">{data.shipment.actualCount}</p>
<p className="text-xl font-bold text-foreground mb-1">{formatKoreanAmount(data.shipment.actualAmount)}</p>
<p className="text-xs text-muted-foreground">{data.shipment.actualCount}</p>
</div>
</div>
</div>
</div>
</CollapsibleDashboardCard>
);
}
@@ -80,29 +70,18 @@ export function DailyProductionSection({ data, showShipment = true }: DailyProdu
return (
<div className="space-y-6">
<div className="rounded-xl border overflow-hidden">
{/* 다크 헤더 */}
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div style={{ backgroundColor: 'rgba(255,255,255,0.1)' }} className="p-2 rounded-lg">
<Factory style={{ color: '#ffffff' }} className="h-5 w-5" />
</div>
<div>
<h3 style={{ color: '#ffffff' }} className="text-lg font-semibold"> </h3>
<p style={{ color: '#cbd5e1' }} className="text-sm">{data.date}</p>
</div>
</div>
<Badge
style={{ backgroundColor: '#8b5cf6', color: '#ffffff', border: 'none' }}
className="hover:opacity-90"
>
</Badge>
</div>
</div>
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
<CollapsibleDashboardCard
icon={<Factory style={{ color: '#ffffff' }} className="h-5 w-5" />}
title="당일 생산 현황"
subtitle={data.date}
rightElement={
<Badge
className="bg-violet-500 text-white border-none hover:opacity-90"
>
</Badge>
}
>
{/* 공정 탭 */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="mb-4">
@@ -116,133 +95,133 @@ export function DailyProductionSection({ data, showShipment = true }: DailyProdu
{data.processes.map((process) => (
<TabsContent key={process.processName} value={process.processName}>
{/* 요약 카드: 전체 작업 / 할일 / 작업중 / 완료 */}
<div className="grid grid-cols-4 gap-3 mb-4">
<div className="flex items-center gap-2 rounded-lg border p-3">
<ClipboardList className="h-4 w-4 text-gray-500" />
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<div className="flex items-center gap-2 rounded-lg border border-border p-3">
<ClipboardList className="h-4 w-4 text-muted-foreground shrink-0" />
<div>
<p className="text-[11px] text-gray-500"> </p>
<p className="text-sm font-bold text-gray-900">{process.totalWork}</p>
<p className="text-[11px] text-muted-foreground"> </p>
<p className="text-sm font-bold text-foreground">{process.totalWork}</p>
</div>
</div>
<div className="flex items-center gap-2 rounded-lg border p-3">
<ListTodo className="h-4 w-4 text-orange-500" />
<div className="flex items-center gap-2 rounded-lg border border-border p-3">
<ListTodo className="h-4 w-4 text-orange-500 shrink-0" />
<div>
<p className="text-[11px] text-gray-500"></p>
<p className="text-sm font-bold text-gray-900">{process.todo}</p>
<p className="text-[11px] text-muted-foreground"></p>
<p className="text-sm font-bold text-foreground">{process.todo}</p>
</div>
</div>
<div className="flex items-center gap-2 rounded-lg border p-3">
<Play className="h-4 w-4 text-blue-500" />
<div className="flex items-center gap-2 rounded-lg border border-border p-3">
<Play className="h-4 w-4 text-blue-500 shrink-0" />
<div>
<p className="text-[11px] text-gray-500"></p>
<p className="text-sm font-bold text-gray-900">{process.inProgress}</p>
<p className="text-[11px] text-muted-foreground"></p>
<p className="text-sm font-bold text-foreground">{process.inProgress}</p>
</div>
</div>
<div className="flex items-center gap-2 rounded-lg border p-3">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<div className="flex items-center gap-2 rounded-lg border border-border p-3">
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
<div>
<p className="text-[11px] text-gray-500"></p>
<p className="text-sm font-bold text-gray-900">{process.completed}</p>
<p className="text-[11px] text-muted-foreground"></p>
<p className="text-sm font-bold text-foreground">{process.completed}</p>
</div>
</div>
</div>
{/* 4열 카드 레이아웃: 긴급 / 우선 / 일반 / 작업자 현황 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{/* 4열 카드 레이아웃: 긴급 / 우선 / 일반 / 작업자 현황 - 모바일 가로스크롤 */}
<div className="-mx-6 px-6 flex gap-4 overflow-x-auto pb-2 lg:mx-0 lg:px-0 lg:grid lg:grid-cols-4 lg:overflow-visible lg:pb-0">
{/* 긴급 */}
<div className="border rounded-lg overflow-hidden">
<div className="p-3 border-b" style={{ backgroundColor: '#fef2f2' }}>
<div className="min-w-[200px] shrink-0 lg:min-w-0 lg:shrink border border-border rounded-lg overflow-hidden">
<div className="p-3 border-b border-border bg-red-50 dark:bg-red-900/30">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 text-red-500" />
<span className="text-xs font-semibold text-red-600"></span>
<span className="text-xs font-semibold text-red-600 dark:text-red-400"></span>
</div>
<span className="text-sm font-bold text-gray-900">{process.urgent}</span>
<span className="text-sm font-bold text-foreground">{process.urgent}</span>
</div>
</div>
<div className="p-2 space-y-2">
<div className="p-2 space-y-2 max-h-[240px] overflow-y-auto">
{process.workItems
.filter((item) => item.status === '진행중')
.slice(0, process.urgent)
.map((item) => (
<div key={item.id} className="border rounded p-2 hover:bg-gray-50 cursor-pointer transition-colors">
<p className="text-xs font-medium text-gray-900 truncate">{item.client}</p>
<p className="text-[11px] text-gray-500">{item.product}</p>
<p className="text-[11px] text-gray-400">{item.quantity}</p>
<div key={item.id} className="border border-border rounded p-2 hover:bg-muted/50 cursor-pointer transition-colors">
<p className="text-xs font-medium text-foreground truncate">{item.client}</p>
<p className="text-[11px] text-muted-foreground">{item.product}</p>
<p className="text-[11px] text-muted-foreground">{item.quantity}</p>
</div>
))}
</div>
</div>
{/* 우선 */}
<div className="border rounded-lg overflow-hidden">
<div className="p-3 border-b" style={{ backgroundColor: '#fff7ed' }}>
<div className="min-w-[200px] shrink-0 lg:min-w-0 lg:shrink border border-border rounded-lg overflow-hidden">
<div className="p-3 border-b border-border bg-orange-50 dark:bg-orange-900/30">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Star className="h-3.5 w-3.5 text-orange-500" />
<span className="text-xs font-semibold text-orange-600"></span>
<span className="text-xs font-semibold text-orange-600 dark:text-orange-400"></span>
</div>
<span className="text-sm font-bold text-gray-900">{process.subLine}</span>
<span className="text-sm font-bold text-foreground">{process.subLine}</span>
</div>
</div>
<div className="p-2 space-y-2">
<div className="p-2 space-y-2 max-h-[240px] overflow-y-auto">
{process.workItems
.filter((item) => item.status === '대기')
.slice(0, process.subLine)
.map((item) => (
<div key={item.id} className="border rounded p-2 hover:bg-gray-50 cursor-pointer transition-colors">
<p className="text-xs font-medium text-gray-900 truncate">{item.client}</p>
<p className="text-[11px] text-gray-500">{item.product}</p>
<p className="text-[11px] text-gray-400">{item.quantity}</p>
<div key={item.id} className="border border-border rounded p-2 hover:bg-muted/50 cursor-pointer transition-colors">
<p className="text-xs font-medium text-foreground truncate">{item.client}</p>
<p className="text-[11px] text-muted-foreground">{item.product}</p>
<p className="text-[11px] text-muted-foreground">{item.quantity}</p>
</div>
))}
</div>
</div>
{/* 일반 */}
<div className="border rounded-lg overflow-hidden">
<div className="p-3 border-b" style={{ backgroundColor: '#eff6ff' }}>
<div className="min-w-[200px] shrink-0 lg:min-w-0 lg:shrink border border-border rounded-lg overflow-hidden">
<div className="p-3 border-b border-border bg-blue-50 dark:bg-blue-900/30">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5 text-blue-500" />
<span className="text-xs font-semibold text-blue-600"></span>
<span className="text-xs font-semibold text-blue-600 dark:text-blue-400"></span>
</div>
<span className="text-sm font-bold text-gray-900">{process.regular}</span>
<span className="text-sm font-bold text-foreground">{process.regular}</span>
</div>
</div>
<div className="p-2 space-y-2">
<div className="p-2 space-y-2 max-h-[240px] overflow-y-auto">
{process.workItems
.slice(0, process.regular)
.map((item) => (
<div key={item.id} className="border rounded p-2 hover:bg-gray-50 cursor-pointer transition-colors">
<p className="text-xs font-medium text-gray-900 truncate">{item.client}</p>
<p className="text-[11px] text-gray-500">{item.product}</p>
<p className="text-[11px] text-gray-400">{item.quantity}</p>
<div key={item.id} className="border border-border rounded p-2 hover:bg-muted/50 cursor-pointer transition-colors">
<p className="text-xs font-medium text-foreground truncate">{item.client}</p>
<p className="text-[11px] text-muted-foreground">{item.product}</p>
<p className="text-[11px] text-muted-foreground">{item.quantity}</p>
</div>
))}
</div>
</div>
{/* 작업자 현황 */}
<div className="border rounded-lg overflow-hidden">
<div className="p-3 border-b" style={{ backgroundColor: '#f0fdf4' }}>
<div className="min-w-[200px] shrink-0 lg:min-w-0 lg:shrink border border-border rounded-lg overflow-hidden">
<div className="p-3 border-b border-border bg-green-50 dark:bg-green-900/30">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Users className="h-3.5 w-3.5 text-green-500" />
<span className="text-xs font-semibold text-green-600"> </span>
<span className="text-xs font-semibold text-green-600 dark:text-green-400"> </span>
</div>
<span className="text-sm font-bold text-gray-900">{process.workerCount}</span>
<span className="text-sm font-bold text-foreground">{process.workerCount}</span>
</div>
</div>
<div className="p-2 space-y-2">
<div className="p-2 space-y-2 max-h-[240px] overflow-y-auto">
{process.workers.map((worker, idx) => (
<div key={idx} className="border rounded p-2">
<div key={idx} className="border border-border rounded p-2">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium text-gray-900">{worker.name}</span>
<span className="text-[11px] text-gray-500">{worker.completed}/{worker.assigned}</span>
<span className="text-xs font-medium text-foreground">{worker.name}</span>
<span className="text-[11px] text-muted-foreground">{worker.completed}/{worker.assigned}</span>
</div>
<div className="flex items-center gap-1.5">
<div className="flex-1 h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full rounded-full"
style={{
@@ -251,7 +230,7 @@ export function DailyProductionSection({ data, showShipment = true }: DailyProdu
}}
/>
</div>
<span className="text-[10px] text-gray-500 min-w-[28px] text-right">{worker.rate}%</span>
<span className="text-[10px] text-muted-foreground min-w-[28px] text-right">{worker.rate}%</span>
</div>
</div>
))}
@@ -262,46 +241,34 @@ export function DailyProductionSection({ data, showShipment = true }: DailyProdu
))}
</Tabs>
</div>
</div>
</CollapsibleDashboardCard>
{/* 출고 현황 (별도 카드) */}
{showShipment && (
<div className="rounded-xl border overflow-hidden">
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div style={{ backgroundColor: 'rgba(255,255,255,0.1)' }} className="p-2 rounded-lg">
<Truck style={{ color: '#ffffff' }} className="h-5 w-5" />
</div>
<div>
<h3 style={{ color: '#ffffff' }} className="text-lg font-semibold"> </h3>
<p style={{ color: '#cbd5e1' }} className="text-sm"> </p>
</div>
</div>
</div>
</div>
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
<CollapsibleDashboardCard
icon={<Truck style={{ color: '#ffffff' }} className="h-5 w-5" />}
title="출고 현황"
subtitle="예상 출고 정보"
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="rounded-xl border p-4" style={{ backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }}>
<div className="rounded-xl border p-4 bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-2 mb-3">
<Package className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium text-gray-700"> (7 )</span>
<span className="text-sm font-medium text-muted-foreground"> (7 )</span>
</div>
<p className="text-xl font-bold text-gray-900 mb-1">{formatKoreanAmount(data.shipment.expectedAmount)}</p>
<p className="text-xs text-gray-500">{data.shipment.expectedCount}</p>
<p className="text-xl font-bold text-foreground mb-1">{formatKoreanAmount(data.shipment.expectedAmount)}</p>
<p className="text-xs text-muted-foreground">{data.shipment.expectedCount}</p>
</div>
<div className="rounded-xl border p-4" style={{ backgroundColor: '#f0fdf4', borderColor: '#bbf7d0' }}>
<div className="rounded-xl border p-4 bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-800">
<div className="flex items-center gap-2 mb-3">
<Truck className="h-4 w-4 text-green-500" />
<span className="text-sm font-medium text-gray-700"> (30 )</span>
<span className="text-sm font-medium text-muted-foreground"> (30 )</span>
</div>
<p className="text-xl font-bold text-gray-900 mb-1">{formatKoreanAmount(data.shipment.actualAmount)}</p>
<p className="text-xs text-gray-500">{data.shipment.actualCount}</p>
<p className="text-xl font-bold text-foreground mb-1">{formatKoreanAmount(data.shipment.actualAmount)}</p>
<p className="text-xs text-muted-foreground">{data.shipment.actualCount}</p>
</div>
</div>
</div>
</div>
</CollapsibleDashboardCard>
)}
</div>
);

View File

@@ -1,7 +1,7 @@
'use client';
import { Card, CardContent } from '@/components/ui/card';
import { SectionTitle, AmountCardItem, CheckPointItem } from '../components';
import { FileText } from 'lucide-react';
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard } from '../components';
import type { DailyReportData } from '../types';
interface DailyReportSectionProps {
@@ -11,27 +11,24 @@ interface DailyReportSectionProps {
export function DailyReportSection({ data, onClick }: DailyReportSectionProps) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<SectionTitle title="일일 일보" badge="info" />
<span className="text-sm text-muted-foreground">{data.date}</span>
</div>
<CollapsibleDashboardCard
icon={<FileText style={{ color: '#ffffff' }} className="h-5 w-5" />}
title="일일 일보"
subtitle={data.date}
>
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4 mb-4">
{data.cards.map((card) => (
<AmountCardItem key={card.id} card={card} onClick={onClick} />
))}
</div>
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4 mb-4">
{data.cards.map((card) => (
<AmountCardItem key={card.id} card={card} onClick={onClick} />
{data.checkPoints.length > 0 && (
<div className="border-t pt-4 space-y-1">
{data.checkPoints.map((cp) => (
<CheckPointItem key={cp.id} checkpoint={cp} />
))}
</div>
{data.checkPoints.length > 0 && (
<div className="border-t pt-4 space-y-1">
{data.checkPoints.map((cp) => (
<CheckPointItem key={cp.id} checkpoint={cp} />
))}
</div>
)}
</CardContent>
</Card>
)}
</CollapsibleDashboardCard>
);
}
}

View File

@@ -2,8 +2,7 @@
import { useRouter } from 'next/navigation';
import { Gavel, FileWarning, AlertCircle, Scale } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { SectionTitle, AmountCardItem, CheckPointItem, type SectionColorTheme } from '../components';
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
import type { DebtCollectionData } from '../types';
// 카드별 아이콘 매핑
@@ -24,39 +23,34 @@ export function DebtCollectionSection({ data }: DebtCollectionSectionProps) {
};
return (
<Card>
<CardContent className="p-6">
<SectionTitle
title="채권추심 현황"
badge="error"
icon={Gavel}
colorTheme="red"
/>
<CollapsibleDashboardCard
icon={<Gavel style={{ color: '#ffffff' }} className="h-5 w-5" />}
title="채권추심 현황"
subtitle="채권추심 관리 현황"
>
<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}
card={card}
onClick={data.detailButtonPath ? handleClick : undefined}
icon={CARD_ICONS[idx] || Gavel}
colorTheme={CARD_THEMES[idx] || 'red'}
showTrend={!!card.previousLabel}
trendValue={card.previousLabel}
trendDirection="down"
subLabelAsBadge
/>
))}
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
{data.cards.map((card, idx) => (
<AmountCardItem
key={card.id}
card={card}
onClick={data.detailButtonPath ? handleClick : undefined}
icon={CARD_ICONS[idx] || Gavel}
colorTheme={CARD_THEMES[idx] || 'red'}
showTrend={!!card.previousLabel}
trendValue={card.previousLabel}
trendDirection="down"
subLabelAsBadge
/>
{data.checkPoints.length > 0 && (
<div className="border-t pt-4 space-y-1">
{data.checkPoints.map((cp) => (
<CheckPointItem key={cp.id} checkpoint={cp} />
))}
</div>
{data.checkPoints.length > 0 && (
<div className="border-t pt-4 space-y-1">
{data.checkPoints.map((cp) => (
<CheckPointItem key={cp.id} checkpoint={cp} />
))}
</div>
)}
</CardContent>
</Card>
)}
</CollapsibleDashboardCard>
);
}
}

View File

@@ -21,11 +21,12 @@ import {
ArrowDownRight,
Banknote,
CircleDollarSign,
LayoutGrid,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { formatKoreanAmount } from '@/lib/utils/amount';
import { CollapsibleDashboardCard } from '../components';
import type { DailyReportData, MonthlyExpenseData, TodayIssueItem, TodayIssueSettings } from '../types';
// ============================================================
@@ -46,10 +47,10 @@ interface EnhancedDailyReportSectionProps {
}
const CARD_STYLES = [
{ bg: '#ecfdf5', border: '#a7f3d0', iconBg: '#10b981', labelColor: '#047857', Icon: FileText },
{ bg: '#eff6ff', border: '#bfdbfe', iconBg: '#3b82f6', labelColor: '#1d4ed8', Icon: Receipt },
{ bg: '#f5f3ff', border: '#ddd6fe', iconBg: '#8b5cf6', labelColor: '#6d28d9', Icon: Briefcase },
{ bg: '#fff7ed', border: '#fed7aa', iconBg: '#f97316', labelColor: '#c2410c', Icon: Clock },
{ bgClass: 'bg-emerald-50 dark:bg-emerald-900/30', borderClass: 'border-emerald-200 dark:border-emerald-800', iconBg: '#10b981', labelClass: 'text-emerald-700 dark:text-emerald-300', Icon: FileText },
{ bgClass: 'bg-blue-50 dark:bg-blue-900/30', borderClass: 'border-blue-200 dark:border-blue-800', iconBg: '#3b82f6', labelClass: 'text-blue-700 dark:text-blue-300', Icon: Receipt },
{ bgClass: 'bg-purple-50 dark:bg-purple-900/30', borderClass: 'border-purple-200 dark:border-purple-800', iconBg: '#8b5cf6', labelClass: 'text-purple-700 dark:text-purple-300', Icon: Briefcase },
{ bgClass: 'bg-orange-50 dark:bg-orange-900/30', borderClass: 'border-orange-200 dark:border-orange-800', iconBg: '#f97316', labelClass: 'text-orange-700 dark:text-orange-300', Icon: Clock },
];
export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyReportSectionProps) {
@@ -64,57 +65,39 @@ export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyRepor
};
return (
<div className="rounded-xl border overflow-hidden">
{/* 다크 헤더 */}
<div
style={{ backgroundColor: '#1e293b' }}
className="px-6 py-4"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
style={{ backgroundColor: 'rgba(255,255,255,0.1)' }}
className="p-2 rounded-lg"
>
<Wallet style={{ color: '#ffffff' }} className="h-5 w-5" />
</div>
<div>
<h3 style={{ color: '#ffffff' }} className="text-lg font-semibold"></h3>
<p style={{ color: '#cbd5e1' }} className="text-sm">{data.date}</p>
</div>
</div>
<Badge
style={{ backgroundColor: '#10b981', color: '#ffffff', border: 'none' }}
className="hover:opacity-90"
>
</Badge>
</div>
</div>
{/* 카드 내용 */}
<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">
<CollapsibleDashboardCard
icon={<Wallet style={{ color: '#ffffff' }} className="h-5 w-5" />}
title="자금현황"
subtitle={data.date}
rightElement={
<Badge
style={{ backgroundColor: '#10b981', color: '#ffffff', border: 'none' }}
className="hover:opacity-90"
>
</Badge>
}
>
<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;
return (
<div
key={card.id}
style={{ backgroundColor: style.bg, borderColor: style.border }}
className="rounded-xl p-4 cursor-pointer transition-all hover:shadow-lg border min-h-[110px] flex flex-col"
className={`rounded-xl p-4 cursor-pointer transition-all hover:shadow-lg border min-h-[110px] flex flex-col ${style.bgClass} ${style.borderClass}`}
onClick={() => handleCardClick(card)}
>
<div className="flex items-center gap-2 mb-3">
<div style={{ backgroundColor: style.iconBg }} className="p-1.5 rounded-lg">
<CardIcon style={{ color: '#ffffff' }} className="h-4 w-4" />
<CardIcon className="h-4 w-4 text-white" />
</div>
<span style={{ color: style.labelColor }} className="text-sm font-medium">
<span className={`text-sm font-medium ${style.labelClass}`}>
{card.label}
</span>
</div>
<div className="flex items-end gap-2">
<span style={{ color: '#0f172a' }} className="text-2xl font-bold">
<span className="text-2xl font-bold text-foreground">
{card.displayValue
? card.displayValue
: card.currency === 'USD'
@@ -123,8 +106,7 @@ export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyRepor
</span>
{card.changeRate && (
<span
style={{ color: card.changeDirection === 'up' ? '#ef4444' : '#3b82f6' }}
className="flex items-center text-xs font-medium mb-1"
className={`flex items-center text-xs font-medium mb-1 ${card.changeDirection === 'up' ? 'text-red-500 dark:text-red-400' : 'text-blue-500 dark:text-blue-400'}`}
>
{card.changeDirection === 'up'
? <ArrowUpRight className="h-3 w-3" />
@@ -141,33 +123,27 @@ export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyRepor
{/* 체크포인트 */}
{data.checkPoints.length > 0 && (
<div className="space-y-2">
<p style={{ color: '#64748b' }} className="text-xs font-medium uppercase tracking-wider mb-3"> </p>
<p className="text-xs font-medium uppercase tracking-wider mb-3 text-muted-foreground"> </p>
{data.checkPoints.map((cp, idx) => (
<div
key={cp.id}
style={{
backgroundColor: idx === 0 ? '#fffbeb' : '#f8fafc',
borderColor: idx === 0 ? '#fde68a' : '#e2e8f0'
}}
className="flex items-start gap-3 p-3 rounded-lg border"
className={`flex items-start gap-3 p-3 rounded-lg border ${idx === 0 ? 'bg-amber-50 border-amber-200 dark:bg-amber-900/30 dark:border-amber-800' : 'bg-slate-50 border-slate-200 dark:bg-slate-800 dark:border-slate-700'}`}
>
<div
style={{ backgroundColor: idx === 0 ? '#fef3c7' : '#f1f5f9' }}
className="p-1 rounded-full shrink-0"
className={`p-1 rounded-full shrink-0 ${idx === 0 ? 'bg-amber-100 dark:bg-amber-800' : 'bg-slate-100 dark:bg-slate-700'}`}
>
{idx === 0 ? (
<AlertTriangle style={{ color: '#d97706' }} className="h-4 w-4" />
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
) : (
<CheckCircle2 style={{ color: '#16a34a' }} className="h-4 w-4" />
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />
)}
</div>
<p style={{ color: '#475569' }} className="text-sm flex-1">{cp.message}</p>
<p className="text-sm flex-1 text-muted-foreground">{cp.message}</p>
</div>
))}
</div>
)}
</div>
</div>
</CollapsibleDashboardCard>
);
}
@@ -189,21 +165,21 @@ const LABEL_TO_SETTING_KEY: Record<string, keyof TodayIssueSettings> = {
'결재 요청': 'approvalRequest',
};
// 라벨별 스타일 매핑 (인라인 스타일용)
const ITEM_STYLES: Record<string, { bg: string; border: string; iconBg: string; labelColor: string; Icon: React.ComponentType<{ style?: React.CSSProperties; className?: string }> }> = {
'수주': { bg: '#eff6ff', border: '#bfdbfe', iconBg: '#3b82f6', labelColor: '#1d4ed8', Icon: ShoppingCart },
'채권 추심': { bg: '#fef2f2', border: '#fecaca', iconBg: '#ef4444', labelColor: '#dc2626', Icon: AlertCircle },
'안전 재고': { bg: '#fff7ed', border: '#fed7aa', iconBg: '#f97316', labelColor: '#ea580c', Icon: Receipt },
'세금 신고': { bg: '#faf5ff', border: '#e9d5ff', iconBg: '#a855f7', labelColor: '#9333ea', Icon: FileText },
'신규 업체 등록': { bg: '#ecfdf5', border: '#a7f3d0', iconBg: '#10b981', labelColor: '#059669', Icon: Building2 },
'연차': { bg: '#ecfeff', border: '#a5f3fc', iconBg: '#06b6d4', labelColor: '#0891b2', Icon: Calendar },
'지각': { bg: '#fffbeb', border: '#fde68a', iconBg: '#f59e0b', labelColor: '#d97706', Icon: Clock },
'결근': { bg: '#fff1f2', border: '#fecdd3', iconBg: '#f43f5e', labelColor: '#e11d48', Icon: Users },
'발주': { bg: '#eef2ff', border: '#c7d2fe', iconBg: '#6366f1', labelColor: '#4f46e5', Icon: Briefcase },
'결재 요청': { bg: '#fdf2f8', border: '#fbcfe8', iconBg: '#ec4899', labelColor: '#db2777', Icon: CheckCircle2 },
// 라벨별 스타일 매핑 (Tailwind 클래스 기반 - 다크모드 지원)
const ITEM_STYLES: Record<string, { bgClass: string; borderClass: string; iconBg: string; labelClass: string; Icon: React.ComponentType<{ style?: React.CSSProperties; className?: string }> }> = {
'수주': { bgClass: 'bg-blue-50 dark:bg-blue-900/30', borderClass: 'border-blue-200 dark:border-blue-800', iconBg: '#3b82f6', labelClass: 'text-blue-700 dark:text-blue-300', Icon: ShoppingCart },
'채권 추심': { bgClass: 'bg-red-50 dark:bg-red-900/30', borderClass: 'border-red-200 dark:border-red-800', iconBg: '#ef4444', labelClass: 'text-red-700 dark:text-red-300', Icon: AlertCircle },
'안전 재고': { bgClass: 'bg-orange-50 dark:bg-orange-900/30', borderClass: 'border-orange-200 dark:border-orange-800', iconBg: '#f97316', labelClass: 'text-orange-700 dark:text-orange-300', Icon: Receipt },
'세금 신고': { bgClass: 'bg-purple-50 dark:bg-purple-900/30', borderClass: 'border-purple-200 dark:border-purple-800', iconBg: '#a855f7', labelClass: 'text-purple-700 dark:text-purple-300', Icon: FileText },
'신규 업체 등록': { bgClass: 'bg-emerald-50 dark:bg-emerald-900/30', borderClass: 'border-emerald-200 dark:border-emerald-800', iconBg: '#10b981', labelClass: 'text-emerald-700 dark:text-emerald-300', Icon: Building2 },
'연차': { bgClass: 'bg-cyan-50 dark:bg-cyan-900/30', borderClass: 'border-cyan-200 dark:border-cyan-800', iconBg: '#06b6d4', labelClass: 'text-cyan-700 dark:text-cyan-300', Icon: Calendar },
'지각': { bgClass: 'bg-amber-50 dark:bg-amber-900/30', borderClass: 'border-amber-200 dark:border-amber-800', iconBg: '#f59e0b', labelClass: 'text-amber-700 dark:text-amber-300', Icon: Clock },
'결근': { bgClass: 'bg-rose-50 dark:bg-rose-900/30', borderClass: 'border-rose-200 dark:border-rose-800', iconBg: '#f43f5e', labelClass: 'text-rose-700 dark:text-rose-300', Icon: Users },
'발주': { bgClass: 'bg-indigo-50 dark:bg-indigo-900/30', borderClass: 'border-indigo-200 dark:border-indigo-800', iconBg: '#6366f1', labelClass: 'text-indigo-700 dark:text-indigo-300', Icon: Briefcase },
'결재 요청': { bgClass: 'bg-pink-50 dark:bg-pink-900/30', borderClass: 'border-pink-200 dark:border-pink-800', iconBg: '#ec4899', labelClass: 'text-pink-700 dark:text-pink-300', Icon: CheckCircle2 },
};
const DEFAULT_STYLE = { bg: '#f8fafc', border: '#e2e8f0', iconBg: '#64748b', labelColor: '#475569', Icon: FileText };
const DEFAULT_STYLE = { bgClass: 'bg-slate-50 dark:bg-slate-800', borderClass: 'border-slate-200 dark:border-slate-700', iconBg: '#64748b', labelClass: 'text-slate-600 dark:text-slate-400', Icon: FileText };
interface EnhancedStatusBoardSectionProps {
items: TodayIssueItem[];
@@ -226,64 +202,57 @@ export function EnhancedStatusBoardSection({ items, itemSettings }: EnhancedStat
: items;
return (
<Card>
<CardContent className="p-6">
{/* 헤더 */}
<div className="flex items-center gap-3 mb-6">
<div style={{ backgroundColor: '#f59e0b' }} className="w-1.5 h-6 rounded-full" />
<h3 style={{ color: '#0f172a' }} className="text-lg font-semibold"></h3>
<Badge
style={{ backgroundColor: '#fef3c7', color: '#b45309', borderColor: '#fde68a' }}
>
{filteredItems.length}
</Badge>
</div>
<CollapsibleDashboardCard
icon={<LayoutGrid style={{ color: '#ffffff' }} className="h-5 w-5" />}
title="현황판"
subtitle="주요 현황 요약"
rightElement={
<Badge
style={{ backgroundColor: '#f59e0b', color: '#ffffff', border: 'none' }}
>
{filteredItems.length}
</Badge>
}
>
{/* 카드 그리드 */}
<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;
const Icon = style.Icon;
// 긴급 항목은 빨간 배경
const bgColor = isHighlighted ? '#ef4444' : style.bg;
const borderColor = isHighlighted ? '#ef4444' : style.border;
const iconBgColor = isHighlighted ? 'rgba(255,255,255,0.2)' : style.iconBg;
const labelColor = isHighlighted ? '#ffffff' : style.labelColor;
const countColor = isHighlighted ? '#ffffff' : '#0f172a';
return (
<div
key={item.id}
style={{ backgroundColor: bgColor, borderColor: borderColor }}
className="relative p-4 rounded-xl border cursor-pointer transition-all hover:scale-[1.02] hover:shadow-md min-h-[130px] flex flex-col"
className={`relative p-4 rounded-xl border cursor-pointer transition-all hover:scale-[1.02] hover:shadow-md min-h-[130px] flex flex-col ${isHighlighted ? 'bg-red-500 border-red-500 dark:bg-red-600 dark:border-red-600' : `${style.bgClass} ${style.borderClass}`}`}
onClick={() => handleItemClick(item.path)}
>
{/* 아이콘 + 라벨 */}
<div className="flex items-center gap-2 mb-3 min-w-0">
<div style={{ backgroundColor: iconBgColor }} className="p-1.5 rounded-lg shrink-0">
<Icon style={{ color: '#ffffff' }} className="h-4 w-4" />
<div
className="p-1.5 rounded-lg shrink-0"
style={{ backgroundColor: isHighlighted ? 'rgba(255,255,255,0.2)' : style.iconBg }}
>
<Icon className="h-4 w-4 text-white" />
</div>
<span style={{ color: labelColor }} className="text-sm font-medium truncate flex-1 min-w-0">
<span className={`text-sm font-medium truncate flex-1 min-w-0 ${isHighlighted ? 'text-white' : style.labelClass}`}>
{item.label}
</span>
</div>
{/* 숫자 */}
<div style={{ color: countColor }} className="text-2xl font-bold">
<div className={`text-2xl font-bold ${isHighlighted ? 'text-white' : 'text-foreground'}`}>
{typeof item.count === 'number' ? `${item.count}` : item.count}
</div>
{/* 부가 정보 (최근 항목 외 N건) - pill 뱃지 스타일 */}
{item.subLabel && (
<span
style={{
backgroundColor: isHighlighted ? 'rgba(255,255,255,0.2)' : `${style.iconBg}15`,
color: isHighlighted ? '#ffffff' : style.labelColor,
borderColor: isHighlighted ? 'rgba(255,255,255,0.3)' : `${style.iconBg}30`,
}}
className="text-xs font-medium mt-auto px-2 py-0.5 rounded-full border truncate w-fit max-w-full"
className={`text-xs font-medium mt-auto px-2 py-0.5 rounded-full border truncate w-fit max-w-full ${isHighlighted ? 'bg-white/20 text-white border-white/30' : style.labelClass}`}
style={!isHighlighted ? {
backgroundColor: `${style.iconBg}15`,
borderColor: `${style.iconBg}30`,
} : undefined}
>
{item.subLabel}
</span>
@@ -292,8 +261,7 @@ export function EnhancedStatusBoardSection({ items, itemSettings }: EnhancedStat
);
})}
</div>
</CardContent>
</Card>
</CollapsibleDashboardCard>
);
}
@@ -311,40 +279,36 @@ export function EnhancedMonthlyExpenseSection({ data, onCardClick }: EnhancedMon
const totalAmount = data.cards.reduce((sum, card) => sum + (Number(card?.amount) || 0), 0);
return (
<Card>
<CardContent className="p-6">
{/* 헤더 */}
<div className="flex items-center gap-3 mb-6">
<div style={{ backgroundColor: '#f97316' }} className="w-1.5 h-6 rounded-full" />
<h3 style={{ color: '#0f172a' }} className="text-lg font-semibold"> </h3>
<Badge
style={{ backgroundColor: '#ffedd5', color: '#c2410c', borderColor: '#fed7aa' }}
>
+15%
</Badge>
</div>
<CollapsibleDashboardCard
icon={<Receipt className="h-5 w-5 text-white" />}
title="당월 예상 지출 내역"
subtitle="이달 예상 지출 정보"
rightElement={
<Badge className="bg-orange-500 text-white border-none hover:opacity-90">
+15%
</Badge>
}
>
{/* 카드 그리드 */}
<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' }}
className="rounded-xl p-4 cursor-pointer transition-all hover:scale-[1.02] hover:shadow-lg border min-h-[130px] flex flex-col"
className="rounded-xl p-4 cursor-pointer transition-all hover:scale-[1.02] hover:shadow-lg border min-h-[130px] flex flex-col bg-purple-50 border-purple-200 dark:bg-purple-900/30 dark:border-purple-800"
onClick={() => onCardClick?.(data.cards[0]?.id || 'me1')}
>
<div className="flex items-center gap-2 mb-2">
<div style={{ backgroundColor: '#8b5cf6' }} className="p-1.5 rounded-lg">
<Receipt style={{ color: '#ffffff' }} className="h-4 w-4" />
<Receipt className="h-4 w-4 text-white" />
</div>
<span style={{ color: '#6d28d9' }} className="text-sm font-medium">
<span className="text-sm font-medium text-purple-700 dark:text-purple-300">
{data.cards[0]?.label || '매입'}
</span>
</div>
<div style={{ color: '#0f172a' }} className="text-2xl font-bold">
<div className="text-2xl font-bold text-foreground">
{formatKoreanAmount(data.cards[0]?.amount || 0)}
</div>
{data.cards[0]?.previousLabel && (
<div style={{ backgroundColor: '#dcfce7', color: '#16a34a' }} className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit">
<div className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">
<TrendingUp className="h-3 w-3" />
{data.cards[0].previousLabel}
</div>
@@ -353,23 +317,22 @@ export function EnhancedMonthlyExpenseSection({ data, onCardClick }: EnhancedMon
{/* 카드 2: 카드 */}
<div
style={{ backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }}
className="rounded-xl p-4 cursor-pointer transition-all hover:scale-[1.02] hover:shadow-lg border min-h-[130px] flex flex-col"
className="rounded-xl p-4 cursor-pointer transition-all hover:scale-[1.02] hover:shadow-lg border min-h-[130px] flex flex-col bg-blue-50 border-blue-200 dark:bg-blue-900/30 dark:border-blue-800"
onClick={() => onCardClick?.(data.cards[1]?.id || 'me2')}
>
<div className="flex items-center gap-2 mb-2">
<div style={{ backgroundColor: '#3b82f6' }} className="p-1.5 rounded-lg">
<CreditCard style={{ color: '#ffffff' }} className="h-4 w-4" />
<CreditCard className="h-4 w-4 text-white" />
</div>
<span style={{ color: '#1d4ed8' }} className="text-sm font-medium">
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
{data.cards[1]?.label || '카드'}
</span>
</div>
<div style={{ color: '#0f172a' }} className="text-2xl font-bold">
<div className="text-2xl font-bold text-foreground">
{formatKoreanAmount(data.cards[1]?.amount || 0)}
</div>
{data.cards[1]?.previousLabel && (
<div style={{ backgroundColor: '#dcfce7', color: '#16a34a' }} className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit">
<div className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">
<TrendingUp className="h-3 w-3" />
{data.cards[1].previousLabel}
</div>
@@ -378,47 +341,45 @@ export function EnhancedMonthlyExpenseSection({ data, onCardClick }: EnhancedMon
{/* 카드 3: 발행어음 */}
<div
style={{ backgroundColor: '#fffbeb', borderColor: '#fde68a' }}
className="rounded-xl p-4 cursor-pointer transition-all hover:scale-[1.02] hover:shadow-lg border min-h-[130px] flex flex-col"
className="rounded-xl p-4 cursor-pointer transition-all hover:scale-[1.02] hover:shadow-lg border min-h-[130px] flex flex-col bg-amber-50 border-amber-200 dark:bg-amber-900/30 dark:border-amber-800"
onClick={() => onCardClick?.(data.cards[2]?.id || 'me3')}
>
<div className="flex items-center gap-2 mb-2">
<div style={{ backgroundColor: '#f59e0b' }} className="p-1.5 rounded-lg">
<Banknote style={{ color: '#ffffff' }} className="h-4 w-4" />
<Banknote className="h-4 w-4 text-white" />
</div>
<span style={{ color: '#b45309' }} className="text-sm font-medium">
<span className="text-sm font-medium text-amber-700 dark:text-amber-300">
{data.cards[2]?.label || '발행어음'}
</span>
</div>
<div style={{ color: '#0f172a' }} className="text-2xl font-bold">
<div className="text-2xl font-bold text-foreground">
{formatKoreanAmount(data.cards[2]?.amount || 0)}
</div>
{data.cards[2]?.previousLabel && (
<div style={{ backgroundColor: '#dcfce7', color: '#16a34a' }} className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit">
<div className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">
<TrendingUp className="h-3 w-3" />
{data.cards[2].previousLabel}
</div>
)}
</div>
{/* 카드 4: 총 예상 지출 합계 (강조 - 인라인 스타일) */}
{/* 카드 4: 총 예상 지출 합계 (강조) */}
<div
style={{ backgroundColor: '#f43f5e', borderColor: '#f43f5e' }}
className="rounded-xl p-4 cursor-pointer transition-all hover:scale-[1.02] hover:shadow-lg border min-h-[130px] flex flex-col"
className="rounded-xl p-4 cursor-pointer transition-all hover:scale-[1.02] hover:shadow-lg border min-h-[130px] flex flex-col bg-rose-500 border-rose-500 dark:bg-rose-600 dark:border-rose-600"
onClick={() => onCardClick?.(data.cards[3]?.id || 'me4')}
>
<div className="flex items-center gap-2 mb-2">
<div style={{ backgroundColor: 'rgba(255,255,255,0.2)' }} className="p-1.5 rounded-lg">
<CircleDollarSign style={{ color: '#ffffff' }} className="h-4 w-4" />
<div className="p-1.5 rounded-lg bg-white/20">
<CircleDollarSign className="h-4 w-4 text-white" />
</div>
<span style={{ color: '#ffe4e6' }} className="text-sm font-medium">
<span className="text-sm font-medium text-rose-100">
</span>
</div>
<div style={{ color: '#ffffff' }} className="text-2xl font-bold">
<div className="text-2xl font-bold text-white">
{formatKoreanAmount(totalAmount)}
</div>
<div style={{ backgroundColor: 'rgba(255,255,255,0.2)', color: '#ffffff' }} className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit">
<div className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit bg-white/20 text-white">
<TrendingUp className="h-3 w-3" />
+10.5%
</div>
@@ -429,33 +390,31 @@ export function EnhancedMonthlyExpenseSection({ data, onCardClick }: EnhancedMon
{data.checkPoints.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{data.checkPoints.map((cp, idx) => {
const colors = [
{ bg: '#fef2f2', border: '#fecaca', iconColor: '#ef4444' },
{ bg: '#fffbeb', border: '#fde68a', iconColor: '#f59e0b' },
{ bg: '#f0fdf4', border: '#bbf7d0', iconColor: '#22c55e' },
const colorClasses = [
{ bg: 'bg-red-50 dark:bg-red-900/30', border: 'border-red-200 dark:border-red-800', icon: 'text-red-500 dark:text-red-400' },
{ bg: 'bg-amber-50 dark:bg-amber-900/30', border: 'border-amber-200 dark:border-amber-800', icon: 'text-amber-500 dark:text-amber-400' },
{ bg: 'bg-green-50 dark:bg-green-900/30', border: 'border-green-200 dark:border-green-800', icon: 'text-green-500 dark:text-green-400' },
];
const color = colors[idx] || colors[2];
const color = colorClasses[idx] || colorClasses[2];
return (
<div
key={cp.id}
style={{ backgroundColor: color.bg, borderColor: color.border }}
className="p-3 rounded-lg border flex items-start gap-2"
className={`p-3 rounded-lg border flex items-start gap-2 ${color.bg} ${color.border}`}
>
{idx === 0 ? (
<AlertTriangle style={{ color: color.iconColor }} className="h-4 w-4 mt-0.5 shrink-0" />
<AlertTriangle className={`h-4 w-4 mt-0.5 shrink-0 ${color.icon}`} />
) : idx === 1 ? (
<AlertCircle style={{ color: color.iconColor }} className="h-4 w-4 mt-0.5 shrink-0" />
<AlertCircle className={`h-4 w-4 mt-0.5 shrink-0 ${color.icon}`} />
) : (
<CheckCircle2 style={{ color: color.iconColor }} className="h-4 w-4 mt-0.5 shrink-0" />
<CheckCircle2 className={`h-4 w-4 mt-0.5 shrink-0 ${color.icon}`} />
)}
<p style={{ color: '#475569' }} className="text-sm">{cp.message}</p>
<p className="text-sm text-muted-foreground">{cp.message}</p>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</CollapsibleDashboardCard>
);
}

View File

@@ -1,8 +1,7 @@
'use client';
import { Wine, Utensils, Users, CreditCard } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { SectionTitle, AmountCardItem, CheckPointItem, type SectionColorTheme } from '../components';
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
import type { EntertainmentData } from '../types';
// 카드별 아이콘 매핑
@@ -16,38 +15,33 @@ interface EntertainmentSectionProps {
export function EntertainmentSection({ data, onCardClick }: EntertainmentSectionProps) {
return (
<Card>
<CardContent className="p-6">
<SectionTitle
title="접대비 현황"
badge="warning"
icon={Wine}
colorTheme="pink"
/>
<CollapsibleDashboardCard
icon={<Wine style={{ color: '#ffffff' }} className="h-5 w-5" />}
title="접대비 현황"
subtitle="접대비 사용 현황"
>
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4 mb-4">
{data.cards.map((card, idx) => (
<AmountCardItem
key={card.id}
card={card}
onClick={() => onCardClick?.(card.id)}
icon={CARD_ICONS[idx] || Wine}
colorTheme={CARD_THEMES[idx] || 'pink'}
showTrend={!!card.previousLabel}
trendValue={card.previousLabel}
trendDirection="up"
/>
))}
</div>
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4 mb-4">
{data.cards.map((card, idx) => (
<AmountCardItem
key={card.id}
card={card}
onClick={() => onCardClick?.(card.id)}
icon={CARD_ICONS[idx] || Wine}
colorTheme={CARD_THEMES[idx] || 'pink'}
showTrend={!!card.previousLabel}
trendValue={card.previousLabel}
trendDirection="up"
/>
{data.checkPoints.length > 0 && (
<div className="border-t pt-4 space-y-1">
{data.checkPoints.map((cp) => (
<CheckPointItem key={cp.id} checkpoint={cp} />
))}
</div>
{data.checkPoints.length > 0 && (
<div className="border-t pt-4 space-y-1">
{data.checkPoints.map((cp) => (
<CheckPointItem key={cp.id} checkpoint={cp} />
))}
</div>
)}
</CardContent>
</Card>
)}
</CollapsibleDashboardCard>
);
}
}

View File

@@ -1,7 +1,7 @@
'use client';
import { Card, CardContent } from '@/components/ui/card';
import { SectionTitle, AmountCardItem, CheckPointItem } from '../components';
import { Receipt } from 'lucide-react';
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard } from '../components';
import type { MonthlyExpenseData } from '../types';
interface MonthlyExpenseSectionProps {
@@ -11,28 +11,28 @@ interface MonthlyExpenseSectionProps {
export function MonthlyExpenseSection({ data, onCardClick }: MonthlyExpenseSectionProps) {
return (
<Card>
<CardContent className="p-6">
<SectionTitle title="당월 예상 지출 내역" badge="warning" />
<CollapsibleDashboardCard
icon={<Receipt style={{ color: '#ffffff' }} className="h-5 w-5" />}
title="당월 예상 지출 내역"
subtitle="이달 예상 지출 정보"
>
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4 mb-4">
{data.cards.map((card) => (
<AmountCardItem
key={card.id}
card={card}
onClick={onCardClick ? () => onCardClick(card.id) : undefined}
/>
))}
</div>
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4 mb-4">
{data.cards.map((card) => (
<AmountCardItem
key={card.id}
card={card}
onClick={onCardClick ? () => onCardClick(card.id) : undefined}
/>
{data.checkPoints.length > 0 && (
<div className="border-t pt-4 space-y-1">
{data.checkPoints.map((cp) => (
<CheckPointItem key={cp.id} checkpoint={cp} />
))}
</div>
{data.checkPoints.length > 0 && (
<div className="border-t pt-4 space-y-1">
{data.checkPoints.map((cp) => (
<CheckPointItem key={cp.id} checkpoint={cp} />
))}
</div>
)}
</CardContent>
</Card>
)}
</CollapsibleDashboardCard>
);
}
}

View File

@@ -32,6 +32,7 @@ import {
Legend,
} from 'recharts';
import { formatKoreanAmount } from '@/lib/utils/amount';
import { CollapsibleDashboardCard } from '../components';
import type { PurchaseStatusData } from '../types';
interface PurchaseStatusSectionProps {
@@ -61,79 +62,56 @@ export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
return (
<div className="space-y-6">
<div className="rounded-xl border overflow-hidden">
{/* 다크 헤더 */}
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div style={{ backgroundColor: 'rgba(255,255,255,0.1)' }} className="p-2 rounded-lg">
<ShoppingCart style={{ color: '#ffffff' }} className="h-5 w-5" />
</div>
<div>
<h3 style={{ color: '#ffffff' }} className="text-lg font-semibold"> </h3>
<p style={{ color: '#cbd5e1' }} className="text-sm"> </p>
</div>
</div>
<Badge
style={{ backgroundColor: '#f59e0b', color: '#ffffff', border: 'none' }}
className="hover:opacity-90"
>
</Badge>
</div>
</div>
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
<CollapsibleDashboardCard
icon={<ShoppingCart className="h-5 w-5 text-white" />}
title="매입 현황"
subtitle="당월 매입 실적"
rightElement={
<Badge className="bg-amber-500 text-white border-none hover:opacity-90">
</Badge>
}
>
{/* 통계카드 3개 - 가로 배치 */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
{/* 누적 매입 */}
<div
style={{ backgroundColor: '#fff7ed', borderColor: '#fed7aa' }}
className="rounded-xl p-4 border"
>
<div className="rounded-xl p-4 border bg-orange-50 border-orange-200 dark:bg-orange-900/30 dark:border-orange-800">
<div className="flex items-center gap-2 mb-2">
<div style={{ backgroundColor: '#f59e0b' }} className="p-1.5 rounded-lg">
<DollarSign style={{ color: '#ffffff' }} className="h-4 w-4" />
<DollarSign className="h-4 w-4 text-white" />
</div>
<span style={{ color: '#b45309' }} className="text-sm font-medium"> </span>
<span className="text-sm font-medium text-amber-700 dark:text-amber-300"> </span>
</div>
<span style={{ color: '#0f172a' }} className="text-xl font-bold">
<span className="text-xl font-bold text-foreground">
{formatKoreanAmount(data.cumulativePurchase)}
</span>
</div>
{/* 미결제 금액 */}
<div
style={{ backgroundColor: '#fef2f2', borderColor: '#fecaca' }}
className="rounded-xl p-4 border"
>
<div className="rounded-xl p-4 border bg-red-50 border-red-200 dark:bg-red-900/30 dark:border-red-800">
<div className="flex items-center gap-2 mb-2">
<div style={{ backgroundColor: '#ef4444' }} className="p-1.5 rounded-lg">
<AlertCircle style={{ color: '#ffffff' }} className="h-4 w-4" />
<AlertCircle className="h-4 w-4 text-white" />
</div>
<span style={{ color: '#dc2626' }} className="text-sm font-medium"> </span>
<span className="text-sm font-medium text-red-700 dark:text-red-300"> </span>
</div>
<span style={{ color: '#0f172a' }} className="text-xl font-bold">
<span className="text-xl font-bold text-foreground">
{formatKoreanAmount(data.unpaidAmount)}
</span>
</div>
{/* 전년 동기 대비 */}
<div
style={{
backgroundColor: data.yoyChange >= 0 ? '#fef2f2' : '#eff6ff',
borderColor: data.yoyChange >= 0 ? '#fecaca' : '#bfdbfe',
}}
className="rounded-xl p-4 border"
className={`rounded-xl p-4 border ${data.yoyChange >= 0 ? 'bg-red-50 border-red-200 dark:bg-red-900/30 dark:border-red-800' : 'bg-blue-50 border-blue-200 dark:bg-blue-900/30 dark:border-blue-800'}`}
>
<div className="flex items-center gap-2 mb-2">
<div style={{ backgroundColor: data.yoyChange >= 0 ? '#ef4444' : '#3b82f6' }} className="p-1.5 rounded-lg">
<TrendingDown style={{ color: '#ffffff' }} className="h-4 w-4" />
<TrendingDown className="h-4 w-4 text-white" />
</div>
<span style={{ color: data.yoyChange >= 0 ? '#dc2626' : '#1d4ed8' }} className="text-sm font-medium"> </span>
<span className={`text-sm font-medium ${data.yoyChange >= 0 ? 'text-red-700 dark:text-red-300' : 'text-blue-700 dark:text-blue-300'}`}> </span>
</div>
<div className="flex items-center gap-1">
<span style={{ color: '#0f172a' }} className="text-xl font-bold">
<span className="text-xl font-bold text-foreground">
{data.yoyChange >= 0 ? '+' : ''}{data.yoyChange}%
</span>
{data.yoyChange >= 0
@@ -146,8 +124,8 @@ export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
{/* 차트 2열 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{/* 월별 매입 추이 */}
<div className="border rounded-lg p-4">
<h4 className="text-sm font-semibold text-gray-700 mb-3"> </h4>
<div className="border border-border rounded-lg p-4">
<h4 className="text-sm font-semibold text-foreground mb-3"> </h4>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={data.monthlyTrend}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
@@ -162,16 +140,16 @@ export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
</div>
{/* 자재 유형별 비율 (Donut) */}
<div className="border rounded-lg p-4">
<h4 className="text-sm font-semibold text-gray-700 mb-3"> </h4>
<ResponsiveContainer width="100%" height={200}>
<div className="border border-border rounded-lg p-4">
<h4 className="text-sm font-semibold text-foreground mb-3"> </h4>
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={data.materialRatio.map((r) => ({ name: r.name, value: r.value, percentage: r.percentage, color: r.color }) as Record<string, unknown>)}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
cy="40%"
innerRadius={40}
outerRadius={65}
dataKey="value"
nameKey="name"
>
@@ -180,72 +158,67 @@ export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
))}
</Pie>
<Tooltip formatter={(value: number | undefined) => [formatKoreanAmount(value ?? 0), '금액']} />
<Legend formatter={(value: string) => {
const item = data.materialRatio.find((r) => r.name === value);
return `${value} ${item?.percentage ?? 0}%`;
}} />
<Legend
verticalAlign="bottom"
wrapperStyle={{ fontSize: '12px', paddingTop: '8px' }}
formatter={(value: string) => {
const item = data.materialRatio.find((r) => r.name === value);
return `${value} ${item?.percentage ?? 0}%`;
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
</div>
</div>
</div>
</CollapsibleDashboardCard>
{/* 당월 매입 내역 (별도 카드) */}
<div className="rounded-xl border overflow-hidden">
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
<div className="flex items-center gap-3">
<div style={{ backgroundColor: 'rgba(255,255,255,0.1)' }} className="p-2 rounded-lg">
<ShoppingCart style={{ color: '#ffffff' }} className="h-5 w-5" />
</div>
<div>
<h3 style={{ color: '#ffffff' }} className="text-lg font-semibold"> </h3>
<p style={{ color: '#cbd5e1' }} className="text-sm"> </p>
</div>
</div>
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 border-b">
<div className="text-sm text-gray-500"> {filteredItems.length}</div>
<div className="flex gap-2">
<MultiSelectCombobox
options={suppliers.map((s) => ({ value: s, label: s }))}
value={supplierFilter}
onChange={setSupplierFilter}
placeholder="전체 공급처"
className="w-[160px] h-8 text-xs"
/>
<Select value={sortOrder} onValueChange={setSortOrder}>
<SelectTrigger className="w-[120px] h-8 text-xs">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
<SelectItem value="date-desc"></SelectItem>
<SelectItem value="date-asc"></SelectItem>
<SelectItem value="amount-desc"> </SelectItem>
<SelectItem value="amount-asc"> </SelectItem>
</SelectContent>
</Select>
</div>
<CollapsibleDashboardCard
icon={<ShoppingCart className="h-5 w-5 text-white" />}
title="당월 매입 내역"
subtitle="당월 매입 거래 상세"
bodyClassName="p-0"
>
<div className="p-3 bg-muted/50 border-b border-border space-y-2">
<div className="text-sm text-muted-foreground"> {filteredItems.length}</div>
<MultiSelectCombobox
options={suppliers.map((s) => ({ value: s, label: s }))}
value={supplierFilter}
onChange={setSupplierFilter}
placeholder="전체 공급처"
className="w-full h-8 text-xs"
/>
<Select value={sortOrder} onValueChange={setSortOrder}>
<SelectTrigger className="w-full h-8 text-xs">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
<SelectItem value="date-desc"></SelectItem>
<SelectItem value="date-asc"></SelectItem>
<SelectItem value="amount-desc"> </SelectItem>
<SelectItem value="amount-asc"> </SelectItem>
</SelectContent>
</Select>
</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>
<th className="px-4 py-2 text-left text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-left text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-right text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-center text-gray-600 font-medium"></th>
<tr className="bg-muted/50 border-b border-border">
<th className="px-4 py-2 text-left text-muted-foreground font-medium"></th>
<th className="px-4 py-2 text-left text-muted-foreground font-medium"></th>
<th className="px-4 py-2 text-left text-muted-foreground font-medium"></th>
<th className="px-4 py-2 text-right text-muted-foreground font-medium"></th>
<th className="px-4 py-2 text-center text-muted-foreground font-medium"></th>
</tr>
</thead>
<tbody>
{filteredItems.map((item, idx) => (
<tr key={idx} className="border-b last:border-b-0 hover:bg-gray-50">
<td className="px-4 py-2 text-gray-700">{item.date}</td>
<td className="px-4 py-2 text-gray-700">{item.supplier}</td>
<td className="px-4 py-2 text-gray-700">{item.item}</td>
<td className="px-4 py-2 text-right text-gray-900 font-medium">
<tr key={idx} className="border-b border-border last:border-b-0 hover:bg-muted/30">
<td className="px-4 py-2 text-muted-foreground">{item.date}</td>
<td className="px-4 py-2 text-muted-foreground">{item.supplier}</td>
<td className="px-4 py-2 text-muted-foreground">{item.item}</td>
<td className="px-4 py-2 text-right text-foreground font-medium">
{item.amount.toLocaleString()}
</td>
<td className="px-4 py-2 text-center">
@@ -253,10 +226,10 @@ export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
variant="outline"
className={
item.status === '결제완료'
? 'text-green-600 border-green-200 bg-green-50'
? 'text-green-600 border-green-200 bg-green-50 dark:text-green-400 dark:border-green-800 dark:bg-green-900/30'
: item.status === '미결제'
? 'text-red-600 border-red-200 bg-red-50'
: 'text-orange-600 border-orange-200 bg-orange-50'
? 'text-red-600 border-red-200 bg-red-50 dark:text-red-400 dark:border-red-800 dark:bg-red-900/30'
: 'text-orange-600 border-orange-200 bg-orange-50 dark:text-orange-400 dark:border-orange-800 dark:bg-orange-900/30'
}
>
{item.status}
@@ -266,9 +239,9 @@ export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
))}
</tbody>
<tfoot>
<tr className="bg-gray-100 font-semibold">
<td className="px-4 py-2 text-gray-700" colSpan={3}></td>
<td className="px-4 py-2 text-right text-gray-900">
<tr className="bg-muted font-semibold">
<td className="px-4 py-2 text-muted-foreground" colSpan={3}></td>
<td className="px-4 py-2 text-right text-foreground">
{filteredItems.reduce((sum, item) => sum + item.amount, 0).toLocaleString()}
</td>
<td />
@@ -276,7 +249,7 @@ export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
</tfoot>
</table>
</div>
</div>
</CollapsibleDashboardCard>
</div>
);
}

View File

@@ -1,9 +1,9 @@
'use client';
import { useRouter } from 'next/navigation';
import { Banknote, Clock, AlertTriangle, CircleDollarSign } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { SectionTitle, AmountCardItem, CheckPointItem, type SectionColorTheme } from '../components';
import { Banknote, Clock, AlertTriangle, CircleDollarSign, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
import type { ReceivableData } from '../types';
// 카드별 아이콘 매핑 (미수금 합계, 30일 이내, 30~90일, 90일 초과)
@@ -24,46 +24,46 @@ export function ReceivableSection({ data }: ReceivableSectionProps) {
};
return (
<Card>
<CardContent className="p-6">
<SectionTitle
title="미수금 현황"
badge="warning"
icon={Banknote}
colorTheme="amber"
actionButton={
data.detailButtonLabel
? {
label: data.detailButtonLabel,
onClick: handleDetailClick,
}
: undefined
}
/>
<CollapsibleDashboardCard
icon={<Banknote style={{ color: '#ffffff' }} className="h-5 w-5" />}
title="미수금 현황"
subtitle="미수금 관리 현황"
rightElement={
data.detailButtonLabel ? (
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); handleDetailClick(); }}
className="text-white hover:bg-white/10 gap-1 text-xs"
>
{data.detailButtonLabel}
<ChevronRight className="h-3 w-3" />
</Button>
) : undefined
}
>
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4 mb-4">
{data.cards.map((card, idx) => (
<AmountCardItem
key={card.id}
card={card}
onClick={handleDetailClick}
icon={CARD_ICONS[idx] || Banknote}
colorTheme={CARD_THEMES[idx] || 'amber'}
showTrend={!!card.previousLabel}
trendValue={card.previousLabel}
trendDirection={idx === 3 ? 'down' : 'up'}
/>
))}
</div>
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4 mb-4">
{data.cards.map((card, idx) => (
<AmountCardItem
key={card.id}
card={card}
onClick={handleDetailClick}
icon={CARD_ICONS[idx] || Banknote}
colorTheme={CARD_THEMES[idx] || 'amber'}
showTrend={!!card.previousLabel}
trendValue={card.previousLabel}
trendDirection={idx === 3 ? 'down' : 'up'}
/>
{data.checkPoints.length > 0 && (
<div className="border-t pt-4 space-y-1">
{data.checkPoints.map((cp) => (
<CheckPointItem key={cp.id} checkpoint={cp} />
))}
</div>
{data.checkPoints.length > 0 && (
<div className="border-t pt-4 space-y-1">
{data.checkPoints.map((cp) => (
<CheckPointItem key={cp.id} checkpoint={cp} />
))}
</div>
)}
</CardContent>
</Card>
)}
</CollapsibleDashboardCard>
);
}
}

View File

@@ -30,6 +30,7 @@ import {
ResponsiveContainer,
} from 'recharts';
import { formatKoreanAmount } from '@/lib/utils/amount';
import { CollapsibleDashboardCard } from '../components';
import type { SalesStatusData } from '../types';
interface SalesStatusSectionProps {
@@ -59,81 +60,58 @@ export function SalesStatusSection({ data }: SalesStatusSectionProps) {
return (
<div className="space-y-6">
<div className="rounded-xl border overflow-hidden">
{/* 다크 헤더 */}
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div style={{ backgroundColor: 'rgba(255,255,255,0.1)' }} className="p-2 rounded-lg">
<BarChart3 style={{ color: '#ffffff' }} className="h-5 w-5" />
</div>
<div>
<h3 style={{ color: '#ffffff' }} className="text-lg font-semibold"> </h3>
<p style={{ color: '#cbd5e1' }} className="text-sm"> </p>
</div>
</div>
<Badge
style={{ backgroundColor: '#3b82f6', color: '#ffffff', border: 'none' }}
className="hover:opacity-90"
>
</Badge>
</div>
</div>
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
<CollapsibleDashboardCard
icon={<BarChart3 className="h-5 w-5 text-white" />}
title="매출 현황"
subtitle="당월 매출 실적"
rightElement={
<Badge className="bg-blue-500 text-white border-none hover:opacity-90">
</Badge>
}
>
{/* 통계카드 4개 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{/* 누적 매출 */}
<div
style={{ backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }}
className="rounded-xl p-4 border"
>
<div className="rounded-xl p-4 border bg-blue-50 border-blue-200 dark:bg-blue-900/30 dark:border-blue-800">
<div className="flex items-center gap-2 mb-2">
<div style={{ backgroundColor: '#3b82f6' }} className="p-1.5 rounded-lg">
<DollarSign style={{ color: '#ffffff' }} className="h-4 w-4" />
<DollarSign className="h-4 w-4 text-white" />
</div>
<span style={{ color: '#1d4ed8' }} className="text-sm font-medium"> </span>
<span className="text-sm font-medium text-blue-700 dark:text-blue-300"> </span>
</div>
<span style={{ color: '#0f172a' }} className="text-xl font-bold">
<span className="text-xl font-bold text-foreground">
{formatKoreanAmount(data.cumulativeSales)}
</span>
</div>
{/* 달성률 */}
<div
style={{ backgroundColor: '#f0fdf4', borderColor: '#bbf7d0' }}
className="rounded-xl p-4 border"
>
<div className="rounded-xl p-4 border bg-green-50 border-green-200 dark:bg-green-900/30 dark:border-green-800">
<div className="flex items-center gap-2 mb-2">
<div style={{ backgroundColor: '#22c55e' }} className="p-1.5 rounded-lg">
<Target style={{ color: '#ffffff' }} className="h-4 w-4" />
<Target className="h-4 w-4 text-white" />
</div>
<span style={{ color: '#15803d' }} className="text-sm font-medium"></span>
<span className="text-sm font-medium text-green-700 dark:text-green-300"></span>
</div>
<span style={{ color: '#0f172a' }} className="text-xl font-bold">
<span className="text-xl font-bold text-foreground">
{data.achievementRate}%
</span>
</div>
{/* 전년 동기 대비 */}
<div
style={{
backgroundColor: data.yoyChange >= 0 ? '#fef2f2' : '#eff6ff',
borderColor: data.yoyChange >= 0 ? '#fecaca' : '#bfdbfe',
}}
className="rounded-xl p-4 border"
className={`rounded-xl p-4 border ${data.yoyChange >= 0 ? 'bg-red-50 border-red-200 dark:bg-red-900/30 dark:border-red-800' : 'bg-blue-50 border-blue-200 dark:bg-blue-900/30 dark:border-blue-800'}`}
>
<div className="flex items-center gap-2 mb-2">
<div style={{ backgroundColor: data.yoyChange >= 0 ? '#ef4444' : '#3b82f6' }} className="p-1.5 rounded-lg">
{data.yoyChange >= 0
? <TrendingUp style={{ color: '#ffffff' }} className="h-4 w-4" />
: <TrendingDown style={{ color: '#ffffff' }} className="h-4 w-4" />}
? <TrendingUp className="h-4 w-4 text-white" />
: <TrendingDown className="h-4 w-4 text-white" />}
</div>
<span style={{ color: data.yoyChange >= 0 ? '#dc2626' : '#1d4ed8' }} className="text-sm font-medium"> </span>
<span className={`text-sm font-medium ${data.yoyChange >= 0 ? 'text-red-700 dark:text-red-300' : 'text-blue-700 dark:text-blue-300'}`}> </span>
</div>
<div className="flex items-center gap-1">
<span style={{ color: '#0f172a' }} className="text-xl font-bold">
<span className="text-xl font-bold text-foreground">
{data.yoyChange >= 0 ? '+' : ''}{data.yoyChange}%
</span>
{data.yoyChange >= 0
@@ -143,17 +121,14 @@ export function SalesStatusSection({ data }: SalesStatusSectionProps) {
</div>
{/* 당월 매출 */}
<div
style={{ backgroundColor: '#faf5ff', borderColor: '#e9d5ff' }}
className="rounded-xl p-4 border"
>
<div className="rounded-xl p-4 border bg-purple-50 border-purple-200 dark:bg-purple-900/30 dark:border-purple-800">
<div className="flex items-center gap-2 mb-2">
<div style={{ backgroundColor: '#a855f7' }} className="p-1.5 rounded-lg">
<Calendar style={{ color: '#ffffff' }} className="h-4 w-4" />
<Calendar className="h-4 w-4 text-white" />
</div>
<span style={{ color: '#7e22ce' }} className="text-sm font-medium"> </span>
<span className="text-sm font-medium text-purple-700 dark:text-purple-300"> </span>
</div>
<span style={{ color: '#0f172a' }} className="text-xl font-bold">
<span className="text-xl font-bold text-foreground">
{formatKoreanAmount(data.monthlySales)}
</span>
</div>
@@ -162,8 +137,8 @@ export function SalesStatusSection({ data }: SalesStatusSectionProps) {
{/* 차트 2열 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{/* 월별 매출 추이 */}
<div className="border rounded-lg p-4">
<h4 className="text-sm font-semibold text-gray-700 mb-3"> </h4>
<div className="border border-border rounded-lg p-4">
<h4 className="text-sm font-semibold text-foreground mb-3"> </h4>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={data.monthlyTrend}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
@@ -178,8 +153,8 @@ export function SalesStatusSection({ data }: SalesStatusSectionProps) {
</div>
{/* 거래처별 매출 (수평 Bar) */}
<div className="border rounded-lg p-4">
<h4 className="text-sm font-semibold text-gray-700 mb-3"> </h4>
<div className="border border-border rounded-lg p-4">
<h4 className="text-sm font-semibold text-foreground mb-3"> </h4>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={data.clientSales} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
@@ -194,63 +169,54 @@ export function SalesStatusSection({ data }: SalesStatusSectionProps) {
</div>
</div>
</div>
</div>
</CollapsibleDashboardCard>
{/* 당월 매출 내역 (별도 카드) */}
<div className="rounded-xl border overflow-hidden">
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
<div className="flex items-center gap-3">
<div style={{ backgroundColor: 'rgba(255,255,255,0.1)' }} className="p-2 rounded-lg">
<BarChart3 style={{ color: '#ffffff' }} className="h-5 w-5" />
</div>
<div>
<h3 style={{ color: '#ffffff' }} className="text-lg font-semibold"> </h3>
<p style={{ color: '#cbd5e1' }} className="text-sm"> </p>
</div>
</div>
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 border-b">
<div className="text-sm text-gray-500"> {filteredItems.length}</div>
<div className="flex gap-2">
<MultiSelectCombobox
options={clients.map((c) => ({ value: c, label: c }))}
value={clientFilter}
onChange={setClientFilter}
placeholder="전체 거래처"
className="w-[160px] h-8 text-xs"
/>
<Select value={sortOrder} onValueChange={setSortOrder}>
<SelectTrigger className="w-[120px] h-8 text-xs">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
<SelectItem value="date-desc"></SelectItem>
<SelectItem value="date-asc"></SelectItem>
<SelectItem value="amount-desc"> </SelectItem>
<SelectItem value="amount-asc"> </SelectItem>
</SelectContent>
</Select>
</div>
<CollapsibleDashboardCard
icon={<BarChart3 className="h-5 w-5 text-white" />}
title="당월 매출 내역"
subtitle="당월 매출 거래 상세"
bodyClassName="p-0"
>
<div className="p-3 bg-muted/50 border-b border-border space-y-2">
<div className="text-sm text-muted-foreground"> {filteredItems.length}</div>
<MultiSelectCombobox
options={clients.map((c) => ({ value: c, label: c }))}
value={clientFilter}
onChange={setClientFilter}
placeholder="전체 거래처"
className="w-full h-8 text-xs"
/>
<Select value={sortOrder} onValueChange={setSortOrder}>
<SelectTrigger className="w-full h-8 text-xs">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
<SelectItem value="date-desc"></SelectItem>
<SelectItem value="date-asc"></SelectItem>
<SelectItem value="amount-desc"> </SelectItem>
<SelectItem value="amount-asc"> </SelectItem>
</SelectContent>
</Select>
</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>
<th className="px-4 py-2 text-left text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-left text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-right text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-center text-gray-600 font-medium"></th>
<tr className="bg-muted/50 border-b border-border">
<th className="px-4 py-2 text-left text-muted-foreground font-medium"></th>
<th className="px-4 py-2 text-left text-muted-foreground font-medium"></th>
<th className="px-4 py-2 text-left text-muted-foreground font-medium"></th>
<th className="px-4 py-2 text-right text-muted-foreground font-medium"></th>
<th className="px-4 py-2 text-center text-muted-foreground font-medium"></th>
</tr>
</thead>
<tbody>
{filteredItems.map((item, idx) => (
<tr key={idx} className="border-b last:border-b-0 hover:bg-gray-50">
<td className="px-4 py-2 text-gray-700">{item.date}</td>
<td className="px-4 py-2 text-gray-700">{item.client}</td>
<td className="px-4 py-2 text-gray-700">{item.item}</td>
<td className="px-4 py-2 text-right text-gray-900 font-medium">
<tr key={idx} className="border-b border-border last:border-b-0 hover:bg-muted/30">
<td className="px-4 py-2 text-muted-foreground">{item.date}</td>
<td className="px-4 py-2 text-muted-foreground">{item.client}</td>
<td className="px-4 py-2 text-muted-foreground">{item.item}</td>
<td className="px-4 py-2 text-right text-foreground font-medium">
{item.amount.toLocaleString()}
</td>
<td className="px-4 py-2 text-center">
@@ -258,10 +224,10 @@ export function SalesStatusSection({ data }: SalesStatusSectionProps) {
variant="outline"
className={
item.status === '입금완료'
? 'text-green-600 border-green-200 bg-green-50'
? 'text-green-600 border-green-200 bg-green-50 dark:text-green-400 dark:border-green-800 dark:bg-green-900/30'
: item.status === '미입금'
? 'text-red-600 border-red-200 bg-red-50'
: 'text-orange-600 border-orange-200 bg-orange-50'
? 'text-red-600 border-red-200 bg-red-50 dark:text-red-400 dark:border-red-800 dark:bg-red-900/30'
: 'text-orange-600 border-orange-200 bg-orange-50 dark:text-orange-400 dark:border-orange-800 dark:bg-orange-900/30'
}
>
{item.status}
@@ -271,9 +237,9 @@ export function SalesStatusSection({ data }: SalesStatusSectionProps) {
))}
</tbody>
<tfoot>
<tr className="bg-gray-100 font-semibold">
<td className="px-4 py-2 text-gray-700" colSpan={3}></td>
<td className="px-4 py-2 text-right text-gray-900">
<tr className="bg-muted font-semibold">
<td className="px-4 py-2 text-muted-foreground" colSpan={3}></td>
<td className="px-4 py-2 text-right text-foreground">
{filteredItems.reduce((sum, item) => sum + item.amount, 0).toLocaleString()}
</td>
<td />
@@ -281,7 +247,7 @@ export function SalesStatusSection({ data }: SalesStatusSectionProps) {
</tfoot>
</table>
</div>
</div>
</CollapsibleDashboardCard>
</div>
);
}

View File

@@ -1,8 +1,8 @@
'use client';
import { useRouter } from 'next/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { SectionTitle, IssueCardItem } from '../components';
import { LayoutGrid } from 'lucide-react';
import { IssueCardItem, CollapsibleDashboardCard } from '../components';
import type { TodayIssueItem, TodayIssueSettings } from '../types';
// 라벨 → 설정키 매핑
@@ -39,22 +39,13 @@ 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`}>
<CollapsibleDashboardCard
icon={<LayoutGrid style={{ color: '#ffffff' }} className="h-5 w-5" />}
title="현황판"
subtitle="주요 현황 요약"
>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3">
{filteredItems.map((item) => (
<IssueCardItem
key={item.id}
@@ -66,7 +57,6 @@ export function StatusBoardSection({ items, itemSettings }: StatusBoardSectionPr
/>
))}
</div>
</CardContent>
</Card>
</CollapsibleDashboardCard>
);
}
}

View File

@@ -2,7 +2,6 @@
import { useState, useMemo, useCallback, useRef, useLayoutEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -31,6 +30,7 @@ import {
Loader2,
type LucideIcon,
} from 'lucide-react';
import { CollapsibleDashboardCard } from '../components';
import { usePastIssue } from '@/hooks/useCEODashboard';
import type { TodayIssueListItem, TodayIssueNotificationType } from '../types';
@@ -44,16 +44,16 @@ interface BadgeStyle {
// notification_type 코드 기반 스타일 매핑 (API 고정값 사용)
const NOTIFICATION_STYLES: Record<TodayIssueNotificationType, BadgeStyle> = {
sales_order: { bg: 'bg-blue-50', text: 'text-blue-700', iconBg: 'bg-blue-500', Icon: ShoppingCart },
bad_debt: { bg: 'bg-purple-50', text: 'text-purple-700', iconBg: 'bg-purple-500', Icon: AlertCircle },
safety_stock: { bg: 'bg-orange-50', text: 'text-orange-700', iconBg: 'bg-orange-500', Icon: Package },
expected_expense: { bg: 'bg-green-50', text: 'text-green-700', iconBg: 'bg-green-500', Icon: Receipt },
vat_report: { bg: 'bg-red-50', text: 'text-red-700', iconBg: 'bg-red-500', Icon: FileText },
approval_request: { bg: 'bg-amber-50', text: 'text-amber-700', iconBg: 'bg-amber-500', Icon: CheckCircle2 },
new_vendor: { bg: 'bg-emerald-50', text: 'text-emerald-700', iconBg: 'bg-emerald-500', Icon: Building2 },
deposit: { bg: 'bg-cyan-50', text: 'text-cyan-700', iconBg: 'bg-cyan-500', Icon: TrendingUp },
withdrawal: { bg: 'bg-pink-50', text: 'text-pink-700', iconBg: 'bg-pink-500', Icon: TrendingDown },
other: { bg: 'bg-gray-50', text: 'text-gray-700', iconBg: 'bg-gray-500', Icon: Info },
sales_order: { bg: 'bg-blue-50 dark:bg-blue-900/30', text: 'text-blue-700 dark:text-blue-300', iconBg: 'bg-blue-500', Icon: ShoppingCart },
bad_debt: { bg: 'bg-purple-50 dark:bg-purple-900/30', text: 'text-purple-700 dark:text-purple-300', iconBg: 'bg-purple-500', Icon: AlertCircle },
safety_stock: { bg: 'bg-orange-50 dark:bg-orange-900/30', text: 'text-orange-700 dark:text-orange-300', iconBg: 'bg-orange-500', Icon: Package },
expected_expense: { bg: 'bg-green-50 dark:bg-green-900/30', text: 'text-green-700 dark:text-green-300', iconBg: 'bg-green-500', Icon: Receipt },
vat_report: { bg: 'bg-red-50 dark:bg-red-900/30', text: 'text-red-700 dark:text-red-300', iconBg: 'bg-red-500', Icon: FileText },
approval_request: { bg: 'bg-amber-50 dark:bg-amber-900/30', text: 'text-amber-700 dark:text-amber-300', iconBg: 'bg-amber-500', Icon: CheckCircle2 },
new_vendor: { bg: 'bg-emerald-50 dark:bg-emerald-900/30', text: 'text-emerald-700 dark:text-emerald-300', iconBg: 'bg-emerald-500', Icon: Building2 },
deposit: { bg: 'bg-cyan-50 dark:bg-cyan-900/30', text: 'text-cyan-700 dark:text-cyan-300', iconBg: 'bg-cyan-500', Icon: TrendingUp },
withdrawal: { bg: 'bg-pink-50 dark:bg-pink-900/30', text: 'text-pink-700 dark:text-pink-300', iconBg: 'bg-pink-500', Icon: TrendingDown },
other: { bg: 'bg-muted/50', text: 'text-muted-foreground', iconBg: 'bg-gray-500', Icon: Info },
};
// 신용등급 색상 매핑 (A=녹색, B=노랑, C=주황, D=빨강)
@@ -277,11 +277,13 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
};
return (
<Card>
<CardContent className="p-6">
{/* 헤더 */}
<CollapsibleDashboardCard
icon={<AlertCircle style={{ color: '#ffffff' }} className="h-5 w-5" />}
title="오늘의 이슈"
subtitle="주요 알림 및 이슈 현황"
>
{/* 필터/탭 영역 */}
<div className="flex items-center gap-3 mb-4 flex-wrap">
<h2 className="text-lg font-semibold text-gray-900 shrink-0"> </h2>
{/* 탭 */}
<Tabs value={activeTab} onValueChange={handleTabChange} className="shrink-0">
@@ -293,11 +295,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 +310,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 +328,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>
@@ -334,7 +336,7 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center justify-between w-full gap-3">
<span>{option.label}</span>
<span className="bg-gray-100 text-gray-600 text-xs px-2 py-0.5 rounded-full font-medium">
<span className="bg-muted text-muted-foreground text-xs px-2 py-0.5 rounded-full font-medium">
{option.count}
</span>
</div>
@@ -348,11 +350,11 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
<div ref={gridRef} className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3 max-h-[400px] overflow-y-auto pr-1">
{activeTab === 'past' && pastLoading ? (
<div className="col-span-full flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-gray-400 mr-2" />
<span className="text-sm text-gray-500"> ...</span>
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground mr-2" />
<span className="text-sm text-muted-foreground"> ...</span>
</div>
) : filteredItems.length === 0 ? (
<div className="col-span-full text-center py-8 text-gray-500">
<div className="col-span-full text-center py-8 text-muted-foreground">
{activeTab === 'past'
? `${formatDateDisplay(pastDate)}에 이슈가 없습니다.`
: '표시할 이슈가 없습니다.'}
@@ -368,7 +370,7 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
return (
<div
key={item.id}
className={`flex items-center gap-2 p-3 border rounded-lg hover:shadow-md transition-all cursor-pointer ${badgeStyle.bg} border-transparent hover:border-gray-200`}
className={`flex items-center gap-2 p-3 border rounded-lg hover:shadow-md transition-all cursor-pointer ${badgeStyle.bg} border-transparent hover:border-border`}
onClick={() => handleItemClick(item)}
>
{/* 아이콘 + 뱃지 */}
@@ -382,7 +384,7 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
</div>
{/* 내용 */}
<span className="text-sm text-gray-800 truncate flex-1 min-w-0">
<span className="text-sm text-foreground truncate flex-1 min-w-0">
{item.content}
</span>
@@ -404,7 +406,7 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
)}
{/* 시간 */}
<span className="text-xs text-gray-500 whitespace-nowrap shrink-0">
<span className="text-xs text-muted-foreground whitespace-nowrap shrink-0">
{item.time}
</span>
@@ -434,7 +436,7 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
<Button
size="sm"
variant="outline"
className="h-6 px-2 text-xs text-gray-600 hover:text-green-600 hover:border-green-600 hover:bg-green-50"
className="h-6 px-2 text-xs text-muted-foreground hover:text-green-600 hover:border-green-600 hover:bg-green-50"
onClick={() => handleDismiss(item)}
>
확인
@@ -447,7 +449,6 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
})
)}
</div>
</CardContent>
</Card>
</CollapsibleDashboardCard>
);
}
}

View File

@@ -11,6 +11,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
import { CollapsibleDashboardCard } from '../components';
import type { UnshippedData } from '../types';
interface UnshippedSectionProps {
@@ -31,81 +32,68 @@ export function UnshippedSection({ data }: UnshippedSectionProps) {
});
return (
<div className="rounded-xl border overflow-hidden">
{/* 다크 헤더 */}
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div style={{ backgroundColor: 'rgba(255,255,255,0.1)' }} className="p-2 rounded-lg">
<PackageX style={{ color: '#ffffff' }} className="h-5 w-5" />
</div>
<div>
<h3 style={{ color: '#ffffff' }} className="text-lg font-semibold"> </h3>
<p style={{ color: '#cbd5e1' }} className="text-sm"> </p>
</div>
</div>
<Badge
style={{ backgroundColor: '#ef4444', color: '#ffffff', border: 'none' }}
className="hover:opacity-90"
>
{data.items.length}
</Badge>
</div>
</div>
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
<CollapsibleDashboardCard
icon={<PackageX style={{ color: '#ffffff' }} className="h-5 w-5" />}
title="미출고 내역"
subtitle="납기일 기준 미출고 현황"
rightElement={
<Badge
className="bg-red-500 text-white border-none hover:opacity-90"
>
{data.items.length}
</Badge>
}
>
{/* 미출고 테이블 */}
<div className="border rounded-lg overflow-hidden">
<div className="flex items-center justify-between p-4 bg-gray-50 border-b">
<h4 className="text-sm font-semibold text-gray-700"> </h4>
<div className="flex gap-2">
<MultiSelectCombobox
options={clients.map((c) => ({ value: c, label: c }))}
value={clientFilter}
onChange={setClientFilter}
placeholder="전체 거래처"
className="w-[160px] h-8 text-xs"
/>
<Select value={sortOrder} onValueChange={setSortOrder}>
<SelectTrigger className="w-[140px] h-8 text-xs">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
<SelectItem value="due-asc"> </SelectItem>
<SelectItem value="due-desc"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="border border-border rounded-lg overflow-hidden">
<div className="p-3 bg-muted/50 border-b border-border space-y-2">
<h4 className="text-sm font-semibold text-foreground"> </h4>
<MultiSelectCombobox
options={clients.map((c) => ({ value: c, label: c }))}
value={clientFilter}
onChange={setClientFilter}
placeholder="전체 거래처"
className="w-full h-8 text-xs"
/>
<Select value={sortOrder} onValueChange={setSortOrder}>
<SelectTrigger className="w-full h-8 text-xs">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
<SelectItem value="due-asc"> </SelectItem>
<SelectItem value="due-desc"> </SelectItem>
</SelectContent>
</Select>
</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>
<th className="px-4 py-2 text-left text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-left text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-left text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-center text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-center text-gray-600 font-medium"></th>
<tr className="bg-muted/50 border-b border-border">
<th className="px-4 py-2 text-center text-muted-foreground font-medium w-12">No</th>
<th className="px-4 py-2 text-left text-muted-foreground font-medium"></th>
<th className="px-4 py-2 text-left text-muted-foreground font-medium"></th>
<th className="px-4 py-2 text-left text-muted-foreground font-medium"></th>
<th className="px-4 py-2 text-center text-muted-foreground font-medium"></th>
<th className="px-4 py-2 text-center text-muted-foreground font-medium"></th>
</tr>
</thead>
<tbody>
{filteredItems.map((item, idx) => (
<tr key={item.id} className="border-b last:border-b-0 hover:bg-gray-50">
<td className="px-4 py-2 text-center text-gray-500">{idx + 1}</td>
<td className="px-4 py-2 text-gray-700">{item.portNo}</td>
<td className="px-4 py-2 text-gray-700">{item.siteName}</td>
<td className="px-4 py-2 text-gray-700">{item.orderClient}</td>
<td className="px-4 py-2 text-center text-gray-700">{item.dueDate}</td>
<tr key={item.id} className="border-b border-border last:border-b-0 hover:bg-muted/50">
<td className="px-4 py-2 text-center text-muted-foreground">{idx + 1}</td>
<td className="px-4 py-2 text-muted-foreground">{item.portNo}</td>
<td className="px-4 py-2 text-muted-foreground">{item.siteName}</td>
<td className="px-4 py-2 text-muted-foreground">{item.orderClient}</td>
<td className="px-4 py-2 text-center text-muted-foreground">{item.dueDate}</td>
<td className="px-4 py-2 text-center">
<Badge
variant="outline"
className={
item.daysLeft <= 3
? 'text-red-600 border-red-200 bg-red-50'
? 'text-red-600 border-red-200 bg-red-50 dark:text-red-400 dark:border-red-800 dark:bg-red-900/30'
: item.daysLeft <= 7
? 'text-orange-600 border-orange-200 bg-orange-50'
: 'text-gray-600 border-gray-200 bg-gray-50'
? 'text-orange-600 border-orange-200 bg-orange-50 dark:text-orange-400 dark:border-orange-800 dark:bg-orange-900/30'
: 'text-muted-foreground border-border bg-muted/50'
}
>
D-{item.daysLeft}
@@ -117,7 +105,6 @@ export function UnshippedSection({ data }: UnshippedSectionProps) {
</table>
</div>
</div>
</div>
</div>
</CollapsibleDashboardCard>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import { Card, CardContent } from '@/components/ui/card';
import { SectionTitle, AmountCardItem, CheckPointItem } from '../components';
import { Calculator } from 'lucide-react';
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard } from '../components';
import type { VatData } from '../types';
interface VatSectionProps {
@@ -11,28 +11,28 @@ interface VatSectionProps {
export function VatSection({ data, onClick }: VatSectionProps) {
return (
<Card>
<CardContent className="p-6">
<SectionTitle title="부가세 현황" badge="warning" />
<CollapsibleDashboardCard
icon={<Calculator style={{ color: '#ffffff' }} className="h-5 w-5" />}
title="부가세 현황"
subtitle="부가세 납부 정보"
>
<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}
card={card}
onClick={onClick}
/>
))}
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
{data.cards.map((card) => (
<AmountCardItem
key={card.id}
card={card}
onClick={onClick}
/>
{data.checkPoints.length > 0 && (
<div className="border-t pt-4 space-y-1">
{data.checkPoints.map((cp) => (
<CheckPointItem key={cp.id} checkpoint={cp} />
))}
</div>
{data.checkPoints.length > 0 && (
<div className="border-t pt-4 space-y-1">
{data.checkPoints.map((cp) => (
<CheckPointItem key={cp.id} checkpoint={cp} />
))}
</div>
)}
</CardContent>
</Card>
)}
</CollapsibleDashboardCard>
);
}
}

View File

@@ -1,8 +1,7 @@
'use client';
import { Heart, Gift, Coffee, Smile } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { SectionTitle, AmountCardItem, CheckPointItem, type SectionColorTheme } from '../components';
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
import type { WelfareData } from '../types';
// 카드별 아이콘 매핑
@@ -16,38 +15,33 @@ interface WelfareSectionProps {
export function WelfareSection({ data, onCardClick }: WelfareSectionProps) {
return (
<Card>
<CardContent className="p-6">
<SectionTitle
title="복리후생비 현황"
badge="info"
icon={Heart}
colorTheme="emerald"
/>
<CollapsibleDashboardCard
icon={<Heart style={{ color: '#ffffff' }} className="h-5 w-5" />}
title="복리후생비 현황"
subtitle="복리후생비 사용 현황"
>
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4 mb-4">
{data.cards.map((card, idx) => (
<AmountCardItem
key={card.id}
card={card}
onClick={onCardClick ? () => onCardClick(card.id) : undefined}
icon={CARD_ICONS[idx] || Heart}
colorTheme={CARD_THEMES[idx] || 'emerald'}
showTrend={!!card.previousLabel}
trendValue={card.previousLabel}
trendDirection="up"
/>
))}
</div>
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4 mb-4">
{data.cards.map((card, idx) => (
<AmountCardItem
key={card.id}
card={card}
onClick={onCardClick ? () => onCardClick(card.id) : undefined}
icon={CARD_ICONS[idx] || Heart}
colorTheme={CARD_THEMES[idx] || 'emerald'}
showTrend={!!card.previousLabel}
trendValue={card.previousLabel}
trendDirection="up"
/>
{data.checkPoints.length > 0 && (
<div className="border-t pt-4 space-y-1">
{data.checkPoints.map((cp) => (
<CheckPointItem key={cp.id} checkpoint={cp} />
))}
</div>
{data.checkPoints.length > 0 && (
<div className="border-t pt-4 space-y-1">
{data.checkPoints.map((cp) => (
<CheckPointItem key={cp.id} checkpoint={cp} />
))}
</div>
)}
</CardContent>
</Card>
)}
</CollapsibleDashboardCard>
);
}
}

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),
};
}

View File

@@ -165,6 +165,14 @@ export function parseNumber(formatted: string): number {
return isNaN(num) ? 0 : num;
}
/**
* 숫자만 추출 (범용)
* 전화번호, 사업자번호 등 포맷팅 전처리에 사용
*/
export function extractDigits(value: string): string {
return value.replace(/\D/g, '');
}
/**
* Leading zero 제거 (01 → 1)
*/
@@ -188,34 +196,3 @@ export function removeLeadingZeros(value: string): string {
return value.replace(/^0+/, '') || '0';
}
/**
* 숫자만 추출 (음수, 소수점 허용 옵션)
*/
export function extractNumbers(value: string, options?: {
allowNegative?: boolean;
allowDecimal?: boolean;
}): string {
const { allowNegative = false, allowDecimal = false } = options || {};
let pattern = '\\d';
if (allowNegative) pattern = '-?' + pattern;
if (allowDecimal) pattern = pattern + '|\\.';
const regex = new RegExp(`[^${allowNegative ? '-' : ''}${allowDecimal ? '.' : ''}\\d]`, 'g');
let result = value.replace(regex, '');
// 중복 마이너스 제거 (첫 번째만 유지)
if (allowNegative && result.includes('-')) {
const isNegative = result.startsWith('-');
result = result.replace(/-/g, '');
if (isNegative) result = '-' + result;
}
// 중복 소수점 제거 (첫 번째만 유지)
if (allowDecimal && result.includes('.')) {
const parts = result.split('.');
result = parts[0] + (parts.length > 1 ? '.' + parts.slice(1).join('') : '');
}
return result;
}

View File

@@ -58,6 +58,17 @@ export function formatDateForInput(dateStr: string | null | undefined): string {
return getLocalDateString(date);
}
/**
* ISO 문자열에서 날짜 부분(YYYY-MM-DD)만 추출
* null/undefined 시 빈 문자열 반환 (폼 데이터 변환용)
* @example toDateString("2025-01-06T00:00:00.000Z") // "2025-01-06"
* @example toDateString(null) // ""
*/
export function toDateString(isoString: string | null | undefined): string {
if (!isoString) return '';
return isoString.split('T')[0];
}
/**
* 날짜 표시용 포맷 (YYYY-MM-DD)
* @example formatDate("2025-01-06T00:00:00.000Z") // "2025-01-06"

View File

@@ -0,0 +1,110 @@
/**
* 공통 Zod 검증 스키마
*
* 품목명, 품목유형, 날짜, 숫자, BOM 등 여러 스키마에서 공유하는 기본 블록
*/
import { z } from 'zod';
// ===== 내부 전용 스키마 =====
/**
* 품목 코드 검증
* 형식: {업체코드}-{품목유형}-{일련번호}
* 예: KD-FG-001
*
* 현재 사용하지 않음 (품목 코드 자동 생성)
*/
export const _itemCodeSchema = z.string()
.min(1, '품목 코드를 입력해주세요')
.regex(
/^[A-Z0-9]+-[A-Z]{2}-\d+$/,
'품목 코드 형식이 올바르지 않습니다 (예: KD-FG-001)'
);
// ===== 공통 필드 스키마 =====
/**
* 품목명 검증
*/
export const itemNameSchema = z.preprocess(
(val) => val === undefined || val === null ? "" : val,
z.string().min(1, '품목명을 입력해주세요').max(200, '품목명은 200자 이내로 입력해주세요')
);
/**
* 품목 유형 검증
*/
export const itemTypeSchema = z.enum(['FG', 'PT', 'SM', 'RM', 'CS'], {
message: '품목 유형을 선택해주세요',
});
/**
* 단위 검증
*
* 현재 사용하지 않음 (materialUnitSchema로 대체)
*/
export const _unitSchema = z.string()
.min(1, '단위를 입력해주세요')
.max(20, '단위는 20자 이내로 입력해주세요');
/**
* 양수 검증 (가격, 수량 등)
* undefined나 빈 문자열은 검증하지 않음
*/
export const positiveNumberSchema = z.union([
z.number().positive('0보다 큰 값을 입력해주세요'),
z.string().transform((val) => parseFloat(val)).pipe(z.number().positive('0보다 큰 값을 입력해주세요')),
z.undefined(),
z.null(),
z.literal('')
]).optional();
/**
* 날짜 검증 (YYYY-MM-DD)
* 빈 문자열이나 undefined는 검증하지 않음
*/
export const dateSchema = z.preprocess(
(val) => {
if (val === undefined || val === null || val === '') return undefined;
return val;
},
z.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, '날짜 형식이 올바르지 않습니다 (YYYY-MM-DD)')
.optional()
);
// ===== BOM 라인 스키마 =====
/**
* 절곡품 전개도 상세 스키마
*/
export const bendingDetailSchema = z.object({
id: z.string(),
no: z.number().int().positive(),
input: z.number(),
elongation: z.number().default(-1),
calculated: z.number(),
sum: z.number(),
shaded: z.boolean().default(false),
aAngle: z.number().optional(),
});
/**
* BOM 라인 스키마
*/
export const bomLineSchema = z.object({
id: z.string(),
childItemCode: z.string().min(1, '하위 품목 코드를 입력해주세요'),
childItemName: z.string().min(1, '하위 품목명을 입력해주세요'),
quantity: z.number().positive('수량은 0보다 커야 합니다'),
unit: z.string().min(1, '단위를 입력해주세요'),
unitPrice: positiveNumberSchema,
quantityFormula: z.string().optional(),
note: z.string().max(500).optional(),
// 절곡품 관련
isBending: z.boolean().optional(),
bendingDiagram: z.string().url().optional(),
bendingDetails: z.array(bendingDetailSchema).optional(),
});

View File

@@ -0,0 +1,191 @@
/**
* 폼 데이터 Zod 검증 스키마
*
* 품목 생성/수정/필터용 스키마
*/
import { z } from 'zod';
import { itemTypeSchema, bendingDetailSchema } from './common';
import {
productSchema,
productSchemaBase,
partSchemaBase,
materialSchemaBase,
materialSchema,
consumableSchemaBase,
consumableSchema,
} from './item-schemas';
// ===== 폼 데이터 스키마 (생성/수정용) =====
/**
* 품목 생성 폼 스키마
* (id, createdAt, updatedAt 제외)
*
* discriminatedUnion은 omit()을 지원하지 않으므로,
* 각 스키마에 대해 개별적으로 omit을 적용합니다.
*/
// partSchemaBase를 omit한 후 itemType merge - refinement는 마지막에 적용
const partSchemaForForm = partSchemaBase
.omit({ createdAt: true, updatedAt: true })
.merge(z.object({ itemType: z.literal('PT') }))
.superRefine((data, ctx) => {
// 1단계: 부품 유형 필수
if (!data.partType || data.partType === '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '부품 유형을 선택해주세요',
path: ['partType'],
});
return; // 부품 유형이 없으면 더 이상 검증하지 않음
}
// 2단계: 부품 유형이 있을 때만 품목명 필수
if (!data.category1 || data.category1 === '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '품목명을 선택해주세요',
path: ['category1'],
});
return; // 품목명이 없으면 더 이상 검증하지 않음 (설치유형 등 체크 안 함)
}
// 3단계: 조립 부품 전용 필드 (partType과 category1이 모두 있을 때만 실행됨)
if (data.partType === 'ASSEMBLY') {
if (!data.installationType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '설치 유형을 선택해주세요',
path: ['installationType'],
});
}
if (!data.sideSpecWidth) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '측면 규격 (가로)를 입력해주세요',
path: ['sideSpecWidth'],
});
}
if (!data.sideSpecHeight) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '측면 규격 (세로)를 입력해주세요',
path: ['sideSpecHeight'],
});
}
if (!data.assemblyLength) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '길이를 선택해주세요',
path: ['assemblyLength'],
});
}
}
// 절곡품 전용 필드 (partType과 category1이 모두 있을 때만 실행됨)
if (data.partType === 'BENDING') {
// 단계별 검증: 종류(category2) → 재질(material) → 폭 합계 → 모양&길이
if (!data.category2 || data.category2 === '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '종류를 선택해주세요',
path: ['category2'],
});
return; // 종류가 없으면 재질, 폭 합계, 모양&길이 체크 안 함
}
if (!data.material) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '재질을 선택해주세요',
path: ['material'],
});
return; // 재질이 없으면 폭 합계, 모양&길이 체크 안 함
}
if (!data.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '폭 합계를 입력해주세요',
path: ['length'],
});
return; // 폭 합계가 없으면 모양&길이 체크 안 함
}
if (!data.bendingLength) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '모양&길이를 선택해주세요',
path: ['bendingLength'],
});
}
}
// 구매 부품 전용 필드 (partType과 category1이 모두 있을 때만 실행됨)
if (data.partType === 'PURCHASED') {
if (data.category1 === 'electric_opener') {
if (!data.electricOpenerPower) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '전원을 선택해주세요',
path: ['electricOpenerPower'],
});
}
if (!data.electricOpenerCapacity) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '용량을 선택해주세요',
path: ['electricOpenerCapacity'],
});
}
}
if (data.category1 === 'motor' && !data.motorVoltage) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '전압을 선택해주세요',
path: ['motorVoltage'],
});
}
if (data.category1 === 'chain' && !data.chainSpec) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '체인 규격을 선택해주세요',
path: ['chainSpec'],
});
}
}
});
export const createItemFormSchema = z.discriminatedUnion('itemType', [
productSchema.omit({ createdAt: true, updatedAt: true }).extend({ itemType: z.literal('FG') }),
partSchemaForForm, // itemType이 이미 merge되어 있음
materialSchemaBase.omit({ createdAt: true, updatedAt: true }).extend({ itemType: z.literal('SM') }),
materialSchemaBase.omit({ createdAt: true, updatedAt: true }).extend({ itemType: z.literal('RM') }),
consumableSchemaBase.omit({ createdAt: true, updatedAt: true }).extend({ itemType: z.literal('CS') }),
]);
/**
* 품목 수정 폼 스키마
* (모든 필드 선택적)
*
* discriminatedUnion은 partial()도 지원하지 않으므로,
* 각 스키마에 대해 개별적으로 처리합니다.
*/
export const updateItemFormSchema = z.discriminatedUnion('itemType', [
productSchemaBase.omit({ createdAt: true, updatedAt: true }).partial().extend({ itemType: z.literal('FG') }),
partSchemaBase.omit({ createdAt: true, updatedAt: true }).partial().extend({ itemType: z.literal('PT') }),
materialSchemaBase.omit({ createdAt: true, updatedAt: true }).partial().extend({ itemType: z.literal('SM') }),
materialSchemaBase.omit({ createdAt: true, updatedAt: true }).partial().extend({ itemType: z.literal('RM') }),
consumableSchemaBase.omit({ createdAt: true, updatedAt: true }).partial().extend({ itemType: z.literal('CS') }),
]);
// ===== 필터 스키마 =====
/**
* 품목 목록 필터 스키마
*/
export const itemFilterSchema = z.object({
itemType: itemTypeSchema.optional(),
search: z.string().optional(),
category1: z.string().optional(),
category2: z.string().optional(),
category3: z.string().optional(),
isActive: z.boolean().optional(),
});

View File

@@ -0,0 +1,31 @@
/**
* Zod 검증 스키마 barrel export
*
* 기존 `@/lib/utils/validation` 경로 호환성 유지
*/
// common
export { bendingDetailSchema, bomLineSchema } from './common';
// item-schemas
export {
productSchema,
partSchema,
materialSchema,
consumableSchema,
itemMasterSchema,
} from './item-schemas';
// form-schemas
export { createItemFormSchema, updateItemFormSchema, itemFilterSchema } from './form-schemas';
// utils
export { getSchemaByItemType, formatZodError } from './utils';
export type {
ItemMasterFormData,
CreateItemFormData,
UpdateItemFormData,
ItemFilterFormData,
BOMLineFormData,
BendingDetailFormData,
} from './utils';

View File

@@ -1,119 +1,25 @@
/**
* Zod
* Zod
*
* react-hook-form과
* FG(), PT(), SM/RM(/), CS()
*/
import { z } from 'zod';
import type { ItemType } from '@/types/item';
// ===== 공통 스키마 =====
/**
*
* : {}-{}-{}
* : KD-FG-001
*
* ( )
*/
const _itemCodeSchema = z.string()
.min(1, '품목 코드를 입력해주세요')
.regex(
/^[A-Z0-9]+-[A-Z]{2}-\d+$/,
'품목 코드 형식이 올바르지 않습니다 (예: KD-FG-001)'
);
/**
*
*/
const itemNameSchema = z.preprocess(
(val) => val === undefined || val === null ? "" : val,
z.string().min(1, '품목명을 입력해주세요').max(200, '품목명은 200자 이내로 입력해주세요')
);
/**
*
*/
const itemTypeSchema = z.enum(['FG', 'PT', 'SM', 'RM', 'CS'], {
message: '품목 유형을 선택해주세요',
});
/**
*
*
* (materialUnitSchema로 )
*/
const _unitSchema = z.string()
.min(1, '단위를 입력해주세요')
.max(20, '단위는 20자 이내로 입력해주세요');
/**
* (, )
* undefined나
*/
const positiveNumberSchema = z.union([
z.number().positive('0보다 큰 값을 입력해주세요'),
z.string().transform((val) => parseFloat(val)).pipe(z.number().positive('0보다 큰 값을 입력해주세요')),
z.undefined(),
z.null(),
z.literal('')
]).optional();
/**
* (YYYY-MM-DD)
* undefined는
*/
const dateSchema = z.preprocess(
(val) => {
if (val === undefined || val === null || val === '') return undefined;
return val;
},
z.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, '날짜 형식이 올바르지 않습니다 (YYYY-MM-DD)')
.optional()
);
// ===== BOM 라인 스키마 =====
/**
*
*/
export const bendingDetailSchema = z.object({
id: z.string(),
no: z.number().int().positive(),
input: z.number(),
elongation: z.number().default(-1),
calculated: z.number(),
sum: z.number(),
shaded: z.boolean().default(false),
aAngle: z.number().optional(),
});
/**
* BOM
*/
export const bomLineSchema = z.object({
id: z.string(),
childItemCode: z.string().min(1, '하위 품목 코드를 입력해주세요'),
childItemName: z.string().min(1, '하위 품목명을 입력해주세요'),
quantity: z.number().positive('수량은 0보다 커야 합니다'),
unit: z.string().min(1, '단위를 입력해주세요'),
unitPrice: positiveNumberSchema,
quantityFormula: z.string().optional(),
note: z.string().max(500).optional(),
// 절곡품 관련
isBending: z.boolean().optional(),
bendingDiagram: z.string().url().optional(),
bendingDetails: z.array(bendingDetailSchema).optional(),
});
import {
itemNameSchema,
itemTypeSchema,
dateSchema,
positiveNumberSchema,
bomLineSchema,
bendingDetailSchema,
} from './common';
// ===== 품목 마스터 기본 스키마 =====
/**
*
*/
const itemMasterBaseSchema = z.object({
export const itemMasterBaseSchema = z.object({
// 공통 필수 필드
itemCode: z.string().optional(), // 자동생성되므로 선택 사항
itemName: itemNameSchema,
@@ -185,7 +91,7 @@ const productFieldsSchema = z.object({
* (FG) (refinement )
*
*/
const productSchemaBase = itemMasterBaseSchema
export const productSchemaBase = itemMasterBaseSchema
.omit({
purchasePrice: true,
salesPrice: true,
@@ -268,7 +174,7 @@ const partFieldsSchema = z.object({
* (PT) (refinement )
* itemName을
*/
const partSchemaBase = itemMasterBaseSchema
export const partSchemaBase = itemMasterBaseSchema
.extend({
itemName: z.string().max(200).optional(), // 부품은 itemName 선택 사항
})
@@ -422,7 +328,7 @@ const materialUnitSchema = z.preprocess(
* / Base (refinement , )
* specification, unit을 (z.object로 )
*/
const materialSchemaBase = z.object({
export const materialSchemaBase = z.object({
// 공통 필수 필드
itemCode: z.string().optional(),
itemName: itemNameSchema,
@@ -479,7 +385,7 @@ export const materialSchema = materialSchemaBase;
* Base
* specification, unit을
*/
const consumableSchemaBase = itemMasterBaseSchema
export const consumableSchemaBase = itemMasterBaseSchema
.extend({
specification: materialSpecificationSchema, // optional → 필수로 변경
unit: materialUnitSchema, // optional → 필수로 변경
@@ -505,221 +411,3 @@ export const itemMasterSchema = z.discriminatedUnion('itemType', [
materialSchema.extend({ itemType: z.literal('RM') }),
consumableSchema.extend({ itemType: z.literal('CS') }),
]);
// ===== 폼 데이터 스키마 (생성/수정용) =====
/**
*
* (id, createdAt, updatedAt )
*
* discriminatedUnion은 omit() ,
* omit을 .
*/
// partSchemaBase를 omit한 후 itemType merge - refinement는 마지막에 적용
const partSchemaForForm = partSchemaBase
.omit({ createdAt: true, updatedAt: true })
.merge(z.object({ itemType: z.literal('PT') }))
.superRefine((data, ctx) => {
// 1단계: 부품 유형 필수
if (!data.partType || data.partType === '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '부품 유형을 선택해주세요',
path: ['partType'],
});
return; // 부품 유형이 없으면 더 이상 검증하지 않음
}
// 2단계: 부품 유형이 있을 때만 품목명 필수
if (!data.category1 || data.category1 === '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '품목명을 선택해주세요',
path: ['category1'],
});
return; // 품목명이 없으면 더 이상 검증하지 않음 (설치유형 등 체크 안 함)
}
// 3단계: 조립 부품 전용 필드 (partType과 category1이 모두 있을 때만 실행됨)
if (data.partType === 'ASSEMBLY') {
if (!data.installationType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '설치 유형을 선택해주세요',
path: ['installationType'],
});
}
if (!data.sideSpecWidth) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '측면 규격 (가로)를 입력해주세요',
path: ['sideSpecWidth'],
});
}
if (!data.sideSpecHeight) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '측면 규격 (세로)를 입력해주세요',
path: ['sideSpecHeight'],
});
}
if (!data.assemblyLength) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '길이를 선택해주세요',
path: ['assemblyLength'],
});
}
}
// 절곡품 전용 필드 (partType과 category1이 모두 있을 때만 실행됨)
if (data.partType === 'BENDING') {
// 단계별 검증: 종류(category2) → 재질(material) → 폭 합계 → 모양&길이
if (!data.category2 || data.category2 === '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '종류를 선택해주세요',
path: ['category2'],
});
return; // 종류가 없으면 재질, 폭 합계, 모양&길이 체크 안 함
}
if (!data.material) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '재질을 선택해주세요',
path: ['material'],
});
return; // 재질이 없으면 폭 합계, 모양&길이 체크 안 함
}
if (!data.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '폭 합계를 입력해주세요',
path: ['length'],
});
return; // 폭 합계가 없으면 모양&길이 체크 안 함
}
if (!data.bendingLength) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '모양&길이를 선택해주세요',
path: ['bendingLength'],
});
}
}
// 구매 부품 전용 필드 (partType과 category1이 모두 있을 때만 실행됨)
if (data.partType === 'PURCHASED') {
if (data.category1 === 'electric_opener') {
if (!data.electricOpenerPower) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '전원을 선택해주세요',
path: ['electricOpenerPower'],
});
}
if (!data.electricOpenerCapacity) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '용량을 선택해주세요',
path: ['electricOpenerCapacity'],
});
}
}
if (data.category1 === 'motor' && !data.motorVoltage) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '전압을 선택해주세요',
path: ['motorVoltage'],
});
}
if (data.category1 === 'chain' && !data.chainSpec) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '체인 규격을 선택해주세요',
path: ['chainSpec'],
});
}
}
});
export const createItemFormSchema = z.discriminatedUnion('itemType', [
productSchema.omit({ createdAt: true, updatedAt: true }).extend({ itemType: z.literal('FG') }),
partSchemaForForm, // itemType이 이미 merge되어 있음
materialSchemaBase.omit({ createdAt: true, updatedAt: true }).extend({ itemType: z.literal('SM') }),
materialSchemaBase.omit({ createdAt: true, updatedAt: true }).extend({ itemType: z.literal('RM') }),
consumableSchemaBase.omit({ createdAt: true, updatedAt: true }).extend({ itemType: z.literal('CS') }),
]);
/**
*
* ( )
*
* discriminatedUnion은 partial() ,
* .
*/
export const updateItemFormSchema = z.discriminatedUnion('itemType', [
productSchemaBase.omit({ createdAt: true, updatedAt: true }).partial().extend({ itemType: z.literal('FG') }),
partSchemaBase.omit({ createdAt: true, updatedAt: true }).partial().extend({ itemType: z.literal('PT') }),
materialSchemaBase.omit({ createdAt: true, updatedAt: true }).partial().extend({ itemType: z.literal('SM') }),
materialSchemaBase.omit({ createdAt: true, updatedAt: true }).partial().extend({ itemType: z.literal('RM') }),
consumableSchemaBase.omit({ createdAt: true, updatedAt: true }).partial().extend({ itemType: z.literal('CS') }),
]);
// ===== 필터 스키마 =====
/**
*
*/
export const itemFilterSchema = z.object({
itemType: itemTypeSchema.optional(),
search: z.string().optional(),
category1: z.string().optional(),
category2: z.string().optional(),
category3: z.string().optional(),
isActive: z.boolean().optional(),
});
// ===== 타입 추출 =====
export type ItemMasterFormData = z.infer<typeof itemMasterSchema>;
export type CreateItemFormData = z.infer<typeof createItemFormSchema>;
export type UpdateItemFormData = z.infer<typeof updateItemFormSchema>;
export type ItemFilterFormData = z.infer<typeof itemFilterSchema>;
export type BOMLineFormData = z.infer<typeof bomLineSchema>;
export type BendingDetailFormData = z.infer<typeof bendingDetailSchema>;
// ===== 유틸리티 함수 =====
/**
*
*/
export function getSchemaByItemType(itemType: ItemType) {
switch (itemType) {
case 'FG':
return productSchema;
case 'PT':
return partSchema;
case 'SM':
case 'RM':
return materialSchema;
case 'CS':
return consumableSchema;
default:
return itemMasterBaseSchema;
}
}
/**
*
*/
export function formatZodError(error: z.ZodError): Record<string, string> {
const formatted: Record<string, string> = {};
error.issues.forEach((err) => {
const path = err.path.join('.');
formatted[path] = err.message;
});
return formatted;
}

View File

@@ -0,0 +1,64 @@
/**
* 검증 유틸리티 함수 및 타입 추출
*/
import { z } from 'zod';
import type { ItemType } from '@/types/item';
import { bendingDetailSchema, bomLineSchema } from './common';
import {
productSchema,
partSchema,
materialSchema,
consumableSchema,
itemMasterSchema,
itemMasterBaseSchema,
} from './item-schemas';
import {
createItemFormSchema,
updateItemFormSchema,
itemFilterSchema,
} from './form-schemas';
// ===== 타입 추출 =====
export type ItemMasterFormData = z.infer<typeof itemMasterSchema>;
export type CreateItemFormData = z.infer<typeof createItemFormSchema>;
export type UpdateItemFormData = z.infer<typeof updateItemFormSchema>;
export type ItemFilterFormData = z.infer<typeof itemFilterSchema>;
export type BOMLineFormData = z.infer<typeof bomLineSchema>;
export type BendingDetailFormData = z.infer<typeof bendingDetailSchema>;
// ===== 유틸리티 함수 =====
/**
* 품목 유형에 따른 스키마 선택
*/
export function getSchemaByItemType(itemType: ItemType) {
switch (itemType) {
case 'FG':
return productSchema;
case 'PT':
return partSchema;
case 'SM':
case 'RM':
return materialSchema;
case 'CS':
return consumableSchema;
default:
return itemMasterBaseSchema;
}
}
/**
* 에러 메시지 한글화
*/
export function formatZodError(error: z.ZodError): Record<string, string> {
const formatted: Record<string, string> = {};
error.issues.forEach((err) => {
const path = err.path.join('.');
formatted[path] = err.message;
});
return formatted;
}