feat: 순서변경 ▲/▼ 버튼 추가 (터치 지원) + 단가표 테이블 스크롤 수정

- ReorderButtons 공통 컴포넌트 신규 생성 (molecules)
- 패턴B(리스트): RankManagement, TitleManagement, CategoryManagement
- 패턴A(테이블): ProcessDetail, ProcessForm, ChecklistDetail
- 패턴C(컴포넌트): DraggableSection, DraggableField, HierarchyTab
- 모바일: GripVertical 숨김, ▲/▼ 버튼만 표시
- 데스크톱: GripVertical + ▲/▼ 버튼 모두 표시
- 단가표 단가정보 테이블 overflow-hidden → overflow-x-auto + min-w 적용
This commit is contained in:
유병철
2026-02-25 14:28:49 +09:00
parent 4dc0644f8d
commit 0b41b9f813
21 changed files with 619 additions and 363 deletions

View File

@@ -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`로 해당 기능만 롤백 가능**

View File

@@ -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(

View File

@@ -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}
/> />
{/* 미리보기 모달 */} {/* 미리보기 모달 */}

View File

@@ -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>

View File

@@ -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}
/> />
{/* 전자결재 모달 */} {/* 전자결재 모달 */}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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">

View File

@@ -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}
/> />
)) ))
)} )}

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

View File

@@ -14,3 +14,5 @@ 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";

View File

@@ -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>

View File

@@ -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> </CardTitle>
<p className="text-sm text-muted-foreground mt-1"> <Badge variant="outline" className="text-xs">
</p>
</div>
<div className="flex items-center gap-3">
<Badge variant="outline" className="text-sm">
{itemCount} {itemCount}
</Badge> </Badge>
<Button variant="outline" size="sm" onClick={handleEdit}> <Button variant="outline" size="sm" onClick={handleEdit} className="shrink-0">
</Button> </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>

View File

@@ -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,19 +581,12 @@ 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> </CardTitle>
<p className="text-sm text-muted-foreground mt-1"> <Badge variant="outline" className="text-xs">
</p>
</div>
<div className="flex items-center gap-3">
<Badge variant="outline" className="text-sm">
{itemCount} {itemCount}
</Badge> </Badge>
<Button <Button
@@ -590,28 +594,30 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setRuleModalOpen(true)} onClick={() => setRuleModalOpen(true)}
className="shrink-0"
> >
</Button> </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,
] ]

View File

@@ -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>
)) ))
)} )}

View File

@@ -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]);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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
]); ]);

View File

@@ -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;