diff --git a/.gitignore b/.gitignore index 27e96a6b..b3f3f04c 100644 --- a/.gitignore +++ b/.gitignore @@ -123,3 +123,6 @@ src/app/**/dev/dashboard/ # ---> Serena MCP memories .serena/ + +# ---> Deploy script (로컬 전용) +deploy.sh diff --git a/CLAUDE.md b/CLAUDE.md index e7fed7c5..bfc3edcf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,32 +35,36 @@ sam_project: - `snapshot.txt`, `.DS_Store` 파일은 항상 제외 - develop에서 자유롭게 커밋 (커밋 메시지 정리 불필요) -### main에 올리기 (기능별 squash merge) +### main에 올리기 (기능별 squash merge) — 필수 규칙 사용자가 "main에 올려줘" 또는 특정 기능을 main에 올리라고 지시할 때만 실행. **절대 자동으로 main에 push하지 않음.** +**🔴 반드시 기능별로 나눠서 올릴 것. 통째로 squash 금지.** + ```bash -# 기능별로 squash merge +# 실행 순서 git checkout main -git merge --squash develop # 또는 cherry-pick으로 특정 커밋만 선별 -git commit -m "feat: [기능명]" +git pull origin main + +# 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 checkout develop ``` -기능별로 나눠서 올리는 경우: -```bash -# 예: "대시보드랑 거래처 main에 올려줘" -git checkout main -git cherry-pick --no-commit <대시보드커밋1> <대시보드커밋2> -git commit -m "feat: CEO 대시보드 캘린더 기능 구현" - -git cherry-pick --no-commit <거래처커밋1> <거래처커밋2> -git commit -m "feat: 거래처 관리 개선" - -git push origin main -git checkout develop -``` +**기능 분류 기준**: +- 같은 도메인/모듈 수정은 하나로 묶기 (예: CEO 대시보드 관련 커밋들) +- CI/CD, 문서 등 인프라 변경은 별도 커밋 (예: chore: Jenkinsfile 정비) +- 커밋 메시지 타입: feat(기능), fix(버그), refactor(리팩토링), chore(설정/문서) **핵심: main에는 기능 단위 커밋만 → 문제 시 `git revert`로 해당 기능만 롤백 가능** diff --git a/deploy.sh b/deploy.sh deleted file mode 100755 index 6ed4fef4..00000000 --- a/deploy.sh +++ /dev/null @@ -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 "==========================================" \ No newline at end of file diff --git a/src/app/[locale]/(protected)/quality/qms/components/DayTabs.tsx b/src/app/[locale]/(protected)/quality/qms/components/DayTabs.tsx index 0ac4521c..121e5f3e 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/DayTabs.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/DayTabs.tsx @@ -26,50 +26,44 @@ export function DayTabs({ activeDay, onDayChange, day1Progress, day2Progress }: return (
- {/* 탭 버튼 */} + {/* 탭 버튼 - 모바일: 세로(아이콘→텍스트→숫자), 데스크탑: 가로 */}
- {/* 1일차 탭 */} + {/* 기준/매뉴얼 심사 탭 */} - {/* 2일차 탭 */} + {/* 로트 추적 심사 탭 */} - {canDelete && ( - - )} - - -
- ); - }, [handlePreview, handleDelete, handleSubmit, handleSaveDraft, isPending, isEditMode, canCreate, canDelete]); + // 헤더 액션 버튼 (config 배열 → 모바일 아이콘 패턴 자동 적용) + const headerActionItems = useMemo(() => [ + { icon: Eye, label: '미리보기', onClick: handlePreview, variant: 'outline' }, + { icon: Trash2, label: '삭제', onClick: handleDelete, variant: 'outline', hidden: !canDelete, disabled: isPending }, + { icon: Send, label: '상신', onClick: handleSubmit, variant: 'outline', disabled: isPending || !canCreate, loading: isPending }, + { icon: Save, label: isEditMode ? '저장' : '임시저장', onClick: handleSaveDraft, variant: 'outline', disabled: isPending, loading: isPending }, + ], [handlePreview, handleDelete, handleSubmit, handleSaveDraft, isPending, isEditMode, canCreate, canDelete]); // 폼 컨텐츠 렌더링 const renderFormContent = useCallback(() => { @@ -622,7 +580,7 @@ export function DocumentCreate() { isLoading={isLoadingDocument} onBack={handleBack} renderForm={renderFormContent} - headerActions={renderHeaderActions()} + headerActionItems={headerActionItems} /> {/* 미리보기 모달 */} diff --git a/src/components/business/CEODashboard/sections/PurchaseStatusSection.tsx b/src/components/business/CEODashboard/sections/PurchaseStatusSection.tsx index e15fccf7..c8268a33 100644 --- a/src/components/business/CEODashboard/sections/PurchaseStatusSection.tsx +++ b/src/components/business/CEODashboard/sections/PurchaseStatusSection.tsx @@ -132,7 +132,7 @@ export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) { [formatKoreanAmount(value ?? 0), '매입']} + formatter={(value) => [formatKoreanAmount(Number(value) || 0), '매입']} /> diff --git a/src/components/business/CEODashboard/sections/SalesStatusSection.tsx b/src/components/business/CEODashboard/sections/SalesStatusSection.tsx index f55ab179..699eae5b 100644 --- a/src/components/business/CEODashboard/sections/SalesStatusSection.tsx +++ b/src/components/business/CEODashboard/sections/SalesStatusSection.tsx @@ -145,7 +145,7 @@ export function SalesStatusSection({ data }: SalesStatusSectionProps) { [formatKoreanAmount(value ?? 0), '매출']} + formatter={(value) => [formatKoreanAmount(Number(value) || 0), '매출']} /> @@ -161,7 +161,7 @@ export function SalesStatusSection({ data }: SalesStatusSectionProps) { [formatKoreanAmount(value ?? 0), '매출']} + formatter={(value) => [formatKoreanAmount(Number(value) || 0), '매출']} /> diff --git a/src/components/business/construction/category-management/index.tsx b/src/components/business/construction/category-management/index.tsx index 7108655a..065cf608 100644 --- a/src/components/business/construction/category-management/index.tsx +++ b/src/components/business/construction/category-management/index.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react'; import { PageLayout } from '@/components/organisms/PageLayout'; import { PageHeader } from '@/components/organisms/PageHeader'; import { FolderTree, Plus, GripVertical, Pencil, Trash2, Loader2 } from 'lucide-react'; +import { ReorderButtons } from '@/components/molecules'; import { ContentSkeleton } from '@/components/ui/skeleton'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -206,6 +207,33 @@ export function CategoryManagement() { 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 조합 중에는 무시) const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) { @@ -217,7 +245,7 @@ export function CategoryManagement() { @@ -267,8 +295,18 @@ export function CategoryManagement() { draggedItem === index ? 'opacity-50 bg-muted' : '' }`} > - {/* 드래그 핸들 */} - + {/* 드래그 핸들 (PC만) */} + + + {/* 순서 변경 버튼 */} + handleMoveItem(index, index - 1)} + onMoveDown={() => handleMoveItem(index, index + 1)} + isFirst={index === 0} + isLast={index === categories.length - 1} + disabled={isSubmitting} + size="xs" + /> {/* 순서 번호 */} @@ -316,7 +354,7 @@ export function CategoryManagement() { {/* 안내 문구 */}

