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:
62
CLAUDE.md
62
CLAUDE.md
@@ -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
31
Jenkinsfile
vendored
@@ -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}>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
110
src/lib/utils/validation/common.ts
Normal file
110
src/lib/utils/validation/common.ts
Normal 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(),
|
||||
});
|
||||
191
src/lib/utils/validation/form-schemas.ts
Normal file
191
src/lib/utils/validation/form-schemas.ts
Normal 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(),
|
||||
});
|
||||
31
src/lib/utils/validation/index.ts
Normal file
31
src/lib/utils/validation/index.ts
Normal 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';
|
||||
@@ -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;
|
||||
}
|
||||
64
src/lib/utils/validation/utils.ts
Normal file
64
src/lib/utils/validation/utils.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user