merge: develop를 main에 머지 (CLAUDE.md 충돌 해결)
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -123,3 +123,6 @@ src/app/**/dev/dashboard/
|
|||||||
|
|
||||||
# ---> Serena MCP memories
|
# ---> Serena MCP memories
|
||||||
.serena/
|
.serena/
|
||||||
|
|
||||||
|
# ---> Deploy script (로컬 전용)
|
||||||
|
deploy.sh
|
||||||
|
|||||||
38
CLAUDE.md
38
CLAUDE.md
@@ -35,32 +35,36 @@ sam_project:
|
|||||||
- `snapshot.txt`, `.DS_Store` 파일은 항상 제외
|
- `snapshot.txt`, `.DS_Store` 파일은 항상 제외
|
||||||
- develop에서 자유롭게 커밋 (커밋 메시지 정리 불필요)
|
- develop에서 자유롭게 커밋 (커밋 메시지 정리 불필요)
|
||||||
|
|
||||||
### main에 올리기 (기능별 squash merge)
|
### main에 올리기 (기능별 squash merge) — 필수 규칙
|
||||||
사용자가 "main에 올려줘" 또는 특정 기능을 main에 올리라고 지시할 때만 실행.
|
사용자가 "main에 올려줘" 또는 특정 기능을 main에 올리라고 지시할 때만 실행.
|
||||||
**절대 자동으로 main에 push하지 않음.**
|
**절대 자동으로 main에 push하지 않음.**
|
||||||
|
|
||||||
|
**🔴 반드시 기능별로 나눠서 올릴 것. 통째로 squash 금지.**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 기능별로 squash merge
|
# 실행 순서
|
||||||
git checkout main
|
git checkout main
|
||||||
git merge --squash develop # 또는 cherry-pick으로 특정 커밋만 선별
|
git pull origin main
|
||||||
git commit -m "feat: [기능명]"
|
|
||||||
|
# 1. develop 커밋 이력 분석 → 기능별 그룹 분류
|
||||||
|
git log --oneline main..develop
|
||||||
|
|
||||||
|
# 2. 기능별로 cherry-pick + squash commit (기능 수만큼 반복)
|
||||||
|
git cherry-pick --no-commit <기능A커밋1> <기능A커밋2> ...
|
||||||
|
git commit -m "feat: [기능A 설명]"
|
||||||
|
|
||||||
|
git cherry-pick --no-commit <기능B커밋1> <기능B커밋2> ...
|
||||||
|
git commit -m "feat: [기능B 설명]"
|
||||||
|
|
||||||
|
# 3. push 후 develop으로 복귀
|
||||||
git push origin main
|
git push origin main
|
||||||
git checkout develop
|
git checkout develop
|
||||||
```
|
```
|
||||||
|
|
||||||
기능별로 나눠서 올리는 경우:
|
**기능 분류 기준**:
|
||||||
```bash
|
- 같은 도메인/모듈 수정은 하나로 묶기 (예: CEO 대시보드 관련 커밋들)
|
||||||
# 예: "대시보드랑 거래처 main에 올려줘"
|
- CI/CD, 문서 등 인프라 변경은 별도 커밋 (예: chore: Jenkinsfile 정비)
|
||||||
git checkout main
|
- 커밋 메시지 타입: feat(기능), fix(버그), refactor(리팩토링), chore(설정/문서)
|
||||||
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`로 해당 기능만 롤백 가능**
|
**핵심: main에는 기능 단위 커밋만 → 문제 시 `git revert`로 해당 기능만 롤백 가능**
|
||||||
|
|
||||||
|
|||||||
158
deploy.sh
158
deploy.sh
@@ -1,158 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
#
|
|
||||||
# SAM React 배포 스크립트
|
|
||||||
# 사용법: ./deploy.sh [dev|prod]
|
|
||||||
#
|
|
||||||
|
|
||||||
set -e # 에러 발생 시 중단
|
|
||||||
|
|
||||||
# ===========================================
|
|
||||||
# 설정
|
|
||||||
# ===========================================
|
|
||||||
ENV="${1:-dev}"
|
|
||||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
|
||||||
BUILD_FILE="next-build.tar.gz"
|
|
||||||
|
|
||||||
# 개발 서버 설정
|
|
||||||
DEV_SSH="hskwon@114.203.209.83"
|
|
||||||
DEV_PATH="/home/webservice/react"
|
|
||||||
DEV_PM2="sam-react"
|
|
||||||
|
|
||||||
# 운영 서버 설정 (추후 설정)
|
|
||||||
# PROD_SSH="user@prod-server"
|
|
||||||
# PROD_PATH="/var/www/react"
|
|
||||||
# PROD_PM2="sam-react-prod"
|
|
||||||
|
|
||||||
# 환경별 설정 선택
|
|
||||||
case $ENV in
|
|
||||||
dev)
|
|
||||||
SSH_TARGET=$DEV_SSH
|
|
||||||
REMOTE_PATH=$DEV_PATH
|
|
||||||
PM2_APP=$DEV_PM2
|
|
||||||
;;
|
|
||||||
prod)
|
|
||||||
echo "❌ 운영 환경은 아직 설정되지 않았습니다."
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "❌ 알 수 없는 환경: $ENV"
|
|
||||||
echo "사용법: ./deploy.sh [dev|prod]"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# ===========================================
|
|
||||||
# 함수 정의
|
|
||||||
# ===========================================
|
|
||||||
log() {
|
|
||||||
echo ""
|
|
||||||
echo "=========================================="
|
|
||||||
echo "🚀 $1"
|
|
||||||
echo "=========================================="
|
|
||||||
}
|
|
||||||
|
|
||||||
error() {
|
|
||||||
echo ""
|
|
||||||
echo "❌ 에러: $1"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# ===========================================
|
|
||||||
# 1. 빌드
|
|
||||||
# ===========================================
|
|
||||||
log "Step 1/5: 빌드 시작"
|
|
||||||
|
|
||||||
# .env.local 백업
|
|
||||||
if [ -f .env.local ]; then
|
|
||||||
echo "📦 .env.local 백업..."
|
|
||||||
mv .env.local .env.local.bak
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 빌드 실행
|
|
||||||
echo "🔨 npm run build..."
|
|
||||||
npm run build || {
|
|
||||||
# 빌드 실패 시 .env.local 복원
|
|
||||||
if [ -f .env.local.bak ]; then
|
|
||||||
mv .env.local.bak .env.local
|
|
||||||
fi
|
|
||||||
error "빌드 실패"
|
|
||||||
}
|
|
||||||
|
|
||||||
# .env.local 복원
|
|
||||||
if [ -f .env.local.bak ]; then
|
|
||||||
echo "📦 .env.local 복원..."
|
|
||||||
mv .env.local.bak .env.local
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ 빌드 완료"
|
|
||||||
|
|
||||||
# ===========================================
|
|
||||||
# 2. 압축
|
|
||||||
# ===========================================
|
|
||||||
log "Step 2/5: 압축 시작"
|
|
||||||
|
|
||||||
# 기존 압축 파일 삭제
|
|
||||||
rm -f $BUILD_FILE
|
|
||||||
|
|
||||||
# .next 폴더 압축 (캐시 제외)
|
|
||||||
echo "📦 .next 폴더 압축 중..."
|
|
||||||
COPYFILE_DISABLE=1 tar --exclude='.next/cache' -czf $BUILD_FILE .next
|
|
||||||
|
|
||||||
# 파일 크기 확인
|
|
||||||
FILE_SIZE=$(ls -lh $BUILD_FILE | awk '{print $5}')
|
|
||||||
echo "✅ 압축 완료: $BUILD_FILE ($FILE_SIZE)"
|
|
||||||
|
|
||||||
# ===========================================
|
|
||||||
# 3. 업로드
|
|
||||||
# ===========================================
|
|
||||||
log "Step 3/5: 서버 업로드"
|
|
||||||
|
|
||||||
echo "📤 $SSH_TARGET:$REMOTE_PATH 로 업로드 중..."
|
|
||||||
scp $BUILD_FILE $SSH_TARGET:$REMOTE_PATH/
|
|
||||||
|
|
||||||
echo "✅ 업로드 완료"
|
|
||||||
|
|
||||||
# ===========================================
|
|
||||||
# 4. 원격 배포 실행
|
|
||||||
# ===========================================
|
|
||||||
log "Step 4/5: 원격 배포 실행"
|
|
||||||
|
|
||||||
echo "🔧 서버에서 배포 스크립트 실행 중..."
|
|
||||||
ssh $SSH_TARGET << EOF
|
|
||||||
cd $REMOTE_PATH
|
|
||||||
|
|
||||||
echo "🗑️ 기존 .next 폴더 삭제..."
|
|
||||||
rm -rf .next
|
|
||||||
|
|
||||||
echo "📦 압축 해제 중..."
|
|
||||||
tar xzf $BUILD_FILE
|
|
||||||
|
|
||||||
echo "🔄 PM2 재시작..."
|
|
||||||
pm2 restart $PM2_APP
|
|
||||||
|
|
||||||
echo "🧹 압축 파일 정리..."
|
|
||||||
rm -f $BUILD_FILE
|
|
||||||
|
|
||||||
echo "✅ 서버 배포 완료"
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# ===========================================
|
|
||||||
# 5. 정리
|
|
||||||
# ===========================================
|
|
||||||
log "Step 5/5: 로컬 정리"
|
|
||||||
|
|
||||||
echo "🧹 로컬 압축 파일 삭제..."
|
|
||||||
rm -f $BUILD_FILE
|
|
||||||
|
|
||||||
# ===========================================
|
|
||||||
# 완료
|
|
||||||
# ===========================================
|
|
||||||
echo ""
|
|
||||||
echo "=========================================="
|
|
||||||
echo "🎉 배포 완료!"
|
|
||||||
echo "=========================================="
|
|
||||||
echo "환경: $ENV"
|
|
||||||
echo "서버: $SSH_TARGET"
|
|
||||||
echo "경로: $REMOTE_PATH"
|
|
||||||
echo "시간: $(date '+%Y-%m-%d %H:%M:%S')"
|
|
||||||
echo "=========================================="
|
|
||||||
@@ -26,50 +26,44 @@ export function DayTabs({ activeDay, onDayChange, day1Progress, day2Progress }:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-3 md:mb-4 space-y-2 md:space-y-3">
|
<div className="mb-3 md:mb-4 space-y-2 md:space-y-3">
|
||||||
{/* 탭 버튼 */}
|
{/* 탭 버튼 - 모바일: 세로(아이콘→텍스트→숫자), 데스크탑: 가로 */}
|
||||||
<div className="flex gap-2 md:gap-3">
|
<div className="flex gap-2 md:gap-3">
|
||||||
{/* 1일차 탭 */}
|
{/* 기준/매뉴얼 심사 탭 */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onDayChange(1)}
|
onClick={() => onDayChange(1)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 flex items-center justify-center gap-1 sm:gap-2 py-1.5 sm:py-2 px-2 sm:px-3 rounded-lg border-2 transition-all',
|
'flex-1 flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 py-2 sm:py-2 px-2 sm:px-3 rounded-lg border-2 transition-all',
|
||||||
activeDay === 1
|
activeDay === 1
|
||||||
? 'bg-blue-600 border-blue-600 text-white shadow-lg'
|
? 'bg-blue-600 border-blue-600 text-white shadow-lg'
|
||||||
: 'bg-white border-gray-200 text-gray-700 hover:border-blue-300 hover:bg-blue-50'
|
: 'bg-white border-gray-200 text-gray-700 hover:border-blue-300 hover:bg-blue-50'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Calendar className="h-4 w-4 shrink-0" />
|
<Calendar className="h-4 w-4 shrink-0" />
|
||||||
<span className="font-medium text-xs sm:text-sm">
|
<span className="font-medium text-xs sm:text-sm">기준/매뉴얼 심사</span>
|
||||||
<span className="hidden sm:inline">기준/매뉴얼 심사</span>
|
|
||||||
<span className="sm:hidden">1일차</span>
|
|
||||||
</span>
|
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 rounded-full ml-1 sm:ml-2 shrink-0',
|
'text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 rounded-full shrink-0',
|
||||||
activeDay === 1 ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600'
|
activeDay === 1 ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600'
|
||||||
)}>
|
)}>
|
||||||
{day1Progress.completed}/{day1Progress.total}
|
{day1Progress.completed}/{day1Progress.total}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 2일차 탭 */}
|
{/* 로트 추적 심사 탭 */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onDayChange(2)}
|
onClick={() => onDayChange(2)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 flex items-center justify-center gap-1 sm:gap-2 py-1.5 sm:py-2 px-2 sm:px-3 rounded-lg border-2 transition-all',
|
'flex-1 flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 py-2 sm:py-2 px-2 sm:px-3 rounded-lg border-2 transition-all',
|
||||||
activeDay === 2
|
activeDay === 2
|
||||||
? 'bg-blue-600 border-blue-600 text-white shadow-lg'
|
? 'bg-blue-600 border-blue-600 text-white shadow-lg'
|
||||||
: 'bg-white border-gray-200 text-gray-700 hover:border-blue-300 hover:bg-blue-50'
|
: 'bg-white border-gray-200 text-gray-700 hover:border-blue-300 hover:bg-blue-50'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Calendar className="h-4 w-4 shrink-0" />
|
<Calendar className="h-4 w-4 shrink-0" />
|
||||||
<span className="font-medium text-xs sm:text-sm">
|
<span className="font-medium text-xs sm:text-sm">로트 추적 심사</span>
|
||||||
<span className="hidden sm:inline">로트 추적 심사</span>
|
|
||||||
<span className="sm:hidden">2일차</span>
|
|
||||||
</span>
|
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 rounded-full ml-1 sm:ml-2 shrink-0',
|
'text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 rounded-full shrink-0',
|
||||||
activeDay === 2 ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600'
|
activeDay === 2 ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600'
|
||||||
)}>
|
)}>
|
||||||
{day2Progress.completed}/{day2Progress.total}
|
{day2Progress.completed}/{day2Progress.total}
|
||||||
@@ -104,10 +98,7 @@ export function DayTabs({ activeDay, onDayChange, day1Progress, day2Progress }:
|
|||||||
|
|
||||||
{/* 1일차 진행률 */}
|
{/* 1일차 진행률 */}
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
<span className="text-xs sm:text-sm text-gray-600 w-14 sm:w-28 shrink-0">
|
<span className="text-xs sm:text-sm text-gray-600 w-20 sm:w-28 shrink-0">기준/매뉴얼 심사</span>
|
||||||
<span className="hidden sm:inline">기준/매뉴얼 심사</span>
|
|
||||||
<span className="sm:hidden">1일차</span>
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 h-1.5 sm:h-2 bg-gray-200 rounded-full overflow-hidden">
|
<div className="flex-1 h-1.5 sm:h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -127,10 +118,7 @@ export function DayTabs({ activeDay, onDayChange, day1Progress, day2Progress }:
|
|||||||
|
|
||||||
{/* 2일차 진행률 */}
|
{/* 2일차 진행률 */}
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
<span className="text-xs sm:text-sm text-gray-600 w-14 sm:w-28 shrink-0">
|
<span className="text-xs sm:text-sm text-gray-600 w-20 sm:w-28 shrink-0">로트 추적 심사</span>
|
||||||
<span className="hidden sm:inline">로트 추적 심사</span>
|
|
||||||
<span className="sm:hidden">2일차</span>
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 h-1.5 sm:h-2 bg-gray-200 rounded-full overflow-hidden">
|
<div className="flex-1 h-1.5 sm:h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useTransition, useRef } from 'react';
|
import { useState, useCallback, useEffect, useTransition, useRef, useMemo } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { usePermission } from '@/hooks/usePermission';
|
import { usePermission } from '@/hooks/usePermission';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { Trash2, Send, Save, Eye, Loader2 } from 'lucide-react';
|
import { Trash2, Send, Save, Eye } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
import { IntegratedDetailTemplate, type ActionItem } from '@/components/templates/IntegratedDetailTemplate';
|
||||||
import {
|
import {
|
||||||
documentCreateConfig,
|
documentCreateConfig,
|
||||||
documentEditConfig,
|
documentEditConfig,
|
||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
getEmployees,
|
getEmployees,
|
||||||
} from './actions';
|
} from './actions';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { BasicInfoSection } from './BasicInfoSection';
|
import { BasicInfoSection } from './BasicInfoSection';
|
||||||
import { ApprovalLineSection } from './ApprovalLineSection';
|
import { ApprovalLineSection } from './ApprovalLineSection';
|
||||||
import { ReferenceSection } from './ReferenceSection';
|
import { ReferenceSection } from './ReferenceSection';
|
||||||
@@ -546,54 +545,13 @@ export function DocumentCreate() {
|
|||||||
? documentCopyConfig
|
? documentCopyConfig
|
||||||
: documentCreateConfig;
|
: documentCreateConfig;
|
||||||
|
|
||||||
// 헤더 액션 버튼 렌더링
|
// 헤더 액션 버튼 (config 배열 → 모바일 아이콘 패턴 자동 적용)
|
||||||
const renderHeaderActions = useCallback(() => {
|
const headerActionItems = useMemo<ActionItem[]>(() => [
|
||||||
return (
|
{ icon: Eye, label: '미리보기', onClick: handlePreview, variant: 'outline' },
|
||||||
<div className="flex items-center gap-2">
|
{ icon: Trash2, label: '삭제', onClick: handleDelete, variant: 'outline', hidden: !canDelete, disabled: isPending },
|
||||||
<Button variant="outline" size="sm" onClick={handlePreview}>
|
{ icon: Send, label: '상신', onClick: handleSubmit, variant: 'outline', disabled: isPending || !canCreate, loading: isPending },
|
||||||
<Eye className="w-4 h-4 mr-1" />
|
{ icon: Save, label: isEditMode ? '저장' : '임시저장', onClick: handleSaveDraft, variant: 'outline', disabled: isPending, loading: isPending },
|
||||||
미리보기
|
], [handlePreview, handleDelete, handleSubmit, handleSaveDraft, isPending, isEditMode, canCreate, canDelete]);
|
||||||
</Button>
|
|
||||||
{canDelete && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4 mr-1" />
|
|
||||||
삭제
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={isPending || !canCreate}
|
|
||||||
>
|
|
||||||
{isPending ? (
|
|
||||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Send className="w-4 h-4 mr-1" />
|
|
||||||
)}
|
|
||||||
상신
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSaveDraft}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
{isPending ? (
|
|
||||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="w-4 h-4 mr-1" />
|
|
||||||
)}
|
|
||||||
{isEditMode ? '저장' : '임시저장'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}, [handlePreview, handleDelete, handleSubmit, handleSaveDraft, isPending, isEditMode, canCreate, canDelete]);
|
|
||||||
|
|
||||||
// 폼 컨텐츠 렌더링
|
// 폼 컨텐츠 렌더링
|
||||||
const renderFormContent = useCallback(() => {
|
const renderFormContent = useCallback(() => {
|
||||||
@@ -622,7 +580,7 @@ export function DocumentCreate() {
|
|||||||
isLoading={isLoadingDocument}
|
isLoading={isLoadingDocument}
|
||||||
onBack={handleBack}
|
onBack={handleBack}
|
||||||
renderForm={renderFormContent}
|
renderForm={renderFormContent}
|
||||||
headerActions={renderHeaderActions()}
|
headerActionItems={headerActionItems}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 미리보기 모달 */}
|
{/* 미리보기 모달 */}
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
|
|||||||
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
|
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
|
||||||
<YAxis tickFormatter={formatAmount} tick={{ fontSize: 11 }} />
|
<YAxis tickFormatter={formatAmount} tick={{ fontSize: 11 }} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: number | undefined) => [formatKoreanAmount(value ?? 0), '매입']}
|
formatter={(value) => [formatKoreanAmount(Number(value) || 0), '매입']}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="amount" fill="#f59e0b" radius={[4, 4, 0, 0]} />
|
<Bar dataKey="amount" fill="#f59e0b" radius={[4, 4, 0, 0]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export function SalesStatusSection({ data }: SalesStatusSectionProps) {
|
|||||||
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
|
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
|
||||||
<YAxis tickFormatter={formatAmount} tick={{ fontSize: 11 }} />
|
<YAxis tickFormatter={formatAmount} tick={{ fontSize: 11 }} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: number | undefined) => [formatKoreanAmount(value ?? 0), '매출']}
|
formatter={(value) => [formatKoreanAmount(Number(value) || 0), '매출']}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="amount" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
<Bar dataKey="amount" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
@@ -161,7 +161,7 @@ export function SalesStatusSection({ data }: SalesStatusSectionProps) {
|
|||||||
<XAxis type="number" tickFormatter={formatAmount} tick={{ fontSize: 11 }} />
|
<XAxis type="number" tickFormatter={formatAmount} tick={{ fontSize: 11 }} />
|
||||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 12 }} width={80} />
|
<YAxis type="category" dataKey="name" tick={{ fontSize: 12 }} width={80} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: number | undefined) => [formatKoreanAmount(value ?? 0), '매출']}
|
formatter={(value) => [formatKoreanAmount(Number(value) || 0), '매출']}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="amount" fill="#10b981" radius={[0, 4, 4, 0]} />
|
<Bar dataKey="amount" fill="#10b981" radius={[0, 4, 4, 0]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react';
|
|||||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||||
import { FolderTree, Plus, GripVertical, Pencil, Trash2, Loader2 } from 'lucide-react';
|
import { FolderTree, Plus, GripVertical, Pencil, Trash2, Loader2 } from 'lucide-react';
|
||||||
|
import { ReorderButtons } from '@/components/molecules';
|
||||||
import { ContentSkeleton } from '@/components/ui/skeleton';
|
import { ContentSkeleton } from '@/components/ui/skeleton';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -206,6 +207,33 @@ export function CategoryManagement() {
|
|||||||
setDraggedItem(index);
|
setDraggedItem(index);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 화살표 버튼으로 순서 변경
|
||||||
|
const handleMoveItem = async (fromIndex: number, toIndex: number) => {
|
||||||
|
const newCategories = [...categories];
|
||||||
|
const [moved] = newCategories.splice(fromIndex, 1);
|
||||||
|
newCategories.splice(toIndex, 0, moved);
|
||||||
|
const reordered = newCategories.map((category, idx) => ({ ...category, order: idx + 1 }));
|
||||||
|
setCategories(reordered);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = reordered.map((category, idx) => ({
|
||||||
|
id: category.id,
|
||||||
|
sort_order: idx + 1,
|
||||||
|
}));
|
||||||
|
const result = await reorderCategories(items);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('순서가 변경되었습니다.');
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '순서 변경에 실패했습니다.');
|
||||||
|
loadCategories();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isNextRedirectError(error)) throw error;
|
||||||
|
toast.error('순서 변경에 실패했습니다.');
|
||||||
|
loadCategories();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 키보드로 추가 (한글 IME 조합 중에는 무시)
|
// 키보드로 추가 (한글 IME 조합 중에는 무시)
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
||||||
@@ -217,7 +245,7 @@ export function CategoryManagement() {
|
|||||||
<PageLayout>
|
<PageLayout>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="카테고리관리"
|
title="카테고리관리"
|
||||||
description="카테고리를 등록하고 관리합니다. 드래그하여 순서를 변경할 수 있습니다."
|
description="카테고리를 등록하고 관리합니다. 화살표 버튼 또는 드래그하여 순서를 변경할 수 있습니다."
|
||||||
icon={FolderTree}
|
icon={FolderTree}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -267,8 +295,18 @@ export function CategoryManagement() {
|
|||||||
draggedItem === index ? 'opacity-50 bg-muted' : ''
|
draggedItem === index ? 'opacity-50 bg-muted' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* 드래그 핸들 */}
|
{/* 드래그 핸들 (PC만) */}
|
||||||
<GripVertical className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
<GripVertical className="h-5 w-5 text-muted-foreground flex-shrink-0 hidden md:block" />
|
||||||
|
|
||||||
|
{/* 순서 변경 버튼 */}
|
||||||
|
<ReorderButtons
|
||||||
|
onMoveUp={() => handleMoveItem(index, index - 1)}
|
||||||
|
onMoveDown={() => handleMoveItem(index, index + 1)}
|
||||||
|
isFirst={index === 0}
|
||||||
|
isLast={index === categories.length - 1}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 순서 번호 */}
|
{/* 순서 번호 */}
|
||||||
<span className="text-sm text-muted-foreground w-8">
|
<span className="text-sm text-muted-foreground w-8">
|
||||||
@@ -316,7 +354,7 @@ export function CategoryManagement() {
|
|||||||
|
|
||||||
{/* 안내 문구 */}
|
{/* 안내 문구 */}
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
※ 카테고리 순서는 드래그 앤 드롭으로 변경할 수 있습니다.
|
※ 화살표 버튼으로 순서를 변경할 수 있습니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,12 @@
|
|||||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { FileText, Stamp, Gavel, Trash2, Save } from 'lucide-react';
|
||||||
import { getExpenseItemOptions, updateEstimate, type ExpenseItemOption } from './actions';
|
import { getExpenseItemOptions, updateEstimate, type ExpenseItemOption } from './actions';
|
||||||
import { createBiddingFromEstimate } from '../bidding/actions';
|
import { createBiddingFromEstimate } from '../bidding/actions';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { ConfirmDialog, DeleteConfirmDialog, SaveConfirmDialog } from '@/components/ui/confirm-dialog';
|
import { ConfirmDialog, DeleteConfirmDialog, SaveConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
import { IntegratedDetailTemplate, type ActionItem } from '@/components/templates/IntegratedDetailTemplate';
|
||||||
import { estimateConfig } from './estimateConfig';
|
import { estimateConfig } from './estimateConfig';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type {
|
import type {
|
||||||
@@ -541,38 +540,19 @@ export default function EstimateDetailForm({
|
|||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ===== 헤더 버튼 =====
|
// ===== 헤더 버튼 (config 배열 → 모바일 아이콘 패턴 자동 적용) =====
|
||||||
const renderHeaderActions = useCallback(() => {
|
const headerActionItems = useMemo<ActionItem[]>(() => {
|
||||||
if (isViewMode) {
|
if (isViewMode) {
|
||||||
return (
|
return [
|
||||||
<div className="flex gap-2">
|
{ icon: FileText, label: '견적서 보기', onClick: () => setShowDocumentModal(true), variant: 'outline' },
|
||||||
<Button variant="outline" onClick={() => setShowDocumentModal(true)}>
|
{ icon: Stamp, label: '전자결재', onClick: () => setShowApprovalModal(true), variant: 'outline' },
|
||||||
견적서 보기
|
{ icon: Gavel, label: '입찰 등록', onClick: handleRegisterBidding, variant: 'outline', className: 'text-green-600 border-green-200 hover:bg-green-50' },
|
||||||
</Button>
|
];
|
||||||
<Button variant="outline" onClick={() => setShowApprovalModal(true)}>
|
|
||||||
전자결재
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={handleRegisterBidding} className="text-green-600 border-green-200 hover:bg-green-50">
|
|
||||||
입찰 등록
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return (
|
return [
|
||||||
<div className="flex gap-2">
|
{ icon: Trash2, label: '삭제', onClick: handleDelete, variant: 'outline', className: 'text-red-500 border-red-200 hover:bg-red-50' },
|
||||||
<Button
|
{ icon: Save, label: '저장', onClick: handleSave, variant: 'default', className: 'bg-blue-500 hover:bg-blue-600', disabled: isLoading, loading: isLoading },
|
||||||
variant="outline"
|
];
|
||||||
className="text-red-500 border-red-200 hover:bg-red-50"
|
|
||||||
onClick={handleDelete}
|
|
||||||
>
|
|
||||||
삭제
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
|
||||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
|
||||||
저장
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}, [isViewMode, isLoading, handleDelete, handleSave, handleRegisterBidding]);
|
}, [isViewMode, isLoading, handleDelete, handleSave, handleRegisterBidding]);
|
||||||
|
|
||||||
// ===== 컨텐츠 렌더링 =====
|
// ===== 컨텐츠 렌더링 =====
|
||||||
@@ -694,7 +674,7 @@ export default function EstimateDetailForm({
|
|||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
renderView={renderContent}
|
renderView={renderContent}
|
||||||
renderForm={renderContent}
|
renderForm={renderContent}
|
||||||
headerActions={renderHeaderActions()}
|
headerActionItems={headerActionItems}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 전자결재 모달 */}
|
{/* 전자결재 모달 */}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { ArrowLeft, Edit, GripVertical, Plus, Trash2 } from 'lucide-react';
|
import { ArrowLeft, Edit, GripVertical, Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { ReorderButtons } from '@/components/molecules';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -144,6 +145,23 @@ export function ChecklistDetail({ checklist }: ChecklistDetailProps) {
|
|||||||
[dragIndex, handleDragEnd, checklist.id]
|
[dragIndex, handleDragEnd, checklist.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 화살표 버튼으로 순서 변경
|
||||||
|
const handleMoveItem = useCallback((fromIndex: number, toIndex: number) => {
|
||||||
|
setItems((prev) => {
|
||||||
|
const updated = [...prev];
|
||||||
|
const [moved] = updated.splice(fromIndex, 1);
|
||||||
|
updated.splice(toIndex, 0, moved);
|
||||||
|
const reordered = updated.map((item, i) => ({ ...item, order: i + 1 }));
|
||||||
|
|
||||||
|
reorderChecklistItems(
|
||||||
|
checklist.id,
|
||||||
|
reordered.map((it) => ({ id: it.id, order: it.order }))
|
||||||
|
);
|
||||||
|
|
||||||
|
return reordered;
|
||||||
|
});
|
||||||
|
}, [checklist.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<PageHeader title="점검표 상세" />
|
<PageHeader title="점검표 상세" />
|
||||||
@@ -204,7 +222,7 @@ export function ChecklistDetail({ checklist }: ChecklistDetailProps) {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/30">
|
<tr className="border-b bg-muted/30">
|
||||||
<th className="w-10 px-3 py-3 text-center text-xs font-medium text-muted-foreground" />
|
<th className="w-16 px-3 py-3 text-center text-xs font-medium text-muted-foreground" />
|
||||||
<th className="w-14 px-3 py-3 text-center text-xs font-medium text-muted-foreground">
|
<th className="w-14 px-3 py-3 text-center text-xs font-medium text-muted-foreground">
|
||||||
No.
|
No.
|
||||||
</th>
|
</th>
|
||||||
@@ -242,10 +260,19 @@ export function ChecklistDetail({ checklist }: ChecklistDetailProps) {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
className="w-10 px-3 py-3 text-center cursor-grab active:cursor-grabbing"
|
className="w-16 px-3 py-3 text-center"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<GripVertical className="h-4 w-4 text-muted-foreground mx-auto" />
|
<div className="flex items-center justify-center gap-0.5">
|
||||||
|
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab active:cursor-grabbing" />
|
||||||
|
<ReorderButtons
|
||||||
|
onMoveUp={() => handleMoveItem(index, index - 1)}
|
||||||
|
onMoveDown={() => handleMoveItem(index, index + 1)}
|
||||||
|
isFirst={index === 0}
|
||||||
|
isLast={index === items.length - 1}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="w-14 px-3 py-3 text-center text-sm text-muted-foreground">
|
<td className="w-14 px-3 py-3 text-center text-sm text-muted-foreground">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
|
|||||||
262
src/components/hr/CardManagement/CardForm.tsx
Executable file
262
src/components/hr/CardManagement/CardForm.tsx
Executable file
@@ -0,0 +1,262 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||||
|
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { CreditCard, ArrowLeft, Save } from 'lucide-react';
|
||||||
|
import type { Card as CardType, CardFormData, CardCompany, CardStatus } from './types';
|
||||||
|
import { CARD_COMPANIES, CARD_STATUS_LABELS } from './types';
|
||||||
|
import { getActiveEmployees } from './actions';
|
||||||
|
|
||||||
|
interface CardFormProps {
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
card?: CardType;
|
||||||
|
onSubmit: (data: CardFormData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardForm({ mode, card, onSubmit }: CardFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [formData, setFormData] = useState<CardFormData>({
|
||||||
|
cardCompany: '',
|
||||||
|
cardType: '',
|
||||||
|
cardNumber: '',
|
||||||
|
cardName: '',
|
||||||
|
alias: '',
|
||||||
|
expiryDate: '',
|
||||||
|
csv: '',
|
||||||
|
paymentDay: '',
|
||||||
|
pinPrefix: '',
|
||||||
|
totalLimit: 0,
|
||||||
|
usedAmount: 0,
|
||||||
|
remainingLimit: 0,
|
||||||
|
status: 'active',
|
||||||
|
userId: '',
|
||||||
|
memo: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 직원 목록 상태
|
||||||
|
const [employees, setEmployees] = useState<Array<{ id: string; label: string }>>([]);
|
||||||
|
const [isLoadingEmployees, setIsLoadingEmployees] = useState(true);
|
||||||
|
|
||||||
|
// 직원 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadEmployees = async () => {
|
||||||
|
setIsLoadingEmployees(true);
|
||||||
|
const result = await getActiveEmployees();
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setEmployees(result.data);
|
||||||
|
}
|
||||||
|
setIsLoadingEmployees(false);
|
||||||
|
};
|
||||||
|
loadEmployees();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 수정 모드일 때 기존 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === 'edit' && card) {
|
||||||
|
setFormData({
|
||||||
|
cardCompany: card.cardCompany,
|
||||||
|
cardType: card.cardType || '',
|
||||||
|
cardNumber: card.cardNumber,
|
||||||
|
cardName: card.cardName,
|
||||||
|
alias: card.alias || '',
|
||||||
|
expiryDate: card.expiryDate,
|
||||||
|
csv: card.csv || '',
|
||||||
|
paymentDay: card.paymentDay || '',
|
||||||
|
pinPrefix: card.pinPrefix,
|
||||||
|
totalLimit: card.totalLimit || 0,
|
||||||
|
usedAmount: card.usedAmount || 0,
|
||||||
|
remainingLimit: card.remainingLimit || 0,
|
||||||
|
status: card.status,
|
||||||
|
userId: card.user?.id || '',
|
||||||
|
memo: card.memo || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [mode, card]);
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (mode === 'edit' && card) {
|
||||||
|
router.push(`/ko/hr/card-management/${card.id}`);
|
||||||
|
} else {
|
||||||
|
router.push('/ko/hr/card-management');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 카드번호 포맷팅 (1234-1234-1234-1234)
|
||||||
|
const handleCardNumberChange = (value: string) => {
|
||||||
|
const digits = value.replace(/\D/g, '').slice(0, 16);
|
||||||
|
const parts = digits.match(/.{1,4}/g) || [];
|
||||||
|
const formatted = parts.join('-');
|
||||||
|
setFormData(prev => ({ ...prev, cardNumber: formatted }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 유효기간 포맷팅 (MMYY)
|
||||||
|
const handleExpiryDateChange = (value: string) => {
|
||||||
|
const digits = value.replace(/\D/g, '').slice(0, 4);
|
||||||
|
setFormData(prev => ({ ...prev, expiryDate: digits }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 비밀번호 앞 2자리
|
||||||
|
const handlePinPrefixChange = (value: string) => {
|
||||||
|
const digits = value.replace(/\D/g, '').slice(0, 2);
|
||||||
|
setFormData(prev => ({ ...prev, pinPrefix: digits }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<PageHeader
|
||||||
|
title={mode === 'create' ? '카드 등록' : '카드 수정'}
|
||||||
|
description={mode === 'create' ? '새로운 카드를 등록합니다' : '카드 정보를 수정합니다'}
|
||||||
|
icon={CreditCard}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cardCompany">카드사</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.cardCompany}
|
||||||
|
onValueChange={(value) => setFormData(prev => ({ ...prev, cardCompany: value as CardCompany }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="cardCompany">
|
||||||
|
<SelectValue placeholder="카드사를 선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CARD_COMPANIES.map((company) => (
|
||||||
|
<SelectItem key={company.value} value={company.value}>
|
||||||
|
{company.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cardNumber">카드번호</Label>
|
||||||
|
<Input
|
||||||
|
id="cardNumber"
|
||||||
|
value={formData.cardNumber}
|
||||||
|
onChange={(e) => handleCardNumberChange(e.target.value)}
|
||||||
|
placeholder="1234-1234-1234-1234"
|
||||||
|
maxLength={19}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="expiryDate">유효기간</Label>
|
||||||
|
<Input
|
||||||
|
id="expiryDate"
|
||||||
|
value={formData.expiryDate}
|
||||||
|
onChange={(e) => handleExpiryDateChange(e.target.value)}
|
||||||
|
placeholder="MMYY"
|
||||||
|
maxLength={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="pinPrefix">카드 비밀번호 앞 2자리</Label>
|
||||||
|
<Input
|
||||||
|
id="pinPrefix"
|
||||||
|
type="password"
|
||||||
|
value={formData.pinPrefix}
|
||||||
|
onChange={(e) => handlePinPrefixChange(e.target.value)}
|
||||||
|
placeholder="**"
|
||||||
|
maxLength={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cardName">카드명</Label>
|
||||||
|
<Input
|
||||||
|
id="cardName"
|
||||||
|
value={formData.cardName}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, cardName: e.target.value }))}
|
||||||
|
placeholder="카드명을 입력해주세요"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="status">상태</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.status}
|
||||||
|
onValueChange={(value) => setFormData(prev => ({ ...prev, status: value as CardStatus }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="status">
|
||||||
|
<SelectValue placeholder="상태 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="active">{CARD_STATUS_LABELS.active}</SelectItem>
|
||||||
|
<SelectItem value="suspended">{CARD_STATUS_LABELS.suspended}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 사용자 정보 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">사용자 정보</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="userId">부서 / 이름 / 직책</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.userId}
|
||||||
|
onValueChange={(value) => setFormData(prev => ({ ...prev, userId: value }))}
|
||||||
|
disabled={isLoadingEmployees}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="userId">
|
||||||
|
<SelectValue placeholder={isLoadingEmployees ? '직원 목록 로딩 중...' : '선택해서 해당 카드의 사용자로 설정'} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{employees.map((employee) => (
|
||||||
|
<SelectItem key={employee.id} value={employee.id}>
|
||||||
|
{employee.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 버튼 영역 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button type="button" variant="outline" onClick={handleBack}>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
{mode === 'create' ? '등록' : '저장'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Edit,
|
Edit,
|
||||||
Unlink
|
Unlink
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { ReorderButtons } from '@/components/molecules';
|
||||||
|
|
||||||
// 입력방식 옵션 (ItemMasterDataManagement에서 사용하는 상수)
|
// 입력방식 옵션 (ItemMasterDataManagement에서 사용하는 상수)
|
||||||
const INPUT_TYPE_OPTIONS = [
|
const INPUT_TYPE_OPTIONS = [
|
||||||
@@ -25,9 +26,11 @@ interface DraggableFieldProps {
|
|||||||
moveField: (dragFieldId: number, hoverFieldId: number) => void;
|
moveField: (dragFieldId: number, hoverFieldId: number) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
|
prevFieldId?: number;
|
||||||
|
nextFieldId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DraggableField({ field, index, moveField, onDelete, onEdit }: DraggableFieldProps) {
|
export function DraggableField({ field, index, moveField, onDelete, onEdit, prevFieldId, nextFieldId }: DraggableFieldProps) {
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
const handleDragStart = (e: React.DragEvent) => {
|
const handleDragStart = (e: React.DragEvent) => {
|
||||||
@@ -79,7 +82,14 @@ export function DraggableField({ field, index, moveField, onDelete, onEdit }: Dr
|
|||||||
>
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<GripVertical className="h-4 w-4 text-gray-400" />
|
<GripVertical className="h-4 w-4 text-gray-400 hidden md:block" />
|
||||||
|
<ReorderButtons
|
||||||
|
onMoveUp={() => prevFieldId !== undefined && moveField(field.id, prevFieldId)}
|
||||||
|
onMoveDown={() => nextFieldId !== undefined && moveField(field.id, nextFieldId)}
|
||||||
|
isFirst={prevFieldId === undefined}
|
||||||
|
isLast={nextFieldId === undefined}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
<span className="text-sm">{field.field_name}</span>
|
<span className="text-sm">{field.field_name}</span>
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{INPUT_TYPE_OPTIONS.find(t => t.value === field.field_type)?.label || field.field_type}
|
{INPUT_TYPE_OPTIONS.find(t => t.value === field.field_type)?.label || field.field_type}
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import {
|
|||||||
X,
|
X,
|
||||||
Unlink
|
Unlink
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { ReorderButtons } from '@/components/molecules';
|
||||||
|
|
||||||
interface DraggableSectionProps {
|
interface DraggableSectionProps {
|
||||||
section: ItemSection;
|
section: ItemSection;
|
||||||
index: number;
|
index: number;
|
||||||
|
totalSections: number;
|
||||||
moveSection: (dragIndex: number, hoverIndex: number) => void;
|
moveSection: (dragIndex: number, hoverIndex: number) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onEditTitle: (id: number, title: string) => void;
|
onEditTitle: (id: number, title: string) => void;
|
||||||
@@ -28,6 +30,7 @@ interface DraggableSectionProps {
|
|||||||
export function DraggableSection({
|
export function DraggableSection({
|
||||||
section,
|
section,
|
||||||
index,
|
index,
|
||||||
|
totalSections,
|
||||||
moveSection,
|
moveSection,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEditTitle,
|
onEditTitle,
|
||||||
@@ -87,7 +90,14 @@ export function DraggableSection({
|
|||||||
<div className="bg-blue-50 border-b p-3">
|
<div className="bg-blue-50 border-b p-3">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
<GripVertical className="h-4 w-4 text-gray-400" style={{ cursor: 'move' }} />
|
<GripVertical className="h-4 w-4 text-gray-400 hidden md:block" style={{ cursor: 'move' }} />
|
||||||
|
<ReorderButtons
|
||||||
|
onMoveUp={() => moveSection(index, index - 1)}
|
||||||
|
onMoveDown={() => moveSection(index, index + 1)}
|
||||||
|
isFirst={index === 0}
|
||||||
|
isLast={index === totalSections - 1}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
<FileText className="h-4 w-4 text-blue-600" />
|
<FileText className="h-4 w-4 text-blue-600" />
|
||||||
{editingSectionId === section.id ? (
|
{editingSectionId === section.id ? (
|
||||||
<div className="flex items-center gap-2 flex-1">
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
|||||||
@@ -380,6 +380,7 @@ export function HierarchyTab({
|
|||||||
key={`section-${section.id}-${index}`}
|
key={`section-${section.id}-${index}`}
|
||||||
section={section}
|
section={section}
|
||||||
index={index}
|
index={index}
|
||||||
|
totalSections={selectedPage.sections.length}
|
||||||
moveSection={(dragIndex, hoverIndex) => {
|
moveSection={(dragIndex, hoverIndex) => {
|
||||||
moveSection(dragIndex, hoverIndex);
|
moveSection(dragIndex, hoverIndex);
|
||||||
}}
|
}}
|
||||||
@@ -469,7 +470,7 @@ export function HierarchyTab({
|
|||||||
) : (
|
) : (
|
||||||
section.fields
|
section.fields
|
||||||
.sort((a, b) => (a.order_no ?? 0) - (b.order_no ?? 0))
|
.sort((a, b) => (a.order_no ?? 0) - (b.order_no ?? 0))
|
||||||
.map((field, fieldIndex) => (
|
.map((field, fieldIndex, sortedFields) => (
|
||||||
<DraggableField
|
<DraggableField
|
||||||
key={`${section.id}-${field.id}-${fieldIndex}`}
|
key={`${section.id}-${field.id}-${fieldIndex}`}
|
||||||
field={field}
|
field={field}
|
||||||
@@ -479,6 +480,8 @@ export function HierarchyTab({
|
|||||||
setConfirmAction({ type: 'unlinkField', pageId: String(selectedPage.id), sectionId: String(section.id), fieldId: String(field.id) });
|
setConfirmAction({ type: 'unlinkField', pageId: String(selectedPage.id), sectionId: String(section.id), fieldId: String(field.id) });
|
||||||
}}
|
}}
|
||||||
onEdit={() => handleEditField(String(section.id), field)}
|
onEdit={() => handleEditField(String(section.id), field)}
|
||||||
|
prevFieldId={fieldIndex > 0 ? sortedFields[fieldIndex - 1].id : undefined}
|
||||||
|
nextFieldId={fieldIndex < sortedFields.length - 1 ? sortedFields[fieldIndex + 1].id : undefined}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
61
src/components/molecules/ReorderButtons.tsx
Normal file
61
src/components/molecules/ReorderButtons.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { ChevronUp, ChevronDown } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ReorderButtonsProps {
|
||||||
|
onMoveUp: () => void;
|
||||||
|
onMoveDown: () => void;
|
||||||
|
isFirst: boolean;
|
||||||
|
isLast: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
size?: 'sm' | 'xs';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReorderButtons({
|
||||||
|
onMoveUp,
|
||||||
|
onMoveDown,
|
||||||
|
isFirst,
|
||||||
|
isLast,
|
||||||
|
disabled = false,
|
||||||
|
size = 'sm',
|
||||||
|
className,
|
||||||
|
}: ReorderButtonsProps) {
|
||||||
|
const iconSize = size === 'xs' ? 'h-3 w-3' : 'h-4 w-4';
|
||||||
|
const btnSize = size === 'xs' ? 'h-5 w-5' : 'h-6 w-6';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col', className)}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(btnSize, 'p-0')}
|
||||||
|
disabled={disabled || isFirst}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
onMoveUp();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronUp className={iconSize} />
|
||||||
|
<span className="sr-only">위로 이동</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(btnSize, 'p-0')}
|
||||||
|
disabled={disabled || isLast}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
onMoveDown();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronDown className={iconSize} />
|
||||||
|
<span className="sr-only">아래로 이동</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,4 +13,6 @@ export { YearQuarterFilter } from "./YearQuarterFilter";
|
|||||||
export type { Quarter } from "./YearQuarterFilter";
|
export type { Quarter } from "./YearQuarterFilter";
|
||||||
|
|
||||||
export { GenericCRUDDialog } from "./GenericCRUDDialog";
|
export { GenericCRUDDialog } from "./GenericCRUDDialog";
|
||||||
export type { GenericCRUDDialogProps, CRUDFieldDefinition } from "./GenericCRUDDialog";
|
export type { GenericCRUDDialogProps, CRUDFieldDefinition } from "./GenericCRUDDialog";
|
||||||
|
|
||||||
|
export { ReorderButtons } from "./ReorderButtons";
|
||||||
@@ -346,23 +346,23 @@ export function PricingTableForm({ mode, initialData }: PricingTableFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 거래등급별 판매단가 */}
|
{/* 거래등급별 판매단가 */}
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full min-w-[600px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/30">
|
<tr className="border-b bg-muted/30">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground w-[140px]">
|
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground min-w-[140px]">
|
||||||
거래등급
|
거래등급
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-muted-foreground w-[120px]">
|
<th className="px-4 py-3 text-right text-xs font-medium text-muted-foreground min-w-[120px]">
|
||||||
마진율
|
마진율
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-muted-foreground w-[120px]">
|
<th className="px-4 py-3 text-right text-xs font-medium text-muted-foreground min-w-[120px]">
|
||||||
판매단가
|
판매단가
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground">
|
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground min-w-[140px]">
|
||||||
비고
|
비고
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-center text-xs font-medium text-muted-foreground w-[80px]">
|
<th className="px-4 py-3 text-center text-xs font-medium text-muted-foreground min-w-[60px]">
|
||||||
<Button size="sm" variant="outline" onClick={handleAddRow} className="h-7 text-xs">
|
<Button size="sm" variant="outline" onClick={handleAddRow} className="h-7 text-xs">
|
||||||
추가
|
추가
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { ArrowLeft, Edit, GripVertical, Plus, Package, Trash2 } from 'lucide-react';
|
import { ArrowLeft, Edit, GripVertical, Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { ReorderButtons } from '@/components/molecules';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -49,7 +50,7 @@ export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps)
|
|||||||
// 드래그 상태
|
// 드래그 상태
|
||||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
const dragNodeRef = useRef<HTMLTableRowElement | null>(null);
|
const dragNodeRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
// 개별 품목 목록 추출
|
// 개별 품목 목록 추출
|
||||||
const individualItems = process.classificationRules
|
const individualItems = process.classificationRules
|
||||||
@@ -103,7 +104,7 @@ export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps)
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ===== 드래그&드롭 (HTML5 네이티브) =====
|
// ===== 드래그&드롭 (HTML5 네이티브) =====
|
||||||
const handleDragStart = useCallback((e: React.DragEvent<HTMLTableRowElement>, index: number) => {
|
const handleDragStart = useCallback((e: React.DragEvent<HTMLElement>, index: number) => {
|
||||||
setDragIndex(index);
|
setDragIndex(index);
|
||||||
dragNodeRef.current = e.currentTarget;
|
dragNodeRef.current = e.currentTarget;
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
@@ -115,7 +116,7 @@ export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps)
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLTableRowElement>, index: number) => {
|
const handleDragOver = useCallback((e: React.DragEvent<HTMLElement>, index: number) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = 'move';
|
e.dataTransfer.dropEffect = 'move';
|
||||||
setDragOverIndex(index);
|
setDragOverIndex(index);
|
||||||
@@ -130,7 +131,7 @@ export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps)
|
|||||||
dragNodeRef.current = null;
|
dragNodeRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDrop = useCallback((e: React.DragEvent<HTMLTableRowElement>, dropIndex: number) => {
|
const handleDrop = useCallback((e: React.DragEvent<HTMLElement>, dropIndex: number) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (dragIndex === null || dragIndex === dropIndex) return;
|
if (dragIndex === null || dragIndex === dropIndex) return;
|
||||||
|
|
||||||
@@ -152,6 +153,23 @@ export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps)
|
|||||||
handleDragEnd();
|
handleDragEnd();
|
||||||
}, [dragIndex, handleDragEnd, process.id]);
|
}, [dragIndex, handleDragEnd, process.id]);
|
||||||
|
|
||||||
|
// 화살표 버튼으로 순서 변경
|
||||||
|
const handleMoveStep = useCallback((fromIndex: number, toIndex: number) => {
|
||||||
|
setSteps((prev) => {
|
||||||
|
const updated = [...prev];
|
||||||
|
const [moved] = updated.splice(fromIndex, 1);
|
||||||
|
updated.splice(toIndex, 0, moved);
|
||||||
|
const reordered = updated.map((step, i) => ({ ...step, order: i + 1 }));
|
||||||
|
|
||||||
|
reorderProcessSteps(
|
||||||
|
process.id,
|
||||||
|
reordered.map((s) => ({ id: s.id, order: s.order }))
|
||||||
|
);
|
||||||
|
|
||||||
|
return reordered;
|
||||||
|
});
|
||||||
|
}, [process.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
@@ -165,7 +183,7 @@ export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps)
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
{/* Row 1: 공정번호 | 공정명 | 담당부서 | 담당자 */}
|
{/* Row 1: 공정번호 | 공정명 | 담당부서 | 담당자 */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-sm text-muted-foreground">공정번호</div>
|
<div className="text-sm text-muted-foreground">공정번호</div>
|
||||||
<div className="font-medium">{process.processCode}</div>
|
<div className="font-medium">{process.processCode}</div>
|
||||||
@@ -184,7 +202,7 @@ export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Row 2: 구분 | 생산일자 | 상태 */}
|
{/* Row 2: 구분 | 생산일자 | 상태 */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mt-6">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6 mt-4 md:mt-6">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-sm text-muted-foreground">구분</div>
|
<div className="text-sm text-muted-foreground">구분</div>
|
||||||
<div className="font-medium">{process.processCategory || '없음'}</div>
|
<div className="font-medium">{process.processCategory || '없음'}</div>
|
||||||
@@ -203,7 +221,7 @@ export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Row 3: 중간검사여부 | 중간검사양식 | 작업일지여부 | 작업일지양식 */}
|
{/* Row 3: 중간검사여부 | 중간검사양식 | 작업일지여부 | 작업일지양식 */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mt-6">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6 mt-4 md:mt-6">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-sm text-muted-foreground">중간검사 여부</div>
|
<div className="text-sm text-muted-foreground">중간검사 여부</div>
|
||||||
<Badge variant={process.needsInspection ? 'default' : 'secondary'}>
|
<Badge variant={process.needsInspection ? 'default' : 'secondary'}>
|
||||||
@@ -234,43 +252,37 @@ export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps)
|
|||||||
|
|
||||||
{/* 품목 설정 정보 */}
|
{/* 품목 설정 정보 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="bg-muted/50">
|
<CardHeader className="bg-muted/50 py-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<div>
|
<CardTitle className="text-base shrink-0">
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
품목 설정 정보
|
||||||
<Package className="h-4 w-4" />
|
</CardTitle>
|
||||||
품목 설정 정보
|
<Badge variant="outline" className="text-xs">
|
||||||
</CardTitle>
|
{itemCount}개
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
</Badge>
|
||||||
품목을 선택하면 이 공정으로 분류됩니다
|
<Button variant="outline" size="sm" onClick={handleEdit} className="shrink-0">
|
||||||
</p>
|
공정 품목 선택
|
||||||
</div>
|
</Button>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Badge variant="outline" className="text-sm">
|
|
||||||
{itemCount}개
|
|
||||||
</Badge>
|
|
||||||
<Button variant="outline" size="sm" onClick={handleEdit}>
|
|
||||||
공정 품목 선택
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
품목을 선택하면 이 공정으로 분류됩니다
|
||||||
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{individualItems.length > 0 && (
|
{individualItems.length > 0 && (
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4 max-h-[240px] md:max-h-none overflow-y-auto">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{individualItems.map((item) => (
|
{individualItems.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-1.5 text-sm"
|
className="inline-flex items-center gap-1.5 rounded-full border bg-muted/30 pl-3 pr-1.5 py-1 text-xs"
|
||||||
>
|
>
|
||||||
<span className="font-mono text-muted-foreground shrink-0">{item.code}</span>
|
<span className="font-mono">{item.code}</span>
|
||||||
<span className="truncate flex-1">{item.name}</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRemoveItem(item.id)}
|
onClick={() => handleRemoveItem(item.id)}
|
||||||
className="shrink-0 ml-auto rounded-sm text-muted-foreground/50 hover:text-destructive transition-colors"
|
className="shrink-0 rounded-full p-0.5 text-muted-foreground/50 hover:text-destructive transition-colors"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -307,34 +319,59 @@ export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps)
|
|||||||
등록된 단계가 없습니다. [단계 등록] 버튼으로 추가해주세요.
|
등록된 단계가 없습니다. [단계 등록] 버튼으로 추가해주세요.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<>
|
||||||
|
{/* 모바일: 카드 리스트 */}
|
||||||
|
<div className="md:hidden divide-y">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, index)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDrop={(e) => handleDrop(e, index)}
|
||||||
|
onClick={() => handleStepClick(step.id)}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2.5 cursor-pointer hover:bg-muted/50 ${
|
||||||
|
dragOverIndex === index && dragIndex !== index
|
||||||
|
? 'border-t-2 border-t-primary'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ReorderButtons
|
||||||
|
onMoveUp={() => handleMoveStep(index, index - 1)}
|
||||||
|
onMoveDown={() => handleMoveStep(index, index + 1)}
|
||||||
|
isFirst={index === 0}
|
||||||
|
isLast={index === steps.length - 1}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground w-5 shrink-0">{index + 1}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-xs font-mono text-muted-foreground">{step.stepCode}</span>
|
||||||
|
<span className="text-sm font-medium truncate">{step.stepName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<Badge variant={step.isRequired ? 'default' : 'outline'} className="text-[10px] px-1.5 py-0">필수</Badge>
|
||||||
|
<Badge variant={step.needsApproval ? 'default' : 'outline'} className="text-[10px] px-1.5 py-0">승인</Badge>
|
||||||
|
<Badge variant={step.needsInspection ? 'default' : 'outline'} className="text-[10px] px-1.5 py-0">검사</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* 데스크탑: 테이블 */}
|
||||||
|
<div className="hidden md:block overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/30">
|
<tr className="border-b bg-muted/30">
|
||||||
<th className="w-10 px-3 py-3 text-center text-xs font-medium text-muted-foreground">
|
<th className="w-16 px-3 py-3 text-center text-xs font-medium text-muted-foreground" />
|
||||||
{/* 드래그 핸들 헤더 */}
|
<th className="w-14 px-3 py-3 text-center text-xs font-medium text-muted-foreground">No.</th>
|
||||||
</th>
|
<th className="px-3 py-3 text-left text-xs font-medium text-muted-foreground">단계코드</th>
|
||||||
<th className="w-14 px-3 py-3 text-center text-xs font-medium text-muted-foreground">
|
<th className="px-3 py-3 text-left text-xs font-medium text-muted-foreground">단계명</th>
|
||||||
No.
|
<th className="w-20 px-3 py-3 text-center text-xs font-medium text-muted-foreground">필수여부</th>
|
||||||
</th>
|
<th className="w-20 px-3 py-3 text-center text-xs font-medium text-muted-foreground">승인여부</th>
|
||||||
<th className="px-3 py-3 text-left text-xs font-medium text-muted-foreground">
|
<th className="w-20 px-3 py-3 text-center text-xs font-medium text-muted-foreground">검사여부</th>
|
||||||
단계코드
|
<th className="w-16 px-3 py-3 text-center text-xs font-medium text-muted-foreground">사용</th>
|
||||||
</th>
|
|
||||||
<th className="px-3 py-3 text-left text-xs font-medium text-muted-foreground">
|
|
||||||
단계명
|
|
||||||
</th>
|
|
||||||
<th className="w-20 px-3 py-3 text-center text-xs font-medium text-muted-foreground">
|
|
||||||
필수여부
|
|
||||||
</th>
|
|
||||||
<th className="w-20 px-3 py-3 text-center text-xs font-medium text-muted-foreground">
|
|
||||||
승인여부
|
|
||||||
</th>
|
|
||||||
<th className="w-20 px-3 py-3 text-center text-xs font-medium text-muted-foreground">
|
|
||||||
검사여부
|
|
||||||
</th>
|
|
||||||
<th className="w-16 px-3 py-3 text-center text-xs font-medium text-muted-foreground">
|
|
||||||
사용
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -353,58 +390,39 @@ export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps)
|
|||||||
: ''
|
: ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<td
|
<td className="w-16 px-3 py-3 text-center" onClick={(e) => e.stopPropagation()}>
|
||||||
className="w-10 px-3 py-3 text-center cursor-grab active:cursor-grabbing"
|
<div className="flex items-center justify-center gap-0.5">
|
||||||
onClick={(e) => e.stopPropagation()}
|
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab active:cursor-grabbing" />
|
||||||
>
|
<ReorderButtons
|
||||||
<GripVertical className="h-4 w-4 text-muted-foreground mx-auto" />
|
onMoveUp={() => handleMoveStep(index, index - 1)}
|
||||||
|
onMoveDown={() => handleMoveStep(index, index + 1)}
|
||||||
|
isFirst={index === 0}
|
||||||
|
isLast={index === steps.length - 1}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="w-14 px-3 py-3 text-center text-sm text-muted-foreground">
|
<td className="w-14 px-3 py-3 text-center text-sm text-muted-foreground">{index + 1}</td>
|
||||||
{index + 1}
|
<td className="px-3 py-3 text-sm font-mono">{step.stepCode}</td>
|
||||||
</td>
|
<td className="px-3 py-3 text-sm font-medium">{step.stepName}</td>
|
||||||
<td className="px-3 py-3 text-sm font-mono">
|
<td className="w-20 px-3 py-3 text-center">
|
||||||
{step.stepCode}
|
<Badge variant={step.isRequired ? 'default' : 'outline'} className="text-xs">{step.isRequired ? 'Y' : 'N'}</Badge>
|
||||||
</td>
|
|
||||||
<td className="px-3 py-3 text-sm font-medium">
|
|
||||||
{step.stepName}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="w-20 px-3 py-3 text-center">
|
<td className="w-20 px-3 py-3 text-center">
|
||||||
<Badge
|
<Badge variant={step.needsApproval ? 'default' : 'outline'} className="text-xs">{step.needsApproval ? 'Y' : 'N'}</Badge>
|
||||||
variant={step.isRequired ? 'default' : 'outline'}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{step.isRequired ? 'Y' : 'N'}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="w-20 px-3 py-3 text-center">
|
<td className="w-20 px-3 py-3 text-center">
|
||||||
<Badge
|
<Badge variant={step.needsInspection ? 'default' : 'outline'} className="text-xs">{step.needsInspection ? 'Y' : 'N'}</Badge>
|
||||||
variant={step.needsApproval ? 'default' : 'outline'}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{step.needsApproval ? 'Y' : 'N'}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="w-20 px-3 py-3 text-center">
|
|
||||||
<Badge
|
|
||||||
variant={step.needsInspection ? 'default' : 'outline'}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{step.needsInspection ? 'Y' : 'N'}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="w-16 px-3 py-3 text-center">
|
<td className="w-16 px-3 py-3 text-center">
|
||||||
<Badge
|
<Badge variant={step.isActive ? 'default' : 'secondary'} className="text-xs">{step.isActive ? 'Y' : 'N'}</Badge>
|
||||||
variant={step.isActive ? 'default' : 'secondary'}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{step.isActive ? 'Y' : 'N'}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
|
|
||||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Plus, GripVertical, Trash2, Package } from 'lucide-react';
|
import { Plus, GripVertical, Trash2 } from 'lucide-react';
|
||||||
|
import { ReorderButtons } from '@/components/molecules';
|
||||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||||
import { processCreateConfig, processEditConfig } from './processConfig';
|
import { processCreateConfig, processEditConfig } from './processConfig';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -261,7 +262,7 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
|||||||
// 드래그&드롭
|
// 드래그&드롭
|
||||||
const dragNodeRef = useRef<HTMLElement | null>(null);
|
const dragNodeRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
const handleDragStart = useCallback((e: React.DragEvent<HTMLTableRowElement>, index: number) => {
|
const handleDragStart = useCallback((e: React.DragEvent<HTMLElement>, index: number) => {
|
||||||
setDragIndex(index);
|
setDragIndex(index);
|
||||||
dragNodeRef.current = e.currentTarget;
|
dragNodeRef.current = e.currentTarget;
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
@@ -272,7 +273,7 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLTableRowElement>, index: number) => {
|
const handleDragOver = useCallback((e: React.DragEvent<HTMLElement>, index: number) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = 'move';
|
e.dataTransfer.dropEffect = 'move';
|
||||||
setDragOverIndex(index);
|
setDragOverIndex(index);
|
||||||
@@ -287,8 +288,18 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
|||||||
setDragOverIndex(null);
|
setDragOverIndex(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 화살표 버튼으로 순서 변경 (로컬 state만 업데이트)
|
||||||
|
const handleMoveStep = useCallback((fromIndex: number, toIndex: number) => {
|
||||||
|
setSteps((prev) => {
|
||||||
|
const updated = [...prev];
|
||||||
|
const [moved] = updated.splice(fromIndex, 1);
|
||||||
|
updated.splice(toIndex, 0, moved);
|
||||||
|
return updated.map((step, i) => ({ ...step, order: i + 1 }));
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleDrop = useCallback(
|
const handleDrop = useCallback(
|
||||||
(e: React.DragEvent<HTMLTableRowElement>, dropIndex: number) => {
|
(e: React.DragEvent<HTMLElement>, dropIndex: number) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (dragIndex === null || dragIndex === dropIndex) {
|
if (dragIndex === null || dragIndex === dropIndex) {
|
||||||
handleDragEnd();
|
handleDragEnd();
|
||||||
@@ -388,7 +399,7 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
{/* Row 1: 공정번호(수정시) | 공정명 | 담당부서 | 담당자 */}
|
{/* Row 1: 공정번호(수정시) | 공정명 | 담당부서 | 담당자 */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
|
||||||
{isEdit && initialData?.processCode && (
|
{isEdit && initialData?.processCode && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>공정번호</Label>
|
<Label>공정번호</Label>
|
||||||
@@ -439,7 +450,7 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Row 2: 구분 | 생산일자 | 상태 */}
|
{/* Row 2: 구분 | 생산일자 | 상태 */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mt-6">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6 mt-4 md:mt-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>구분</Label>
|
<Label>구분</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -491,7 +502,7 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Row 3: 중간검사여부 | 중간검사양식 | 작업일지여부 | 작업일지양식 */}
|
{/* Row 3: 중간검사여부 | 중간검사양식 | 작업일지여부 | 작업일지양식 */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mt-6">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6 mt-4 md:mt-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>중간검사 여부</Label>
|
<Label>중간검사 여부</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -570,48 +581,43 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
|||||||
|
|
||||||
{/* 품목 설정 정보 */}
|
{/* 품목 설정 정보 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="bg-muted/50">
|
<CardHeader className="bg-muted/50 py-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<div>
|
<CardTitle className="text-base shrink-0">
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
품목 설정 정보
|
||||||
<Package className="h-4 w-4" />
|
</CardTitle>
|
||||||
품목 설정 정보
|
<Badge variant="outline" className="text-xs">
|
||||||
</CardTitle>
|
{itemCount}개
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
</Badge>
|
||||||
품목을 선택하면 이 공정으로 분류됩니다
|
<Button
|
||||||
</p>
|
type="button"
|
||||||
</div>
|
variant="outline"
|
||||||
<div className="flex items-center gap-3">
|
size="sm"
|
||||||
<Badge variant="outline" className="text-sm">
|
onClick={() => setRuleModalOpen(true)}
|
||||||
{itemCount}개
|
className="shrink-0"
|
||||||
</Badge>
|
>
|
||||||
<Button
|
공정 품목 선택
|
||||||
type="button"
|
</Button>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setRuleModalOpen(true)}
|
|
||||||
>
|
|
||||||
공정 품목 선택
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
품목을 선택하면 이 공정으로 분류됩니다
|
||||||
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{individualItems.length > 0 && (
|
{individualItems.length > 0 && (
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4 max-h-[240px] md:max-h-none overflow-y-auto">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{individualItems.map((item) => (
|
{individualItems.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-1.5 text-sm"
|
className="inline-flex items-center gap-1.5 rounded-full border bg-muted/30 pl-3 pr-1.5 py-1 text-xs"
|
||||||
>
|
>
|
||||||
<span className="font-mono text-muted-foreground shrink-0">{item.code}</span>
|
<span className="font-mono">{item.code}</span>
|
||||||
<span className="truncate flex-1">{item.name}</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRemoveItem(item.id)}
|
onClick={() => handleRemoveItem(item.id)}
|
||||||
className="shrink-0 ml-auto rounded-sm text-muted-foreground/50 hover:text-destructive transition-colors"
|
className="shrink-0 rounded-full p-0.5 text-muted-foreground/50 hover:text-destructive transition-colors"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -648,32 +654,71 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
|||||||
: '공정을 먼저 등록한 후 단계를 추가할 수 있습니다.'}
|
: '공정을 먼저 등록한 후 단계를 추가할 수 있습니다.'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<>
|
||||||
|
{/* 모바일: 카드 리스트 */}
|
||||||
|
<div className="md:hidden divide-y">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, index)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDrop={(e) => handleDrop(e, index)}
|
||||||
|
onClick={() => handleStepClick(step.id)}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2.5 cursor-pointer hover:bg-muted/50 ${
|
||||||
|
dragOverIndex === index && dragIndex !== index
|
||||||
|
? 'border-t-2 border-t-primary'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ReorderButtons
|
||||||
|
onMoveUp={() => handleMoveStep(index, index - 1)}
|
||||||
|
onMoveDown={() => handleMoveStep(index, index + 1)}
|
||||||
|
isFirst={index === 0}
|
||||||
|
isLast={index === steps.length - 1}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground w-5 shrink-0">{index + 1}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-xs font-mono text-muted-foreground">{step.stepCode}</span>
|
||||||
|
<span className="text-sm font-medium truncate">{step.stepName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<Badge variant={step.isRequired ? 'default' : 'outline'} className="text-[10px] px-1.5 py-0">필수</Badge>
|
||||||
|
<Badge variant={step.needsApproval ? 'default' : 'outline'} className="text-[10px] px-1.5 py-0">승인</Badge>
|
||||||
|
<Badge variant={step.needsInspection ? 'default' : 'outline'} className="text-[10px] px-1.5 py-0">검사</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteStep(step.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* 데스크탑: 테이블 */}
|
||||||
|
<div className="hidden md:block overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/30">
|
<tr className="border-b bg-muted/30">
|
||||||
<th className="w-10 px-3 py-3" />
|
<th className="w-16 px-3 py-3" />
|
||||||
<th className="w-14 px-3 py-3 text-center text-xs font-medium text-muted-foreground">
|
<th className="w-14 px-3 py-3 text-center text-xs font-medium text-muted-foreground">No.</th>
|
||||||
No.
|
<th className="px-3 py-3 text-left text-xs font-medium text-muted-foreground">단계코드</th>
|
||||||
</th>
|
<th className="px-3 py-3 text-left text-xs font-medium text-muted-foreground">단계명</th>
|
||||||
<th className="px-3 py-3 text-left text-xs font-medium text-muted-foreground">
|
<th className="w-20 px-3 py-3 text-center text-xs font-medium text-muted-foreground">필수여부</th>
|
||||||
단계코드
|
<th className="w-20 px-3 py-3 text-center text-xs font-medium text-muted-foreground">승인여부</th>
|
||||||
</th>
|
<th className="w-20 px-3 py-3 text-center text-xs font-medium text-muted-foreground">검사여부</th>
|
||||||
<th className="px-3 py-3 text-left text-xs font-medium text-muted-foreground">
|
<th className="w-16 px-3 py-3 text-center text-xs font-medium text-muted-foreground">사용</th>
|
||||||
단계명
|
|
||||||
</th>
|
|
||||||
<th className="w-20 px-3 py-3 text-center text-xs font-medium text-muted-foreground">
|
|
||||||
필수여부
|
|
||||||
</th>
|
|
||||||
<th className="w-20 px-3 py-3 text-center text-xs font-medium text-muted-foreground">
|
|
||||||
승인여부
|
|
||||||
</th>
|
|
||||||
<th className="w-20 px-3 py-3 text-center text-xs font-medium text-muted-foreground">
|
|
||||||
검사여부
|
|
||||||
</th>
|
|
||||||
<th className="w-16 px-3 py-3 text-center text-xs font-medium text-muted-foreground">
|
|
||||||
사용
|
|
||||||
</th>
|
|
||||||
<th className="w-12 px-3 py-3" />
|
<th className="w-12 px-3 py-3" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -693,48 +738,32 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
|||||||
: ''
|
: ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<td
|
<td className="w-16 px-3 py-3 text-center" onClick={(e) => e.stopPropagation()}>
|
||||||
className="w-10 px-3 py-3 text-center cursor-grab active:cursor-grabbing"
|
<div className="flex items-center justify-center gap-0.5">
|
||||||
onClick={(e) => e.stopPropagation()}
|
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab active:cursor-grabbing" />
|
||||||
>
|
<ReorderButtons
|
||||||
<GripVertical className="h-4 w-4 text-muted-foreground mx-auto" />
|
onMoveUp={() => handleMoveStep(index, index - 1)}
|
||||||
</td>
|
onMoveDown={() => handleMoveStep(index, index + 1)}
|
||||||
<td className="w-14 px-3 py-3 text-center text-sm text-muted-foreground">
|
isFirst={index === 0}
|
||||||
{index + 1}
|
isLast={index === steps.length - 1}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="w-14 px-3 py-3 text-center text-sm text-muted-foreground">{index + 1}</td>
|
||||||
<td className="px-3 py-3 text-sm font-mono">{step.stepCode}</td>
|
<td className="px-3 py-3 text-sm font-mono">{step.stepCode}</td>
|
||||||
<td className="px-3 py-3 text-sm font-medium">{step.stepName}</td>
|
<td className="px-3 py-3 text-sm font-medium">{step.stepName}</td>
|
||||||
<td className="w-20 px-3 py-3 text-center">
|
<td className="w-20 px-3 py-3 text-center">
|
||||||
<Badge
|
<Badge variant={step.isRequired ? 'default' : 'outline'} className="text-xs">{step.isRequired ? 'Y' : 'N'}</Badge>
|
||||||
variant={step.isRequired ? 'default' : 'outline'}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{step.isRequired ? 'Y' : 'N'}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="w-20 px-3 py-3 text-center">
|
<td className="w-20 px-3 py-3 text-center">
|
||||||
<Badge
|
<Badge variant={step.needsApproval ? 'default' : 'outline'} className="text-xs">{step.needsApproval ? 'Y' : 'N'}</Badge>
|
||||||
variant={step.needsApproval ? 'default' : 'outline'}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{step.needsApproval ? 'Y' : 'N'}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="w-20 px-3 py-3 text-center">
|
<td className="w-20 px-3 py-3 text-center">
|
||||||
<Badge
|
<Badge variant={step.needsInspection ? 'default' : 'outline'} className="text-xs">{step.needsInspection ? 'Y' : 'N'}</Badge>
|
||||||
variant={step.needsInspection ? 'default' : 'outline'}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{step.needsInspection ? 'Y' : 'N'}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="w-16 px-3 py-3 text-center">
|
<td className="w-16 px-3 py-3 text-center">
|
||||||
<Badge
|
<Badge variant={step.isActive ? 'default' : 'secondary'} className="text-xs">{step.isActive ? 'Y' : 'N'}</Badge>
|
||||||
variant={step.isActive ? 'default' : 'secondary'}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{step.isActive ? 'Y' : 'N'}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="w-12 px-3 py-3 text-center">
|
<td className="w-12 px-3 py-3 text-center">
|
||||||
<Button
|
<Button
|
||||||
@@ -755,6 +784,7 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -808,6 +838,7 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
|||||||
handleDragOver,
|
handleDragOver,
|
||||||
handleDragEnd,
|
handleDragEnd,
|
||||||
handleDrop,
|
handleDrop,
|
||||||
|
handleMoveStep,
|
||||||
isEdit,
|
isEdit,
|
||||||
initialData?.id,
|
initialData?.id,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule, processId, proc
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
{/* TODO: 백엔드 API에서 process_name, process_category 필터/응답 지원 후 구분 필터 활성화
|
||||||
<Select
|
<Select
|
||||||
key={`category-${processFilter}`}
|
key={`category-${processFilter}`}
|
||||||
value={categoryFilter}
|
value={categoryFilter}
|
||||||
@@ -271,6 +272,7 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule, processId, proc
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
*/}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -283,21 +285,22 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule, processId, proc
|
|||||||
<TableHead className="w-[80px]">품목유형</TableHead>
|
<TableHead className="w-[80px]">품목유형</TableHead>
|
||||||
<TableHead>품목코드</TableHead>
|
<TableHead>품목코드</TableHead>
|
||||||
<TableHead>품목명</TableHead>
|
<TableHead>품목명</TableHead>
|
||||||
{/* TODO: API에서 process_name, process_category 필드 지원 후 실제 데이터 표시 */}
|
{/* TODO: 백엔드 API에서 process_name, process_category 응답 지원 후 공정/구분 컬럼 활성화
|
||||||
<TableHead className="w-[80px]">공정</TableHead>
|
<TableHead className="w-[80px]">공정</TableHead>
|
||||||
<TableHead className="w-[80px]">구분</TableHead>
|
<TableHead className="w-[80px]">구분</TableHead>
|
||||||
|
*/}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isItemsLoading ? (
|
{isItemsLoading ? (
|
||||||
<TableRow key="loading">
|
<TableRow key="loading">
|
||||||
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
|
||||||
품목 목록을 불러오는 중...
|
품목 목록을 불러오는 중...
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : itemList.length === 0 ? (
|
) : itemList.length === 0 ? (
|
||||||
<TableRow key="empty">
|
<TableRow key="empty">
|
||||||
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
|
||||||
{searchKeyword.trim() === ''
|
{searchKeyword.trim() === ''
|
||||||
? '품목을 검색해주세요 (한글 1자 이상, 영문 2자 이상)'
|
? '품목을 검색해주세요 (한글 1자 이상, 영문 2자 이상)'
|
||||||
: '검색 결과가 없습니다'}
|
: '검색 결과가 없습니다'}
|
||||||
@@ -320,9 +323,10 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule, processId, proc
|
|||||||
<TableCell>{item.type}</TableCell>
|
<TableCell>{item.type}</TableCell>
|
||||||
<TableCell className="font-medium">{item.code}</TableCell>
|
<TableCell className="font-medium">{item.code}</TableCell>
|
||||||
<TableCell>{item.fullName}</TableCell>
|
<TableCell>{item.fullName}</TableCell>
|
||||||
{/* TODO: API 지원 후 item.processName / item.processCategory 표시 */}
|
{/* TODO: 백엔드 API 지원 후 item.processName / item.processCategory 표시
|
||||||
<TableCell className="text-muted-foreground">-</TableCell>
|
<TableCell className="text-muted-foreground">{item.processName || '-'}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">-</TableCell>
|
<TableCell className="text-muted-foreground">{item.processCategory || '-'}</TableCell>
|
||||||
|
*/}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
537
src/components/quotes/QuoteCalculationReport.tsx
Executable file
537
src/components/quotes/QuoteCalculationReport.tsx
Executable file
@@ -0,0 +1,537 @@
|
|||||||
|
/**
|
||||||
|
* 견적 산출내역서 / 견적서 컴포넌트
|
||||||
|
* - documentType="견적서": 간단한 견적서
|
||||||
|
* - documentType="견적산출내역서": 상세 산출내역서 + 소요자재 내역
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { QuoteFormDataV2 } from "./QuoteRegistration";
|
||||||
|
import type { CompanyFormData } from "@/components/settings/CompanyInfoManagement/types";
|
||||||
|
|
||||||
|
interface QuoteCalculationReportProps {
|
||||||
|
quote: QuoteFormDataV2;
|
||||||
|
companyInfo?: CompanyFormData | null;
|
||||||
|
documentType?: "견적산출내역서" | "견적서";
|
||||||
|
showDetailedBreakdown?: boolean;
|
||||||
|
showMaterialList?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuoteCalculationReport({
|
||||||
|
quote,
|
||||||
|
companyInfo,
|
||||||
|
documentType = "견적산출내역서",
|
||||||
|
showDetailedBreakdown = true,
|
||||||
|
showMaterialList = true
|
||||||
|
}: QuoteCalculationReportProps) {
|
||||||
|
const formatAmount = (amount: number | null | undefined) => {
|
||||||
|
if (amount == null) return '0';
|
||||||
|
return Number(amount).toLocaleString('ko-KR');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return `${date.getFullYear()}년 ${String(date.getMonth() + 1).padStart(2, '0')}월 ${String(date.getDate()).padStart(2, '0')}일`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 총 금액 계산 (totalPrice > unitPrice * quantity > inspectionFee 우선순위)
|
||||||
|
const totalAmount = quote.locations?.reduce((sum, loc) => {
|
||||||
|
const locTotal = loc.totalPrice ||
|
||||||
|
(loc.unitPrice || 0) * (loc.quantity || 1) ||
|
||||||
|
(loc.inspectionFee || 0) * (loc.quantity || 1);
|
||||||
|
return sum + locTotal;
|
||||||
|
}, 0) || 0;
|
||||||
|
|
||||||
|
// 소요자재 내역 - BOM 자재 목록 (locations[].bomResult.items)에서 가져옴
|
||||||
|
const materialItems = (quote.locations || []).flatMap(loc =>
|
||||||
|
(loc.bomResult?.items || [])
|
||||||
|
).map((material, index) => ({
|
||||||
|
no: index + 1,
|
||||||
|
itemCode: material.item_code || '-',
|
||||||
|
name: material.item_name || '-',
|
||||||
|
spec: material.specification || '-',
|
||||||
|
quantity: Math.floor(material.quantity || 1),
|
||||||
|
unit: material.unit || 'EA',
|
||||||
|
unitPrice: material.unit_price || 0,
|
||||||
|
totalPrice: material.total_price || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{`
|
||||||
|
@media print {
|
||||||
|
@page {
|
||||||
|
size: A4 portrait;
|
||||||
|
margin: 15mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print\\:hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#quote-report-content {
|
||||||
|
background: white !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-break-after {
|
||||||
|
page-break-after: always;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 공문서 스타일 */
|
||||||
|
.official-doc {
|
||||||
|
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
|
||||||
|
background: white;
|
||||||
|
color: #000;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-header {
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 3px double #000;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-title {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-number {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box {
|
||||||
|
border: 2px solid #000;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box-header {
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-bottom: 2px solid #000;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box-content {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table th {
|
||||||
|
background: #f8f8f8;
|
||||||
|
border: 1px solid #999;
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table td {
|
||||||
|
border: 1px solid #999;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-box {
|
||||||
|
border: 3px double #000;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 30px 0;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-label {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-value {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #000;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-note {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
background: #000;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
margin: 30px 0 15px 0;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border: 2px solid #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-table thead th {
|
||||||
|
background: #e8e8e8;
|
||||||
|
border: 1px solid #666;
|
||||||
|
padding: 10px 6px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-table tbody td {
|
||||||
|
border: 1px solid #999;
|
||||||
|
padding: 8px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-table tbody tr:hover {
|
||||||
|
background: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-table tfoot td {
|
||||||
|
background: #f0f0f0;
|
||||||
|
border: 1px solid #666;
|
||||||
|
padding: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border: 2px solid #000;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-table th {
|
||||||
|
background: #e8e8e8;
|
||||||
|
border: 1px solid #666;
|
||||||
|
padding: 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-table td {
|
||||||
|
border: 1px solid #999;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stamp-area {
|
||||||
|
border: 2px solid #000;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stamp-text {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 10px;
|
||||||
|
color: #999;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-note {
|
||||||
|
margin-top: 40px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #ccc;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-section {
|
||||||
|
margin-top: 30px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{/* 문서 컴포넌트 */}
|
||||||
|
<div className="official-doc">
|
||||||
|
{/* 문서 헤더 */}
|
||||||
|
<div className="doc-header">
|
||||||
|
<div className="doc-title">
|
||||||
|
{documentType === "견적서" ? "견 적 서" : "견 적 산 출 내 역 서"}
|
||||||
|
</div>
|
||||||
|
<div className="doc-number">
|
||||||
|
문서번호: {quote.id || '-'} | 작성일자: {formatDate(quote.registrationDate || '')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 수요자 정보 */}
|
||||||
|
<div className="info-box">
|
||||||
|
<div className="info-box-header">수 요 자</div>
|
||||||
|
<div className="info-box-content">
|
||||||
|
<table className="info-table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>업체명</th>
|
||||||
|
<td colSpan={3}>{quote.clientName || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>현장명</th>
|
||||||
|
<td>{quote.siteName || '-'}</td>
|
||||||
|
<th>담당자</th>
|
||||||
|
<td>{quote.manager || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>제품명</th>
|
||||||
|
<td>{quote.locations?.[0]?.productName || '-'}</td>
|
||||||
|
<th>연락처</th>
|
||||||
|
<td>{quote.contact || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 공급자 정보 */}
|
||||||
|
<div className="info-box">
|
||||||
|
<div className="info-box-header">공 급 자</div>
|
||||||
|
<div className="info-box-content">
|
||||||
|
<table className="info-table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>상호</th>
|
||||||
|
<td>{companyInfo?.companyName || '-'}</td>
|
||||||
|
<th>사업자등록번호</th>
|
||||||
|
<td>{companyInfo?.businessNumber || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>대표자</th>
|
||||||
|
<td>{companyInfo?.representativeName || '-'}</td>
|
||||||
|
<th>업태</th>
|
||||||
|
<td>{companyInfo?.businessType || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>종목</th>
|
||||||
|
<td colSpan={3}>{companyInfo?.businessCategory || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>사업장주소</th>
|
||||||
|
<td colSpan={3}>{companyInfo?.address || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>전화</th>
|
||||||
|
<td>{companyInfo?.managerPhone || '-'}</td>
|
||||||
|
<th>이메일</th>
|
||||||
|
<td>{companyInfo?.email || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 총 견적금액 */}
|
||||||
|
<div className="amount-box">
|
||||||
|
<div className="amount-label">총 견적금액</div>
|
||||||
|
<div className="amount-value">₩ {formatAmount(totalAmount)}</div>
|
||||||
|
<div className="amount-note">※ 부가가치세 별도</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 세부 산출내역서 */}
|
||||||
|
{showDetailedBreakdown && quote.locations && quote.locations.length > 0 && (
|
||||||
|
<div className="page-break-after">
|
||||||
|
<div className="section-title">세 부 산 출 내 역</div>
|
||||||
|
|
||||||
|
<table className="detail-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: '40px' }}>No.</th>
|
||||||
|
<th style={{ width: '200px' }}>품목명</th>
|
||||||
|
<th style={{ width: '150px' }}>규격</th>
|
||||||
|
<th style={{ width: '70px' }}>수량</th>
|
||||||
|
<th style={{ width: '50px' }}>단위</th>
|
||||||
|
<th style={{ width: '110px' }}>단가</th>
|
||||||
|
<th style={{ width: '130px' }}>금액</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{quote.locations.map((loc, index) => {
|
||||||
|
const unitPrice = loc.unitPrice || loc.inspectionFee || 0;
|
||||||
|
const locTotal = loc.totalPrice || unitPrice * (loc.quantity || 1);
|
||||||
|
return (
|
||||||
|
<tr key={loc.id || `loc-${index}`}>
|
||||||
|
<td style={{ textAlign: 'center' }}>{index + 1}</td>
|
||||||
|
<td>{loc.productName}</td>
|
||||||
|
<td style={{ fontSize: '11px' }}>{`${loc.openWidth}×${loc.openHeight}mm`}</td>
|
||||||
|
<td style={{ textAlign: 'right' }}>{Math.floor(loc.quantity || 0)}</td>
|
||||||
|
<td style={{ textAlign: 'center' }}>SET</td>
|
||||||
|
<td style={{ textAlign: 'right' }}>{formatAmount(unitPrice)}</td>
|
||||||
|
<td style={{ textAlign: 'right', fontWeight: '600' }}>{formatAmount(locTotal)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} style={{ textAlign: 'right', padding: '12px', background: '#e0e0e0', fontWeight: '700' }}>공급가액 합계</td>
|
||||||
|
<td style={{ textAlign: 'right', padding: '12px', background: '#e0e0e0', fontSize: '15px', fontWeight: '700' }}>{formatAmount(totalAmount)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 소요자재 내역 */}
|
||||||
|
{showMaterialList && documentType !== "견적서" && (
|
||||||
|
<div>
|
||||||
|
<div className="section-title">소 요 자 재 내 역</div>
|
||||||
|
|
||||||
|
{/* 제품 정보 */}
|
||||||
|
<div className="info-box" style={{ marginTop: '15px' }}>
|
||||||
|
<div className="info-box-content">
|
||||||
|
<table className="info-table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>제품구분</th>
|
||||||
|
<td>{quote.locations?.[0]?.itemCategory === 'steel' ? '철재' : '스크린'}</td>
|
||||||
|
<th>부호</th>
|
||||||
|
<td>{quote.locations?.[0]?.code || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>오픈사이즈</th>
|
||||||
|
<td>W {quote.locations?.[0]?.openWidth || '-'} × H {quote.locations?.[0]?.openHeight || '-'} (mm)</td>
|
||||||
|
<th>제작사이즈</th>
|
||||||
|
<td>W {Number(quote.locations?.[0]?.openWidth || 0) + 100} × H {Number(quote.locations?.[0]?.openHeight || 0) + 100} (mm)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>수량</th>
|
||||||
|
<td>{Math.floor(quote.locations?.[0]?.quantity || 1)} SET</td>
|
||||||
|
<th>케이스</th>
|
||||||
|
<td>2438 × 550 (mm)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 자재 목록 테이블 */}
|
||||||
|
{materialItems.length > 0 ? (
|
||||||
|
<table className="material-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: '40px' }}>No.</th>
|
||||||
|
<th style={{ width: '100px' }}>품목코드</th>
|
||||||
|
<th>자재명</th>
|
||||||
|
<th style={{ width: '200px' }}>규격</th>
|
||||||
|
<th style={{ width: '80px' }}>수량</th>
|
||||||
|
<th style={{ width: '60px' }}>단위</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{materialItems.map((item, index) => (
|
||||||
|
<tr key={index}>
|
||||||
|
<td style={{ textAlign: 'center' }}>{index + 1}</td>
|
||||||
|
<td style={{ textAlign: 'center', fontSize: '11px' }}>{item.itemCode}</td>
|
||||||
|
<td>{item.name}</td>
|
||||||
|
<td style={{ fontSize: '11px' }}>{item.spec}</td>
|
||||||
|
<td style={{ textAlign: 'center', fontWeight: '600' }}>{item.quantity}</td>
|
||||||
|
<td style={{ textAlign: 'center' }}>{item.unit}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
border: '2px solid #000',
|
||||||
|
padding: '30px',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: '15px',
|
||||||
|
color: '#666'
|
||||||
|
}}>
|
||||||
|
소요자재 정보가 없습니다. (BOM 계산 데이터가 필요합니다)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 비고사항 */}
|
||||||
|
{quote.remarks && (
|
||||||
|
<div style={{ marginTop: '30px' }}>
|
||||||
|
<div className="section-title">비 고 사 항</div>
|
||||||
|
<div style={{
|
||||||
|
border: '2px solid #000',
|
||||||
|
padding: '15px',
|
||||||
|
minHeight: '100px',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
fontSize: '13px',
|
||||||
|
lineHeight: '1.8',
|
||||||
|
marginTop: '15px'
|
||||||
|
}}>
|
||||||
|
{quote.remarks}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 서명란 */}
|
||||||
|
<div className="signature-section">
|
||||||
|
<div style={{ display: 'inline-block', textAlign: 'left' }}>
|
||||||
|
<div style={{ marginBottom: '15px', fontSize: '14px' }}>
|
||||||
|
상기와 같이 견적합니다.
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '13px', marginBottom: '5px' }}>{formatDate(quote.registrationDate || '')}</div>
|
||||||
|
<div style={{ fontSize: '15px', fontWeight: '600' }}>
|
||||||
|
공급자: {companyInfo?.companyName || '-'} (인)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stamp-area">
|
||||||
|
<div className="stamp-text">
|
||||||
|
(인감<br/>날인)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 안내사항 */}
|
||||||
|
<div className="footer-note">
|
||||||
|
<p style={{ fontWeight: '600', marginBottom: '8px' }}>【 유의사항 】</p>
|
||||||
|
<p>1. 본 견적서는 {formatDate(quote.registrationDate || '')} 기준으로 작성되었으며, 자재 가격 변동 시 조정될 수 있습니다.</p>
|
||||||
|
<p>2. 견적 유효기간은 발행일로부터 30일이며, 기간 경과 시 재견적이 필요합니다.</p>
|
||||||
|
<p>3. 제작 사양 및 수량 변경 시 견적 금액이 변동될 수 있습니다.</p>
|
||||||
|
<p>4. 현장 여건에 따라 추가 비용이 발생할 수 있습니다.</p>
|
||||||
|
<p style={{ marginTop: '12px', textAlign: 'center', fontWeight: '600' }}>
|
||||||
|
문의: {companyInfo?.managerName || quote.manager || '담당자'} | {companyInfo?.managerPhone || '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -387,14 +387,14 @@ export function PermissionManagement() {
|
|||||||
}) => (
|
}) => (
|
||||||
<div className="flex items-center gap-2 flex-wrap ml-auto">
|
<div className="flex items-center gap-2 flex-wrap ml-auto">
|
||||||
{selItems.size > 0 && (
|
{selItems.size > 0 && (
|
||||||
<Button variant="destructive" onClick={handleBulkDelete}>
|
<Button variant="destructive" size="sm" className="md:size-default" onClick={handleBulkDelete}>
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<Trash2 className="h-4 w-4 md:mr-2" />
|
||||||
선택 삭제 ({selItems.size})
|
<span className="hidden md:inline">선택 삭제 ({selItems.size})</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button onClick={handleAdd}>
|
<Button size="sm" className="md:size-default" onClick={handleAdd}>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 md:mr-2" />
|
||||||
역할 등록
|
<span className="hidden md:inline">역할 등록</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
), [handleBulkDelete, handleAdd]);
|
), [handleBulkDelete, handleAdd]);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react';
|
|||||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||||
import { Award, Plus, GripVertical, Pencil, Trash2, Loader2 } from 'lucide-react';
|
import { Award, Plus, GripVertical, Pencil, Trash2, Loader2 } from 'lucide-react';
|
||||||
|
import { ReorderButtons } from '@/components/molecules';
|
||||||
import { ContentSkeleton } from '@/components/ui/skeleton';
|
import { ContentSkeleton } from '@/components/ui/skeleton';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -185,6 +186,33 @@ export function RankManagement() {
|
|||||||
setDraggedItem(index);
|
setDraggedItem(index);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 화살표 버튼으로 순서 변경
|
||||||
|
const handleMoveItem = async (fromIndex: number, toIndex: number) => {
|
||||||
|
const newRanks = [...ranks];
|
||||||
|
const [moved] = newRanks.splice(fromIndex, 1);
|
||||||
|
newRanks.splice(toIndex, 0, moved);
|
||||||
|
const reordered = newRanks.map((rank, idx) => ({ ...rank, order: idx + 1 }));
|
||||||
|
setRanks(reordered);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = reordered.map((rank, idx) => ({
|
||||||
|
id: rank.id,
|
||||||
|
sort_order: idx + 1,
|
||||||
|
}));
|
||||||
|
const result = await reorderRanks(items);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('순서가 변경되었습니다.');
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '순서 변경에 실패했습니다.');
|
||||||
|
loadRanks();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isNextRedirectError(error)) throw error;
|
||||||
|
toast.error('순서 변경에 실패했습니다.');
|
||||||
|
loadRanks();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 키보드로 추가 (한글 IME 조합 중에는 무시)
|
// 키보드로 추가 (한글 IME 조합 중에는 무시)
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
||||||
@@ -196,7 +224,7 @@ export function RankManagement() {
|
|||||||
<PageLayout>
|
<PageLayout>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="직급관리"
|
title="직급관리"
|
||||||
description="사원의 직급을 관리합니다. 드래그하여 순서를 변경할 수 있습니다."
|
description="사원의 직급을 관리합니다. 화살표 버튼 또는 드래그하여 순서를 변경할 수 있습니다."
|
||||||
icon={Award}
|
icon={Award}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -246,8 +274,18 @@ export function RankManagement() {
|
|||||||
draggedItem === index ? 'opacity-50 bg-muted' : ''
|
draggedItem === index ? 'opacity-50 bg-muted' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* 드래그 핸들 */}
|
{/* 드래그 핸들 (PC만) */}
|
||||||
<GripVertical className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
<GripVertical className="h-5 w-5 text-muted-foreground flex-shrink-0 hidden md:block" />
|
||||||
|
|
||||||
|
{/* 순서 변경 버튼 */}
|
||||||
|
<ReorderButtons
|
||||||
|
onMoveUp={() => handleMoveItem(index, index - 1)}
|
||||||
|
onMoveDown={() => handleMoveItem(index, index + 1)}
|
||||||
|
isFirst={index === 0}
|
||||||
|
isLast={index === ranks.length - 1}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 순서 번호 */}
|
{/* 순서 번호 */}
|
||||||
<span className="text-sm text-muted-foreground w-8">
|
<span className="text-sm text-muted-foreground w-8">
|
||||||
@@ -295,7 +333,7 @@ export function RankManagement() {
|
|||||||
|
|
||||||
{/* 안내 문구 */}
|
{/* 안내 문구 */}
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
※ 직급 순서는 드래그 앤 드롭으로 변경할 수 있습니다.
|
※ 화살표 버튼으로 순서를 변경할 수 있습니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react';
|
|||||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||||
import { Briefcase, Plus, GripVertical, Pencil, Trash2, Loader2 } from 'lucide-react';
|
import { Briefcase, Plus, GripVertical, Pencil, Trash2, Loader2 } from 'lucide-react';
|
||||||
|
import { ReorderButtons } from '@/components/molecules';
|
||||||
import { ContentSkeleton } from '@/components/ui/skeleton';
|
import { ContentSkeleton } from '@/components/ui/skeleton';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -185,6 +186,33 @@ export function TitleManagement() {
|
|||||||
setDraggedItem(index);
|
setDraggedItem(index);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 화살표 버튼으로 순서 변경
|
||||||
|
const handleMoveItem = async (fromIndex: number, toIndex: number) => {
|
||||||
|
const newTitles = [...titles];
|
||||||
|
const [moved] = newTitles.splice(fromIndex, 1);
|
||||||
|
newTitles.splice(toIndex, 0, moved);
|
||||||
|
const reordered = newTitles.map((title, idx) => ({ ...title, order: idx + 1 }));
|
||||||
|
setTitles(reordered);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = reordered.map((title, idx) => ({
|
||||||
|
id: title.id,
|
||||||
|
sort_order: idx + 1,
|
||||||
|
}));
|
||||||
|
const result = await reorderTitles(items);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('순서가 변경되었습니다.');
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '순서 변경에 실패했습니다.');
|
||||||
|
loadTitles();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isNextRedirectError(error)) throw error;
|
||||||
|
toast.error('순서 변경에 실패했습니다.');
|
||||||
|
loadTitles();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 키보드로 추가 (한글 IME 조합 중에는 무시)
|
// 키보드로 추가 (한글 IME 조합 중에는 무시)
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
||||||
@@ -196,7 +224,7 @@ export function TitleManagement() {
|
|||||||
<PageLayout>
|
<PageLayout>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="직책관리"
|
title="직책관리"
|
||||||
description="사원의 직책을 관리합니다. 드래그하여 순서를 변경할 수 있습니다."
|
description="사원의 직책을 관리합니다. 화살표 버튼 또는 드래그하여 순서를 변경할 수 있습니다."
|
||||||
icon={Briefcase}
|
icon={Briefcase}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -246,8 +274,18 @@ export function TitleManagement() {
|
|||||||
draggedItem === index ? 'opacity-50 bg-muted' : ''
|
draggedItem === index ? 'opacity-50 bg-muted' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* 드래그 핸들 */}
|
{/* 드래그 핸들 (PC만) */}
|
||||||
<GripVertical className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
<GripVertical className="h-5 w-5 text-muted-foreground flex-shrink-0 hidden md:block" />
|
||||||
|
|
||||||
|
{/* 순서 변경 버튼 */}
|
||||||
|
<ReorderButtons
|
||||||
|
onMoveUp={() => handleMoveItem(index, index - 1)}
|
||||||
|
onMoveDown={() => handleMoveItem(index, index + 1)}
|
||||||
|
isFirst={index === 0}
|
||||||
|
isLast={index === titles.length - 1}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 순서 번호 */}
|
{/* 순서 번호 */}
|
||||||
<span className="text-sm text-muted-foreground w-8">
|
<span className="text-sm text-muted-foreground w-8">
|
||||||
@@ -295,7 +333,7 @@ export function TitleManagement() {
|
|||||||
|
|
||||||
{/* 안내 문구 */}
|
{/* 안내 문구 */}
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
※ 직책 순서는 드래그 앤 드롭으로 변경할 수 있습니다.
|
※ 화살표 버튼으로 순서를 변경할 수 있습니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { ArrowLeft, Save, Trash2, X, Edit } from 'lucide-react';
|
import { ArrowLeft, Save, Trash2, X, Edit, Loader2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useMenuStore } from '@/stores/menuStore';
|
import { useMenuStore } from '@/stores/menuStore';
|
||||||
|
import type { ActionItem } from '../types';
|
||||||
|
|
||||||
export interface DetailActionsProps {
|
export interface DetailActionsProps {
|
||||||
/** 현재 모드 */
|
/** 현재 모드 */
|
||||||
@@ -49,8 +50,10 @@ export interface DetailActionsProps {
|
|||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
onSubmit?: () => void;
|
onSubmit?: () => void;
|
||||||
/** 추가 액션 (삭제 버튼 앞에 표시) */
|
/** 추가 액션 (삭제 버튼 앞에 표시, 자유 JSX) */
|
||||||
extraActions?: ReactNode;
|
extraActions?: ReactNode;
|
||||||
|
/** 추가 액션 아이템 (config 배열, 모바일 아이콘 패턴 자동 적용) */
|
||||||
|
extraActionItems?: ActionItem[];
|
||||||
/** 하단 고정 (sticky) 모드 */
|
/** 하단 고정 (sticky) 모드 */
|
||||||
sticky?: boolean;
|
sticky?: boolean;
|
||||||
/** 추가 클래스 */
|
/** 추가 클래스 */
|
||||||
@@ -69,6 +72,7 @@ export function DetailActions({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
extraActions,
|
extraActions,
|
||||||
|
extraActionItems,
|
||||||
sticky = false,
|
sticky = false,
|
||||||
className,
|
className,
|
||||||
}: DetailActionsProps) {
|
}: DetailActionsProps) {
|
||||||
@@ -104,9 +108,9 @@ export function DetailActions({
|
|||||||
|
|
||||||
// Fixed 스타일: 화면 하단에 고정 (사이드바 상태에 따라 동적 계산)
|
// Fixed 스타일: 화면 하단에 고정 (사이드바 상태에 따라 동적 계산)
|
||||||
// 모바일: 좌우 여백 16px (left-4 right-4)
|
// 모바일: 좌우 여백 16px (left-4 right-4)
|
||||||
// 태블릿/데스크탑: 사이드바 펼침(w-64=256px), 접힘(w-24=96px) + 여백 고려
|
// 태블릿/데스크탑: 사이드바 펼침(w-64=256px), 접힘(w-24=96px) + 콘텐츠 패딩(24px) 맞춤
|
||||||
const stickyStyles = sticky
|
const stickyStyles = sticky
|
||||||
? `fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'}`
|
? `fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[24px] ${sidebarCollapsed ? 'md:left-[120px]' : 'md:left-[280px]'}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// 공통 레이아웃: 왼쪽(뒤로) | 오른쪽(액션들)
|
// 공통 레이아웃: 왼쪽(뒤로) | 오른쪽(액션들)
|
||||||
@@ -134,6 +138,24 @@ export function DetailActions({
|
|||||||
<div className="flex items-center gap-1 md:gap-2">
|
<div className="flex items-center gap-1 md:gap-2">
|
||||||
{extraActions}
|
{extraActions}
|
||||||
|
|
||||||
|
{/* config 배열 기반 추가 버튼 (모바일 아이콘 패턴 자동 적용) */}
|
||||||
|
{extraActionItems?.filter(item => !item.hidden).map((item, idx) => {
|
||||||
|
const Icon = item.loading ? Loader2 : item.icon;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={idx}
|
||||||
|
variant={item.variant || 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className={cn('md:size-default', item.className)}
|
||||||
|
onClick={item.onClick}
|
||||||
|
disabled={item.disabled || item.loading}
|
||||||
|
>
|
||||||
|
<Icon className={cn('w-4 h-4 md:mr-2', item.loading && 'animate-spin')} />
|
||||||
|
<span className="hidden md:inline">{item.label}</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* 삭제 버튼: view, edit 모드에서 표시 (create는 삭제할 대상 없음) */}
|
{/* 삭제 버튼: view, edit 모드에서 표시 (create는 삭제할 대상 없음) */}
|
||||||
{!isCreateMode && canDelete && showDelete && onDelete && (
|
{!isCreateMode && canDelete && showDelete && onDelete && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ function IntegratedDetailTemplateInner<T extends Record<string, unknown>>(
|
|||||||
renderForm,
|
renderForm,
|
||||||
renderField,
|
renderField,
|
||||||
headerActions,
|
headerActions,
|
||||||
|
headerActionItems,
|
||||||
beforeContent,
|
beforeContent,
|
||||||
afterContent,
|
afterContent,
|
||||||
buttonPosition = 'bottom',
|
buttonPosition = 'bottom',
|
||||||
@@ -360,12 +361,13 @@ function IntegratedDetailTemplateInner<T extends Record<string, unknown>>(
|
|||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
extraActions={headerActions}
|
extraActions={headerActions}
|
||||||
|
extraActionItems={headerActionItems}
|
||||||
sticky={shouldSticky}
|
sticky={shouldSticky}
|
||||||
className={additionalClass}
|
className={additionalClass}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
mode, isSubmitting, permissions, actions, headerActions, shouldSticky,
|
mode, isSubmitting, permissions, actions, headerActions, headerActionItems, shouldSticky,
|
||||||
navigateToList, handleDelete, handleEdit, handleCancel, handleSubmit, onDelete
|
navigateToList, handleDelete, handleEdit, handleCancel, handleSubmit, onDelete
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -224,8 +224,10 @@ export interface IntegratedDetailTemplateProps<T = Record<string, unknown>> {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
) => ReactNode | null;
|
) => ReactNode | null;
|
||||||
/** 헤더 우측 추가 액션 */
|
/** 헤더 우측 추가 액션 (자유 JSX - Badge 등 비버튼 요소용) */
|
||||||
headerActions?: ReactNode;
|
headerActions?: ReactNode;
|
||||||
|
/** 헤더 우측 추가 버튼 (config 배열 - 모바일 아이콘 패턴 자동 적용) */
|
||||||
|
headerActionItems?: ActionItem[];
|
||||||
/** 폼 앞에 추가 콘텐츠 */
|
/** 폼 앞에 추가 콘텐츠 */
|
||||||
beforeContent?: ReactNode;
|
beforeContent?: ReactNode;
|
||||||
/** 폼 뒤에 추가 콘텐츠 */
|
/** 폼 뒤에 추가 콘텐츠 */
|
||||||
@@ -242,6 +244,26 @@ export interface IntegratedDetailTemplateProps<T = Record<string, unknown>> {
|
|||||||
isSubmitting?: boolean;
|
isSubmitting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 액션 아이템 (headerActionItems용) =====
|
||||||
|
export interface ActionItem {
|
||||||
|
/** 아이콘 (lucide-react) */
|
||||||
|
icon: LucideIcon;
|
||||||
|
/** 버튼 라벨 (데스크탑에서 표시, 모바일은 아이콘만) */
|
||||||
|
label: string;
|
||||||
|
/** 클릭 핸들러 */
|
||||||
|
onClick: () => void;
|
||||||
|
/** 버튼 variant */
|
||||||
|
variant?: 'default' | 'outline' | 'destructive' | 'secondary' | 'ghost';
|
||||||
|
/** 커스텀 클래스 (예: 'bg-green-600 hover:bg-green-700') */
|
||||||
|
className?: string;
|
||||||
|
/** 비활성화 여부 */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** 조건부 숨김 */
|
||||||
|
hidden?: boolean;
|
||||||
|
/** true면 Loader2 스피너로 아이콘 대체 */
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// ===== API 응답 타입 =====
|
// ===== API 응답 타입 =====
|
||||||
export interface ApiResponse<T = unknown> {
|
export interface ApiResponse<T = unknown> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user