- ※ 카테고리 순서는 드래그 앤 드롭으로 변경할 수 있습니다. + ※ 화살표 버튼으로 순서를 변경할 수 있습니다.

diff --git a/src/components/business/construction/estimates/EstimateDetailForm.tsx b/src/components/business/construction/estimates/EstimateDetailForm.tsx index 9789e0c7..f8281280 100644 --- a/src/components/business/construction/estimates/EstimateDetailForm.tsx +++ b/src/components/business/construction/estimates/EstimateDetailForm.tsx @@ -3,13 +3,12 @@ import { useState, useCallback, useMemo, useEffect } from 'react'; import dynamic from 'next/dynamic'; 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 { createBiddingFromEstimate } from '../bidding/actions'; import { useAuthStore } from '@/stores/authStore'; -import { Button } from '@/components/ui/button'; 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 { toast } from 'sonner'; import type { @@ -541,38 +540,19 @@ export default function EstimateDetailForm({ })); }, []); - // ===== 헤더 버튼 ===== - const renderHeaderActions = useCallback(() => { + // ===== 헤더 버튼 (config 배열 → 모바일 아이콘 패턴 자동 적용) ===== + const headerActionItems = useMemo(() => { if (isViewMode) { - return ( -
- - - -
- ); + return [ + { icon: FileText, label: '견적서 보기', onClick: () => setShowDocumentModal(true), variant: 'outline' }, + { 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' }, + ]; } - return ( -
- - -
- ); + return [ + { icon: Trash2, label: '삭제', onClick: handleDelete, variant: 'outline', className: 'text-red-500 border-red-200 hover:bg-red-50' }, + { icon: Save, label: '저장', onClick: handleSave, variant: 'default', className: 'bg-blue-500 hover:bg-blue-600', disabled: isLoading, loading: isLoading }, + ]; }, [isViewMode, isLoading, handleDelete, handleSave, handleRegisterBidding]); // ===== 컨텐츠 렌더링 ===== @@ -694,7 +674,7 @@ export default function EstimateDetailForm({ onEdit={handleEdit} renderView={renderContent} renderForm={renderContent} - headerActions={renderHeaderActions()} + headerActionItems={headerActionItems} /> {/* 전자결재 모달 */} diff --git a/src/components/checklist-management/ChecklistDetail.tsx b/src/components/checklist-management/ChecklistDetail.tsx index f71ea8e3..90ed4aab 100644 --- a/src/components/checklist-management/ChecklistDetail.tsx +++ b/src/components/checklist-management/ChecklistDetail.tsx @@ -12,6 +12,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { ArrowLeft, Edit, GripVertical, Plus, Trash2 } from 'lucide-react'; +import { ReorderButtons } from '@/components/molecules'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; @@ -144,6 +145,23 @@ export function ChecklistDetail({ checklist }: ChecklistDetailProps) { [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 ( @@ -204,7 +222,7 @@ export function ChecklistDetail({ checklist }: ChecklistDetailProps) { - @@ -242,10 +260,19 @@ export function ChecklistDetail({ checklist }: ChecklistDetailProps) { }`} >
+ No. e.stopPropagation()} > - +
+ + handleMoveItem(index, index - 1)} + onMoveDown={() => handleMoveItem(index, index + 1)} + isFirst={index === 0} + isLast={index === items.length - 1} + size="xs" + /> +
{index + 1} diff --git a/src/components/hr/CardManagement/CardForm.tsx b/src/components/hr/CardManagement/CardForm.tsx new file mode 100755 index 00000000..713e5bfc --- /dev/null +++ b/src/components/hr/CardManagement/CardForm.tsx @@ -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({ + cardCompany: '', + cardType: '', + cardNumber: '', + cardName: '', + alias: '', + expiryDate: '', + csv: '', + paymentDay: '', + pinPrefix: '', + totalLimit: 0, + usedAmount: 0, + remainingLimit: 0, + status: 'active', + userId: '', + memo: '', + }); + + // 직원 목록 상태 + const [employees, setEmployees] = useState>([]); + 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 ( + + + +
+ {/* 기본 정보 */} + + + 기본 정보 + + +
+
+ + +
+ +
+ + handleCardNumberChange(e.target.value)} + placeholder="1234-1234-1234-1234" + maxLength={19} + /> +
+ +
+ + handleExpiryDateChange(e.target.value)} + placeholder="MMYY" + maxLength={4} + /> +
+ +
+ + handlePinPrefixChange(e.target.value)} + placeholder="**" + maxLength={2} + /> +
+ +
+ + setFormData(prev => ({ ...prev, cardName: e.target.value }))} + placeholder="카드명을 입력해주세요" + /> +
+ +
+ + +
+
+
+
+ + {/* 사용자 정보 */} + + + 사용자 정보 + + +
+ + +
+
+
+ + {/* 버튼 영역 */} +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/items/ItemMasterDataManagement/components/DraggableField.tsx b/src/components/items/ItemMasterDataManagement/components/DraggableField.tsx index c96e9fb1..879c2476 100644 --- a/src/components/items/ItemMasterDataManagement/components/DraggableField.tsx +++ b/src/components/items/ItemMasterDataManagement/components/DraggableField.tsx @@ -7,6 +7,7 @@ import { Edit, Unlink } from 'lucide-react'; +import { ReorderButtons } from '@/components/molecules'; // 입력방식 옵션 (ItemMasterDataManagement에서 사용하는 상수) const INPUT_TYPE_OPTIONS = [ @@ -25,9 +26,11 @@ interface DraggableFieldProps { moveField: (dragFieldId: number, hoverFieldId: number) => void; onDelete: () => 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 handleDragStart = (e: React.DragEvent) => { @@ -79,7 +82,14 @@ export function DraggableField({ field, index, moveField, onDelete, onEdit }: Dr >
- + + prevFieldId !== undefined && moveField(field.id, prevFieldId)} + onMoveDown={() => nextFieldId !== undefined && moveField(field.id, nextFieldId)} + isFirst={prevFieldId === undefined} + isLast={nextFieldId === undefined} + size="xs" + /> {field.field_name} {INPUT_TYPE_OPTIONS.find(t => t.value === field.field_type)?.label || field.field_type} diff --git a/src/components/items/ItemMasterDataManagement/components/DraggableSection.tsx b/src/components/items/ItemMasterDataManagement/components/DraggableSection.tsx index 0e2171f9..0db73067 100644 --- a/src/components/items/ItemMasterDataManagement/components/DraggableSection.tsx +++ b/src/components/items/ItemMasterDataManagement/components/DraggableSection.tsx @@ -10,10 +10,12 @@ import { X, Unlink } from 'lucide-react'; +import { ReorderButtons } from '@/components/molecules'; interface DraggableSectionProps { section: ItemSection; index: number; + totalSections: number; moveSection: (dragIndex: number, hoverIndex: number) => void; onDelete: () => void; onEditTitle: (id: number, title: string) => void; @@ -28,6 +30,7 @@ interface DraggableSectionProps { export function DraggableSection({ section, index, + totalSections, moveSection, onDelete, onEditTitle, @@ -87,7 +90,14 @@ export function DraggableSection({
- + + moveSection(index, index - 1)} + onMoveDown={() => moveSection(index, index + 1)} + isFirst={index === 0} + isLast={index === totalSections - 1} + size="xs" + /> {editingSectionId === section.id ? (
diff --git a/src/components/items/ItemMasterDataManagement/tabs/HierarchyTab/index.tsx b/src/components/items/ItemMasterDataManagement/tabs/HierarchyTab/index.tsx index be2e2f3a..cb01efe5 100644 --- a/src/components/items/ItemMasterDataManagement/tabs/HierarchyTab/index.tsx +++ b/src/components/items/ItemMasterDataManagement/tabs/HierarchyTab/index.tsx @@ -380,6 +380,7 @@ export function HierarchyTab({ key={`section-${section.id}-${index}`} section={section} index={index} + totalSections={selectedPage.sections.length} moveSection={(dragIndex, hoverIndex) => { moveSection(dragIndex, hoverIndex); }} @@ -469,7 +470,7 @@ export function HierarchyTab({ ) : ( section.fields .sort((a, b) => (a.order_no ?? 0) - (b.order_no ?? 0)) - .map((field, fieldIndex) => ( + .map((field, fieldIndex, sortedFields) => ( handleEditField(String(section.id), field)} + prevFieldId={fieldIndex > 0 ? sortedFields[fieldIndex - 1].id : undefined} + nextFieldId={fieldIndex < sortedFields.length - 1 ? sortedFields[fieldIndex + 1].id : undefined} /> )) )} diff --git a/src/components/molecules/ReorderButtons.tsx b/src/components/molecules/ReorderButtons.tsx new file mode 100644 index 00000000..26c1ed1a --- /dev/null +++ b/src/components/molecules/ReorderButtons.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/src/components/molecules/index.ts b/src/components/molecules/index.ts index 0d81d69a..18d26d22 100644 --- a/src/components/molecules/index.ts +++ b/src/components/molecules/index.ts @@ -13,4 +13,6 @@ export { YearQuarterFilter } from "./YearQuarterFilter"; export type { Quarter } from "./YearQuarterFilter"; export { GenericCRUDDialog } from "./GenericCRUDDialog"; -export type { GenericCRUDDialogProps, CRUDFieldDefinition } from "./GenericCRUDDialog"; \ No newline at end of file +export type { GenericCRUDDialogProps, CRUDFieldDefinition } from "./GenericCRUDDialog"; + +export { ReorderButtons } from "./ReorderButtons"; \ No newline at end of file diff --git a/src/components/pricing-table-management/PricingTableForm.tsx b/src/components/pricing-table-management/PricingTableForm.tsx index 90a6a4e9..46b57e2c 100644 --- a/src/components/pricing-table-management/PricingTableForm.tsx +++ b/src/components/pricing-table-management/PricingTableForm.tsx @@ -346,23 +346,23 @@ export function PricingTableForm({ mode, initialData }: PricingTableFormProps) {
{/* 거래등급별 판매단가 */} -
- +
+
- - - - -
+ 거래등급 + 마진율 + 판매단가 + 비고 + diff --git a/src/components/process-management/ProcessDetail.tsx b/src/components/process-management/ProcessDetail.tsx index 06325e84..2c3bffaf 100644 --- a/src/components/process-management/ProcessDetail.tsx +++ b/src/components/process-management/ProcessDetail.tsx @@ -11,7 +11,8 @@ import { useState, useEffect, useCallback, useRef } from 'react'; 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; @@ -49,7 +50,7 @@ export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps) // 드래그 상태 const [dragIndex, setDragIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null); - const dragNodeRef = useRef(null); + const dragNodeRef = useRef(null); // 개별 품목 목록 추출 const individualItems = process.classificationRules @@ -103,7 +104,7 @@ export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps) }; // ===== 드래그&드롭 (HTML5 네이티브) ===== - const handleDragStart = useCallback((e: React.DragEvent, index: number) => { + const handleDragStart = useCallback((e: React.DragEvent, index: number) => { setDragIndex(index); dragNodeRef.current = e.currentTarget; e.dataTransfer.effectAllowed = 'move'; @@ -115,7 +116,7 @@ export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps) }); }, []); - const handleDragOver = useCallback((e: React.DragEvent, index: number) => { + const handleDragOver = useCallback((e: React.DragEvent, index: number) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverIndex(index); @@ -130,7 +131,7 @@ export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps) dragNodeRef.current = null; }, []); - const handleDrop = useCallback((e: React.DragEvent, dropIndex: number) => { + const handleDrop = useCallback((e: React.DragEvent, dropIndex: number) => { e.preventDefault(); if (dragIndex === null || dragIndex === dropIndex) return; @@ -152,6 +153,23 @@ export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps) handleDragEnd(); }, [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 ( {/* 헤더 */} @@ -165,7 +183,7 @@ export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps) {/* Row 1: 공정번호 | 공정명 | 담당부서 | 담당자 */} -
+
공정번호
{process.processCode}
@@ -184,7 +202,7 @@ export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps)
{/* Row 2: 구분 | 생산일자 | 상태 */} -
+
구분
{process.processCategory || '없음'}
@@ -203,7 +221,7 @@ export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps)
{/* Row 3: 중간검사여부 | 중간검사양식 | 작업일지여부 | 작업일지양식 */} -
+
중간검사 여부
@@ -234,43 +252,37 @@ export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps) {/* 품목 설정 정보 */} - -
-
- - - 품목 설정 정보 - -

- 품목을 선택하면 이 공정으로 분류됩니다 -

-
-
- - {itemCount}개 - - -
+ +
+ + 품목 설정 정보 + + + {itemCount}개 + +
+

+ 품목을 선택하면 이 공정으로 분류됩니다 +

{individualItems.length > 0 && ( - -
+ +
{individualItems.map((item) => (
- {item.code} - {item.name} + {item.code}
))} @@ -307,34 +319,59 @@ export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps) 등록된 단계가 없습니다. [단계 등록] 버튼으로 추가해주세요.
) : ( -
+ <> + {/* 모바일: 카드 리스트 */} +
+ {steps.map((step, index) => ( +
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' + : '' + }`} + > + handleMoveStep(index, index - 1)} + onMoveDown={() => handleMoveStep(index, index + 1)} + isFirst={index === 0} + isLast={index === steps.length - 1} + size="xs" + /> + {index + 1} +
+
+ {step.stepCode} + {step.stepName} +
+
+
+ 필수 + 승인 + 검사 +
+
+ ))} +
+ {/* 데스크탑: 테이블 */} +
- - - - - - - - + + + + + + + @@ -353,58 +390,39 @@ export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps) : '' }`} > - - - - + + + - ))}
- {/* 드래그 핸들 헤더 */} - - No. - - 단계코드 - - 단계명 - - 필수여부 - - 승인여부 - - 검사여부 - - 사용 - + No.단계코드단계명필수여부승인여부검사여부사용
e.stopPropagation()} - > - + e.stopPropagation()}> +
+ + handleMoveStep(index, index - 1)} + onMoveDown={() => handleMoveStep(index, index + 1)} + isFirst={index === 0} + isLast={index === steps.length - 1} + size="xs" + /> +
- {index + 1} - - {step.stepCode} - - {step.stepName} + {index + 1}{step.stepCode}{step.stepName} + {step.isRequired ? 'Y' : 'N'} - - {step.isRequired ? 'Y' : 'N'} - + {step.needsApproval ? 'Y' : 'N'} - - {step.needsApproval ? 'Y' : 'N'} - - - - {step.needsInspection ? 'Y' : 'N'} - + {step.needsInspection ? 'Y' : 'N'} - - {step.isActive ? 'Y' : 'N'} - + {step.isActive ? 'Y' : 'N'}
+ )} diff --git a/src/components/process-management/ProcessForm.tsx b/src/components/process-management/ProcessForm.tsx index df713219..b5125085 100644 --- a/src/components/process-management/ProcessForm.tsx +++ b/src/components/process-management/ProcessForm.tsx @@ -13,7 +13,8 @@ import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; 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 { processCreateConfig, processEditConfig } from './processConfig'; import { Button } from '@/components/ui/button'; @@ -261,7 +262,7 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) { // 드래그&드롭 const dragNodeRef = useRef(null); - const handleDragStart = useCallback((e: React.DragEvent, index: number) => { + const handleDragStart = useCallback((e: React.DragEvent, index: number) => { setDragIndex(index); dragNodeRef.current = e.currentTarget; e.dataTransfer.effectAllowed = 'move'; @@ -272,7 +273,7 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) { }); }, []); - const handleDragOver = useCallback((e: React.DragEvent, index: number) => { + const handleDragOver = useCallback((e: React.DragEvent, index: number) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverIndex(index); @@ -287,8 +288,18 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) { 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( - (e: React.DragEvent, dropIndex: number) => { + (e: React.DragEvent, dropIndex: number) => { e.preventDefault(); if (dragIndex === null || dragIndex === dropIndex) { handleDragEnd(); @@ -388,7 +399,7 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) { {/* Row 1: 공정번호(수정시) | 공정명 | 담당부서 | 담당자 */} -
+
{isEdit && initialData?.processCode && (
@@ -439,7 +450,7 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
{/* Row 2: 구분 | 생산일자 | 상태 */} -
+
- -
-
- - - 품목 설정 정보 - -

- 품목을 선택하면 이 공정으로 분류됩니다 -

-
-
- - {itemCount}개 - - -
+ +
+ + 품목 설정 정보 + + + {itemCount}개 + +
+

+ 품목을 선택하면 이 공정으로 분류됩니다 +

{individualItems.length > 0 && ( - -
+ +
{individualItems.map((item) => (
- {item.code} - {item.name} + {item.code}
))} @@ -648,32 +654,71 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) { : '공정을 먼저 등록한 후 단계를 추가할 수 있습니다.'}
) : ( -
+ <> + {/* 모바일: 카드 리스트 */} +
+ {steps.map((step, index) => ( +
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' + : '' + }`} + > + handleMoveStep(index, index - 1)} + onMoveDown={() => handleMoveStep(index, index + 1)} + isFirst={index === 0} + isLast={index === steps.length - 1} + size="xs" + /> + {index + 1} +
+
+ {step.stepCode} + {step.stepName} +
+
+
+ 필수 + 승인 + 검사 +
+ +
+ ))} +
+ {/* 데스크탑: 테이블 */} +
- - - - - - - + + + + + + + @@ -693,48 +738,32 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) { : '' }`} > - - +
- - No. - - 단계코드 - - 단계명 - - 필수여부 - - 승인여부 - - 검사여부 - - 사용 - + No.단계코드단계명필수여부승인여부검사여부사용
e.stopPropagation()} - > - - - {index + 1} + e.stopPropagation()}> +
+ + handleMoveStep(index, index - 1)} + onMoveDown={() => handleMoveStep(index, index + 1)} + isFirst={index === 0} + isLast={index === steps.length - 1} + size="xs" + /> +
{index + 1} {step.stepCode} {step.stepName} - - {step.isRequired ? 'Y' : 'N'} - + {step.isRequired ? 'Y' : 'N'} - - {step.needsApproval ? 'Y' : 'N'} - + {step.needsApproval ? 'Y' : 'N'} - - {step.needsInspection ? 'Y' : 'N'} - + {step.needsInspection ? 'Y' : 'N'} - - {step.isActive ? 'Y' : 'N'} - + {step.isActive ? 'Y' : 'N'}
+ )} @@ -808,6 +838,7 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) { handleDragOver, handleDragEnd, handleDrop, + handleMoveStep, isEdit, initialData?.id, ] diff --git a/src/components/process-management/RuleModal.tsx b/src/components/process-management/RuleModal.tsx index d6a82f67..95ff1972 100644 --- a/src/components/process-management/RuleModal.tsx +++ b/src/components/process-management/RuleModal.tsx @@ -254,6 +254,7 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule, processId, proc ))} + {/* TODO: 백엔드 API에서 process_name, process_category 필터/응답 지원 후 구분 필터 활성화 + */}
@@ -283,21 +285,22 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule, processId, proc 품목유형 품목코드 품목명 - {/* TODO: API에서 process_name, process_category 필드 지원 후 실제 데이터 표시 */} + {/* TODO: 백엔드 API에서 process_name, process_category 응답 지원 후 공정/구분 컬럼 활성화 공정 구분 + */} {isItemsLoading ? ( - + 품목 목록을 불러오는 중... ) : itemList.length === 0 ? ( - + {searchKeyword.trim() === '' ? '품목을 검색해주세요 (한글 1자 이상, 영문 2자 이상)' : '검색 결과가 없습니다'} @@ -320,9 +323,10 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule, processId, proc {item.type} {item.code} {item.fullName} - {/* TODO: API 지원 후 item.processName / item.processCategory 표시 */} - - - - + {/* TODO: 백엔드 API 지원 후 item.processName / item.processCategory 표시 + {item.processName || '-'} + {item.processCategory || '-'} + */} )) )} diff --git a/src/components/quotes/QuoteCalculationReport.tsx b/src/components/quotes/QuoteCalculationReport.tsx new file mode 100755 index 00000000..56f2934d --- /dev/null +++ b/src/components/quotes/QuoteCalculationReport.tsx @@ -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 ( + <> + + + {/* 문서 컴포넌트 */} +
+ {/* 문서 헤더 */} +
+
+ {documentType === "견적서" ? "견 적 서" : "견 적 산 출 내 역 서"} +
+
+ 문서번호: {quote.id || '-'} | 작성일자: {formatDate(quote.registrationDate || '')} +
+
+ + {/* 수요자 정보 */} +
+
수 요 자
+
+ + + + + + + + + + + + + + + + + + + +
업체명{quote.clientName || '-'}
현장명{quote.siteName || '-'}담당자{quote.manager || '-'}
제품명{quote.locations?.[0]?.productName || '-'}연락처{quote.contact || '-'}
+
+
+ + {/* 공급자 정보 */} +
+
공 급 자
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
상호{companyInfo?.companyName || '-'}사업자등록번호{companyInfo?.businessNumber || '-'}
대표자{companyInfo?.representativeName || '-'}업태{companyInfo?.businessType || '-'}
종목{companyInfo?.businessCategory || '-'}
사업장주소{companyInfo?.address || '-'}
전화{companyInfo?.managerPhone || '-'}이메일{companyInfo?.email || '-'}
+
+
+ + {/* 총 견적금액 */} +
+
총 견적금액
+
₩ {formatAmount(totalAmount)}
+
※ 부가가치세 별도
+
+ + {/* 세부 산출내역서 */} + {showDetailedBreakdown && quote.locations && quote.locations.length > 0 && ( +
+
세 부 산 출 내 역
+ + + + + + + + + + + + + + + {quote.locations.map((loc, index) => { + const unitPrice = loc.unitPrice || loc.inspectionFee || 0; + const locTotal = loc.totalPrice || unitPrice * (loc.quantity || 1); + return ( + + + + + + + + + + ); + })} + + + + + + + +
No.품목명규격수량단위단가금액
{index + 1}{loc.productName}{`${loc.openWidth}×${loc.openHeight}mm`}{Math.floor(loc.quantity || 0)}SET{formatAmount(unitPrice)}{formatAmount(locTotal)}
공급가액 합계{formatAmount(totalAmount)}
+
+ )} + + {/* 소요자재 내역 */} + {showMaterialList && documentType !== "견적서" && ( +
+
소 요 자 재 내 역
+ + {/* 제품 정보 */} +
+
+ + + + + + + + + + + + + + + + + + + + + +
제품구분{quote.locations?.[0]?.itemCategory === 'steel' ? '철재' : '스크린'}부호{quote.locations?.[0]?.code || '-'}
오픈사이즈W {quote.locations?.[0]?.openWidth || '-'} × H {quote.locations?.[0]?.openHeight || '-'} (mm)제작사이즈W {Number(quote.locations?.[0]?.openWidth || 0) + 100} × H {Number(quote.locations?.[0]?.openHeight || 0) + 100} (mm)
수량{Math.floor(quote.locations?.[0]?.quantity || 1)} SET케이스2438 × 550 (mm)
+
+
+ + {/* 자재 목록 테이블 */} + {materialItems.length > 0 ? ( + + + + + + + + + + + + + {materialItems.map((item, index) => ( + + + + + + + + + ))} + +
No.품목코드자재명규격수량단위
{index + 1}{item.itemCode}{item.name}{item.spec}{item.quantity}{item.unit}
+ ) : ( +
+ 소요자재 정보가 없습니다. (BOM 계산 데이터가 필요합니다) +
+ )} +
+ )} + + {/* 비고사항 */} + {quote.remarks && ( +
+
비 고 사 항
+
+ {quote.remarks} +
+
+ )} + + {/* 서명란 */} +
+
+
+ 상기와 같이 견적합니다. +
+
+
+
{formatDate(quote.registrationDate || '')}
+
+ 공급자: {companyInfo?.companyName || '-'} (인) +
+
+
+
+ (인감
날인) +
+
+
+
+
+ + {/* 하단 안내사항 */} +
+

【 유의사항 】

+

1. 본 견적서는 {formatDate(quote.registrationDate || '')} 기준으로 작성되었으며, 자재 가격 변동 시 조정될 수 있습니다.

+

2. 견적 유효기간은 발행일로부터 30일이며, 기간 경과 시 재견적이 필요합니다.

+

3. 제작 사양 및 수량 변경 시 견적 금액이 변동될 수 있습니다.

+

4. 현장 여건에 따라 추가 비용이 발생할 수 있습니다.

+

+ 문의: {companyInfo?.managerName || quote.manager || '담당자'} | {companyInfo?.managerPhone || '-'} +

+
+
+ + ); +} \ No newline at end of file diff --git a/src/components/settings/PermissionManagement/index.tsx b/src/components/settings/PermissionManagement/index.tsx index 0ebf5dda..7cf74ff5 100644 --- a/src/components/settings/PermissionManagement/index.tsx +++ b/src/components/settings/PermissionManagement/index.tsx @@ -387,14 +387,14 @@ export function PermissionManagement() { }) => (
{selItems.size > 0 && ( - )} -
), [handleBulkDelete, handleAdd]); diff --git a/src/components/settings/RankManagement/index.tsx b/src/components/settings/RankManagement/index.tsx index 90365ba9..cc00b7ad 100644 --- a/src/components/settings/RankManagement/index.tsx +++ b/src/components/settings/RankManagement/index.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react'; import { PageLayout } from '@/components/organisms/PageLayout'; import { PageHeader } from '@/components/organisms/PageHeader'; import { Award, Plus, GripVertical, Pencil, Trash2, Loader2 } from 'lucide-react'; +import { ReorderButtons } from '@/components/molecules'; import { ContentSkeleton } from '@/components/ui/skeleton'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -185,6 +186,33 @@ export function RankManagement() { 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 조합 중에는 무시) const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) { @@ -196,7 +224,7 @@ export function RankManagement() { @@ -246,8 +274,18 @@ export function RankManagement() { draggedItem === index ? 'opacity-50 bg-muted' : '' }`} > - {/* 드래그 핸들 */} - + {/* 드래그 핸들 (PC만) */} + + + {/* 순서 변경 버튼 */} + handleMoveItem(index, index - 1)} + onMoveDown={() => handleMoveItem(index, index + 1)} + isFirst={index === 0} + isLast={index === ranks.length - 1} + disabled={isSubmitting} + size="xs" + /> {/* 순서 번호 */} @@ -295,7 +333,7 @@ export function RankManagement() { {/* 안내 문구 */}

- ※ 직급 순서는 드래그 앤 드롭으로 변경할 수 있습니다. + ※ 화살표 버튼으로 순서를 변경할 수 있습니다.

diff --git a/src/components/settings/TitleManagement/index.tsx b/src/components/settings/TitleManagement/index.tsx index 973bdc26..c164adc3 100644 --- a/src/components/settings/TitleManagement/index.tsx +++ b/src/components/settings/TitleManagement/index.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react'; import { PageLayout } from '@/components/organisms/PageLayout'; import { PageHeader } from '@/components/organisms/PageHeader'; import { Briefcase, Plus, GripVertical, Pencil, Trash2, Loader2 } from 'lucide-react'; +import { ReorderButtons } from '@/components/molecules'; import { ContentSkeleton } from '@/components/ui/skeleton'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -185,6 +186,33 @@ export function TitleManagement() { 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 조합 중에는 무시) const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) { @@ -196,7 +224,7 @@ export function TitleManagement() { @@ -246,8 +274,18 @@ export function TitleManagement() { draggedItem === index ? 'opacity-50 bg-muted' : '' }`} > - {/* 드래그 핸들 */} - + {/* 드래그 핸들 (PC만) */} + + + {/* 순서 변경 버튼 */} + handleMoveItem(index, index - 1)} + onMoveDown={() => handleMoveItem(index, index + 1)} + isFirst={index === 0} + isLast={index === titles.length - 1} + disabled={isSubmitting} + size="xs" + /> {/* 순서 번호 */} @@ -295,7 +333,7 @@ export function TitleManagement() { {/* 안내 문구 */}

- ※ 직책 순서는 드래그 앤 드롭으로 변경할 수 있습니다. + ※ 화살표 버튼으로 순서를 변경할 수 있습니다.

diff --git a/src/components/templates/IntegratedDetailTemplate/components/DetailActions.tsx b/src/components/templates/IntegratedDetailTemplate/components/DetailActions.tsx index 31ce318e..b6617ba9 100644 --- a/src/components/templates/IntegratedDetailTemplate/components/DetailActions.tsx +++ b/src/components/templates/IntegratedDetailTemplate/components/DetailActions.tsx @@ -13,10 +13,11 @@ 'use client'; 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 { cn } from '@/lib/utils'; import { useMenuStore } from '@/stores/menuStore'; +import type { ActionItem } from '../types'; export interface DetailActionsProps { /** 현재 모드 */ @@ -49,8 +50,10 @@ export interface DetailActionsProps { onDelete?: () => void; onEdit?: () => void; onSubmit?: () => void; - /** 추가 액션 (삭제 버튼 앞에 표시) */ + /** 추가 액션 (삭제 버튼 앞에 표시, 자유 JSX) */ extraActions?: ReactNode; + /** 추가 액션 아이템 (config 배열, 모바일 아이콘 패턴 자동 적용) */ + extraActionItems?: ActionItem[]; /** 하단 고정 (sticky) 모드 */ sticky?: boolean; /** 추가 클래스 */ @@ -69,6 +72,7 @@ export function DetailActions({ onEdit, onSubmit, extraActions, + extraActionItems, sticky = false, className, }: DetailActionsProps) { @@ -104,9 +108,9 @@ export function DetailActions({ // Fixed 스타일: 화면 하단에 고정 (사이드바 상태에 따라 동적 계산) // 모바일: 좌우 여백 16px (left-4 right-4) - // 태블릿/데스크탑: 사이드바 펼침(w-64=256px), 접힘(w-24=96px) + 여백 고려 + // 태블릿/데스크탑: 사이드바 펼침(w-64=256px), 접힘(w-24=96px) + 콘텐츠 패딩(24px) 맞춤 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({
{extraActions} + {/* config 배열 기반 추가 버튼 (모바일 아이콘 패턴 자동 적용) */} + {extraActionItems?.filter(item => !item.hidden).map((item, idx) => { + const Icon = item.loading ? Loader2 : item.icon; + return ( + + ); + })} + {/* 삭제 버튼: view, edit 모드에서 표시 (create는 삭제할 대상 없음) */} {!isCreateMode && canDelete && showDelete && onDelete && (