Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -133,7 +133,6 @@ export default function CardTransactionDetailClient({
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDelete}
|
||||
onModeChange={handleModeChange}
|
||||
buttonPosition="top"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,7 +138,6 @@ export default function DepositDetailClientV2({
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDelete}
|
||||
onModeChange={handleModeChange}
|
||||
buttonPosition="top"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -675,7 +675,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
// ===== 동적 config =====
|
||||
const dynamicConfig = {
|
||||
...purchaseConfig,
|
||||
title: isNewMode ? '매입 등록' : '매입 상세',
|
||||
title: isNewMode ? '매입' : '매입 상세',
|
||||
actions: {
|
||||
...purchaseConfig.actions,
|
||||
submitLabel: isNewMode ? '등록' : '저장',
|
||||
|
||||
@@ -138,7 +138,6 @@ export default function WithdrawalDetailClientV2({
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDelete}
|
||||
onModeChange={handleModeChange}
|
||||
buttonPosition="top"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,13 +22,13 @@ export const documentCreateConfig: DetailConfig = {
|
||||
|
||||
export const documentEditConfig: DetailConfig = {
|
||||
...documentCreateConfig,
|
||||
title: '문서 수정',
|
||||
title: '문서',
|
||||
description: '기존 결재 문서를 수정합니다',
|
||||
// actions는 documentCreateConfig에서 상속 (커스텀 버튼 사용)
|
||||
};
|
||||
|
||||
export const documentCopyConfig: DetailConfig = {
|
||||
...documentCreateConfig,
|
||||
title: '문서 복제',
|
||||
title: '문서',
|
||||
description: '복제된 문서를 수정 후 상신합니다',
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format } from 'date-fns';
|
||||
import { FileText, Download, ArrowLeft } from 'lucide-react';
|
||||
import { FileText, Download, ArrowLeft, Trash2, Edit } from 'lucide-react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -34,6 +34,7 @@ import { CommentSection } from '../CommentSection';
|
||||
import { deletePost } from '../actions';
|
||||
import type { Post, Comment } from '../types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
|
||||
interface BoardDetailProps {
|
||||
post: Post;
|
||||
@@ -43,6 +44,7 @@ interface BoardDetailProps {
|
||||
|
||||
export function BoardDetail({ post, comments: initialComments, currentUserId }: BoardDetailProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [comments, setComments] = useState<Comment[]>(initialComments);
|
||||
@@ -119,28 +121,9 @@ export function BoardDetail({ post, comments: initialComments, currentUserId }:
|
||||
title="게시글 상세"
|
||||
description="게시글을 조회합니다."
|
||||
icon={FileText}
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
{isMyPost && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-red-500 border-red-200 hover:bg-red-50 hover:text-red-600"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEdit}>수정</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6 pb-24">
|
||||
{/* 게시글 카드 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="pb-4">
|
||||
@@ -215,6 +198,31 @@ export function BoardDetail({ post, comments: initialComments, currentUserId }:
|
||||
onDeleteComment={handleDeleteComment}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 액션 버튼 (sticky) */}
|
||||
<div className={`fixed bottom-6 ${sidebarCollapsed ? 'left-[156px]' : 'left-[316px]'} right-[48px] px-6 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 flex items-center justify-between`}>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
{isMyPost && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<DeleteConfirmDialog
|
||||
|
||||
@@ -27,7 +27,7 @@ export const boardCreateConfig: DetailConfig = {
|
||||
*/
|
||||
export const boardEditConfig: DetailConfig = {
|
||||
...boardCreateConfig,
|
||||
title: '게시글 수정',
|
||||
title: '게시글',
|
||||
description: '게시글을 수정합니다',
|
||||
actions: {
|
||||
...boardCreateConfig.actions,
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Hammer, List } from 'lucide-react';
|
||||
import { Hammer, ArrowLeft, Trash2, Edit, X, Save, Plus } from 'lucide-react';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -43,6 +44,7 @@ export default function LaborDetailClient({
|
||||
isNewMode = false,
|
||||
}: LaborDetailClientProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
|
||||
// 모드 상태
|
||||
const [mode, setMode] = useState<'view' | 'edit' | 'new'>(
|
||||
@@ -268,47 +270,6 @@ export default function LaborDetailClient({
|
||||
// 페이지 타이틀
|
||||
const pageTitle = mode === 'new' ? '노임 등록' : '노임 상세';
|
||||
|
||||
// 액션 버튼
|
||||
const actionButtons = (
|
||||
<div className="flex items-center gap-2">
|
||||
{mode === 'view' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEditMode}>수정</Button>
|
||||
</>
|
||||
)}
|
||||
{mode === 'edit' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? '저장 중...' : '저장'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{mode === 'new' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? '등록 중...' : '등록'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLoading && !isNewMode) {
|
||||
return (
|
||||
<PageLayout>
|
||||
@@ -331,9 +292,8 @@ export default function LaborDetailClient({
|
||||
title={pageTitle}
|
||||
description="노임 정보를 등록하고 관리합니다."
|
||||
icon={Hammer}
|
||||
actions={actionButtons}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 pb-24">
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -440,6 +400,56 @@ export default function LaborDetailClient({
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 하단 액션 버튼 (sticky) */}
|
||||
<div className={`fixed bottom-6 ${sidebarCollapsed ? 'left-[156px]' : 'left-[316px]'} right-[48px] px-6 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 flex items-center justify-between`}>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{mode === 'view' && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEditMode}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{mode === 'edit' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{isSaving ? '저장 중...' : '저장'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{mode === 'new' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{isSaving ? '등록 중...' : '등록'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* LaborDetailClientV2 - IntegratedDetailTemplate 기반 노임 상세/등록/수정
|
||||
*
|
||||
* 기존 LaborDetailClient를 IntegratedDetailTemplate으로 마이그레이션
|
||||
* - buttonPosition="top" 사용 (상단 버튼)
|
||||
* - 6개 필드: 노임번호, 구분, 최소M, 최대M, 노임단가, 상태
|
||||
*/
|
||||
|
||||
@@ -113,7 +112,6 @@ export default function LaborDetailClientV2({
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDelete}
|
||||
onModeChange={handleModeChange}
|
||||
buttonPosition="top"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { DollarSign, List } from 'lucide-react';
|
||||
import { DollarSign, ArrowLeft, Trash2, Edit, X, Save, Plus, List } from 'lucide-react';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -63,6 +64,7 @@ const initialFormData: FormData = {
|
||||
|
||||
export default function PricingDetailClient({ id, mode }: PricingDetailClientProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
const [pricing, setPricing] = useState<Pricing | null>(null);
|
||||
const [formData, setFormData] = useState<FormData>(initialFormData);
|
||||
const [vendors, setVendors] = useState<{ id: string; name: string }[]>([]);
|
||||
@@ -243,43 +245,9 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
|
||||
title={pageTitle}
|
||||
description={pageDescription}
|
||||
icon={DollarSign}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{isViewMode && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(true)}>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEdit}>수정</Button>
|
||||
</>
|
||||
)}
|
||||
{isEditMode && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isLoading}>
|
||||
{isLoading ? '저장 중...' : '저장'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isCreateMode && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isLoading}>
|
||||
{isLoading ? '등록 중...' : '등록'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6 pb-24">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
@@ -416,6 +384,57 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 하단 액션 버튼 (sticky) */}
|
||||
<div className={`fixed bottom-6 ${sidebarCollapsed ? 'left-[156px]' : 'left-[316px]'} right-[48px] px-6 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 flex items-center justify-between`}>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{isViewMode && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isEditMode && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isLoading}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{isLoading ? '저장 중...' : '저장'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isCreateMode && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isLoading}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{isLoading ? '등록 중...' : '등록'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<DeleteConfirmDialog
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* PricingDetailClientV2 - IntegratedDetailTemplate 기반 단가 상세/등록/수정
|
||||
*
|
||||
* 기존 PricingDetailClient를 IntegratedDetailTemplate으로 마이그레이션
|
||||
* - buttonPosition="top" 사용 (상단 버튼)
|
||||
* - 12개 필드: 단가번호, 품목유형, 카테고리명, 품목명, 규격, 무게, 단위, 구분, 거래처, 판매단가, 상태, 비고
|
||||
* - 대부분 필드 readonly, 거래처/판매단가/상태/비고만 edit/create 모드에서 수정 가능
|
||||
*/
|
||||
@@ -127,7 +126,6 @@ export default function PricingDetailClientV2({
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDelete}
|
||||
onModeChange={handleModeChange}
|
||||
buttonPosition="top"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { Client } from "../../hooks/useClientList";
|
||||
import { PageLayout } from "../organisms/PageLayout";
|
||||
import { PageHeader } from "../organisms/PageHeader";
|
||||
import { useMenuStore } from "@/store/menuStore";
|
||||
|
||||
interface ClientDetailProps {
|
||||
client: Client;
|
||||
@@ -64,6 +65,8 @@ export function ClientDetail({
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: ClientDetailProps) {
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
|
||||
// 금액 포맷
|
||||
const formatCurrency = (amount: string) => {
|
||||
if (!amount) return "-";
|
||||
@@ -73,30 +76,14 @@ export function ClientDetail({
|
||||
|
||||
return (
|
||||
<PageLayout maxWidth="2xl">
|
||||
{/* 헤더 - PageHeader 사용으로 등록/수정과 동일한 레이아웃 */}
|
||||
{/* 헤더 */}
|
||||
<PageHeader
|
||||
title={client.name}
|
||||
description="거래처 상세 정보"
|
||||
icon={Building2}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onEdit}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onDelete}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 pb-24">
|
||||
{/* 1. 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -244,6 +231,24 @@ export function ClientDetail({
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 액션 버튼 (sticky) */}
|
||||
<div className={`fixed bottom-6 ${sidebarCollapsed ? 'left-[156px]' : 'left-[316px]'} right-[48px] px-6 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 flex items-center justify-between`}>
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={onDelete} className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={onEdit}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export const clientCreateConfig: DetailConfig = {
|
||||
* 거래처 수정 페이지 Config
|
||||
*/
|
||||
export const clientEditConfig: DetailConfig = {
|
||||
title: '거래처 수정',
|
||||
title: '거래처',
|
||||
description: '거래처 정보를 수정합니다',
|
||||
icon: Building2,
|
||||
basePath: '/sales/client-management',
|
||||
|
||||
@@ -27,7 +27,7 @@ export const inquiryCreateConfig: DetailConfig = {
|
||||
*/
|
||||
export const inquiryEditConfig: DetailConfig = {
|
||||
...inquiryCreateConfig,
|
||||
title: '1:1 문의 수정',
|
||||
title: '1:1 문의',
|
||||
description: '1:1 문의를 수정합니다',
|
||||
actions: {
|
||||
...inquiryCreateConfig.actions,
|
||||
|
||||
@@ -1036,6 +1036,7 @@ export function EmployeeForm({
|
||||
renderForm={renderFormContent}
|
||||
renderView={renderFormContent}
|
||||
initialData={employee as unknown as Record<string, unknown>}
|
||||
stickyButtons={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -268,7 +268,7 @@ export default function ItemListClient() {
|
||||
// ID 추출
|
||||
idField: 'id',
|
||||
|
||||
// 테이블 컬럼
|
||||
// 테이블 컬럼 (sortable: true로 정렬 가능)
|
||||
columns: [
|
||||
{ key: 'rowNumber', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'itemCode', label: '품목코드', className: 'min-w-[120px]' },
|
||||
@@ -276,7 +276,7 @@ export default function ItemListClient() {
|
||||
{ key: 'itemName', label: '품목명', className: 'min-w-[150px]' },
|
||||
{ key: 'specification', label: '규격', className: 'min-w-[100px]' },
|
||||
{ key: 'unit', label: '단위', className: 'min-w-[60px]' },
|
||||
{ key: 'status', label: '품목상태', className: 'min-w-[80px]' },
|
||||
{ key: 'isActive', label: '품목상태', className: 'min-w-[80px]' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[120px] text-right' },
|
||||
],
|
||||
|
||||
|
||||
199
src/components/layout/CommandMenuSearch.tsx
Normal file
199
src/components/layout/CommandMenuSearch.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback, useImperativeHandle, forwardRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMenuStore, type MenuItem } from '@/store/menuStore';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
} from '@/components/ui/command';
|
||||
import { Folder, ChevronRight } from 'lucide-react';
|
||||
|
||||
// 평탄화된 메뉴 아이템 타입
|
||||
interface FlatMenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
path: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
breadcrumb: string[]; // 부모 메뉴 경로 (예: ['판매관리', '거래처관리'])
|
||||
depth: number;
|
||||
}
|
||||
|
||||
// 외부에서 제어 가능한 ref 타입
|
||||
export interface CommandMenuSearchRef {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
// 메뉴 트리를 평탄화하는 함수
|
||||
function flattenMenuItems(
|
||||
items: MenuItem[],
|
||||
parentBreadcrumb: string[] = []
|
||||
): FlatMenuItem[] {
|
||||
const result: FlatMenuItem[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const currentBreadcrumb = [...parentBreadcrumb, item.label];
|
||||
|
||||
// 실제 경로가 있는 메뉴만 추가 (# 제외)
|
||||
if (item.path && item.path !== '#') {
|
||||
result.push({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
path: item.path,
|
||||
icon: item.icon || Folder,
|
||||
breadcrumb: currentBreadcrumb,
|
||||
depth: currentBreadcrumb.length,
|
||||
});
|
||||
}
|
||||
|
||||
// 자식 메뉴 재귀 처리
|
||||
if (item.children && item.children.length > 0) {
|
||||
result.push(...flattenMenuItems(item.children, currentBreadcrumb));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const CommandMenuSearch = forwardRef<CommandMenuSearchRef>((_, ref) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const router = useRouter();
|
||||
const { menuItems } = useMenuStore();
|
||||
|
||||
// 외부에서 제어할 수 있도록 ref 노출
|
||||
useImperativeHandle(ref, () => ({
|
||||
open: () => setOpen(true),
|
||||
close: () => setOpen(false),
|
||||
toggle: () => setOpen((prev) => !prev),
|
||||
}));
|
||||
|
||||
// 평탄화된 메뉴 목록 (메모이제이션)
|
||||
const flatMenuItems = useMemo(() => {
|
||||
return flattenMenuItems(menuItems);
|
||||
}, [menuItems]);
|
||||
|
||||
// 검색 필터링 (한글 초성 검색 지원)
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!search.trim()) {
|
||||
return flatMenuItems;
|
||||
}
|
||||
|
||||
const searchLower = search.toLowerCase();
|
||||
|
||||
return flatMenuItems.filter((item) => {
|
||||
// 메뉴 이름으로 검색
|
||||
if (item.label.toLowerCase().includes(searchLower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// breadcrumb 전체 경로로 검색
|
||||
const fullPath = item.breadcrumb.join(' ').toLowerCase();
|
||||
if (fullPath.includes(searchLower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 경로로 검색
|
||||
if (item.path.toLowerCase().includes(searchLower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}, [flatMenuItems, search]);
|
||||
|
||||
// 메뉴 선택 핸들러
|
||||
const handleSelect = useCallback(
|
||||
(item: FlatMenuItem) => {
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
router.push(item.path);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// 키보드 단축키 (Ctrl+K / Cmd+K)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// 다이얼로그 닫힐 때 검색어 초기화
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
setSearch('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
title="메뉴 검색"
|
||||
description="메뉴 이름이나 경로를 입력하세요"
|
||||
>
|
||||
<CommandInput
|
||||
placeholder="메뉴 검색... (예: 거래처, 품목, 단가)"
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
||||
<CommandGroup heading="메뉴">
|
||||
{filteredItems.map((item) => {
|
||||
const IconComponent = item.icon;
|
||||
return (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
value={item.breadcrumb.join(' ')}
|
||||
onSelect={() => handleSelect(item)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<IconComponent className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||
{item.breadcrumb.map((crumb, index) => (
|
||||
<span key={index} className="flex items-center gap-1">
|
||||
{index > 0 && (
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
index === item.breadcrumb.length - 1
|
||||
? 'font-medium text-foreground'
|
||||
: 'text-muted-foreground text-sm'
|
||||
}
|
||||
>
|
||||
{crumb}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground ml-auto hidden sm:block">
|
||||
{item.path}
|
||||
</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
});
|
||||
|
||||
CommandMenuSearch.displayName = 'CommandMenuSearch';
|
||||
|
||||
export default CommandMenuSearch;
|
||||
@@ -75,7 +75,7 @@ function MenuItemComponent({
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={`w-full flex items-center rounded-xl transition-all duration-200 ease-out touch-manipulation group relative overflow-hidden sidebar-menu-item ${
|
||||
sidebarCollapsed ? 'p-3 justify-center' : 'space-x-2.5 p-3 md:p-3.5'
|
||||
sidebarCollapsed ? 'p-2.5 justify-center' : 'space-x-2.5 p-3 md:p-3.5'
|
||||
} ${
|
||||
isActive
|
||||
? "text-white clean-shadow scale-[0.98]"
|
||||
@@ -271,11 +271,11 @@ export default function Sidebar({
|
||||
<div
|
||||
ref={menuContainerRef}
|
||||
className={`sidebar-scroll flex-1 overflow-y-auto transition-all duration-300 ${
|
||||
sidebarCollapsed ? 'px-2 py-3' : 'px-3 py-4 md:px-4 md:py-4'
|
||||
sidebarCollapsed ? 'px-2 py-2' : 'px-3 py-4 md:px-4 md:py-4'
|
||||
}`}
|
||||
>
|
||||
<div className={`transition-all duration-300 ${
|
||||
sidebarCollapsed ? 'space-y-1.5 mt-4' : 'space-y-1.5 mt-3'
|
||||
sidebarCollapsed ? 'space-y-1 mt-2' : 'space-y-1.5 mt-3'
|
||||
}`}>
|
||||
{menuItems.map((item) => (
|
||||
<MenuItemComponent
|
||||
|
||||
@@ -100,12 +100,15 @@ function getOrderStatusBadge(status: OrderStatus) {
|
||||
order_confirmed: { label: "수주확정", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
production_ordered: { label: "생산지시완료", className: "bg-blue-100 text-blue-700 border-blue-200" },
|
||||
in_production: { label: "생산중", className: "bg-green-100 text-green-700 border-green-200" },
|
||||
produced: { label: "생산완료", className: "bg-blue-100 text-blue-700 border-blue-200" },
|
||||
rework: { label: "재작업중", className: "bg-orange-100 text-orange-700 border-orange-200" },
|
||||
work_completed: { label: "작업완료", className: "bg-blue-600 text-white border-blue-600" },
|
||||
shipping: { label: "출하중", className: "bg-purple-100 text-purple-700 border-purple-200" },
|
||||
shipped: { label: "출하완료", className: "bg-gray-500 text-white border-gray-500" },
|
||||
completed: { label: "완료", className: "bg-gray-500 text-white border-gray-500" },
|
||||
cancelled: { label: "취소", className: "bg-red-100 text-red-700 border-red-200" },
|
||||
};
|
||||
const config = statusConfig[status];
|
||||
const config = statusConfig[status] || { label: status, className: "bg-gray-100 text-gray-700 border-gray-200" };
|
||||
return (
|
||||
<BadgeSm className={config.className}>
|
||||
{config.label}
|
||||
@@ -239,7 +242,7 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
|
||||
const dynamicConfig = useMemo(() => {
|
||||
return {
|
||||
...orderSalesConfig,
|
||||
title: "수주 수정",
|
||||
title: "수주",
|
||||
actions: {
|
||||
...orderSalesConfig.actions,
|
||||
showEdit: false, // 수정 모드에서는 수정 버튼 숨김
|
||||
|
||||
@@ -70,12 +70,15 @@ function getOrderStatusBadge(status: OrderStatus) {
|
||||
order_confirmed: { label: "수주확정", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
production_ordered: { label: "생산지시완료", className: "bg-blue-100 text-blue-700 border-blue-200" },
|
||||
in_production: { label: "생산중", className: "bg-green-100 text-green-700 border-green-200" },
|
||||
produced: { label: "생산완료", className: "bg-blue-100 text-blue-700 border-blue-200" },
|
||||
rework: { label: "재작업중", className: "bg-orange-100 text-orange-700 border-orange-200" },
|
||||
work_completed: { label: "작업완료", className: "bg-blue-600 text-white border-blue-600" },
|
||||
shipping: { label: "출하중", className: "bg-purple-100 text-purple-700 border-purple-200" },
|
||||
shipped: { label: "출하완료", className: "bg-gray-500 text-white border-gray-500" },
|
||||
completed: { label: "완료", className: "bg-gray-500 text-white border-gray-500" },
|
||||
cancelled: { label: "취소", className: "bg-red-100 text-red-700 border-red-200" },
|
||||
};
|
||||
const config = statusConfig[status];
|
||||
const config = statusConfig[status] || { label: status, className: "bg-gray-100 text-gray-700 border-gray-200" };
|
||||
return (
|
||||
<BadgeSm className={config.className}>
|
||||
{config.label}
|
||||
|
||||
@@ -23,7 +23,7 @@ export const orderSalesConfig: DetailConfig = {
|
||||
actions: {
|
||||
showBack: true,
|
||||
showDelete: false, // 삭제 대신 취소 기능 사용
|
||||
showEdit: true,
|
||||
showEdit: false, // headerActions에서 상태별로 동적 관리
|
||||
backLabel: '목록',
|
||||
editLabel: '수정',
|
||||
submitLabel: '저장',
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { List, Edit, Wrench, Package } from 'lucide-react';
|
||||
import { List, Edit, Wrench, Package, ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { ProcessWorkLogPreviewModal } from './ProcessWorkLogPreviewModal';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import type { Process } from '@/types/process';
|
||||
import { MATCHING_TYPE_OPTIONS } from '@/types/process';
|
||||
|
||||
@@ -18,6 +20,7 @@ interface ProcessDetailProps {
|
||||
export function ProcessDetail({ process }: ProcessDetailProps) {
|
||||
const router = useRouter();
|
||||
const [workLogModalOpen, setWorkLogModalOpen] = useState(false);
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
|
||||
// 패턴 규칙과 개별 품목 분리
|
||||
const { patternRules, individualItems } = useMemo(() => {
|
||||
@@ -51,49 +54,38 @@ export function ProcessDetail({ process }: ProcessDetailProps) {
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Wrench className="h-6 w-6" />
|
||||
<h1 className="text-xl font-semibold">공정 상세</h1>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleList}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="공정 상세"
|
||||
icon={Wrench}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 pb-24">
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="bg-muted/50">
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">공정코드</div>
|
||||
{/* 반응형 6열 그리드: PC 6열, 태블릿 4열, 작은태블릿 2열, 모바일 1열 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
||||
<div className="space-y-1 lg:col-span-2">
|
||||
<div className="text-sm text-muted-foreground">공정코드</div>
|
||||
<div className="font-medium">{process.processCode}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">공정명</div>
|
||||
<div className="space-y-1 lg:col-span-2">
|
||||
<div className="text-sm text-muted-foreground">공정명</div>
|
||||
<div className="font-medium">{process.processName}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">공정구분</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-muted-foreground">공정구분</div>
|
||||
<Badge variant="secondary">{process.processType}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">담당부서</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-muted-foreground">담당부서</div>
|
||||
<div className="font-medium">{process.department}</div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<div className="text-sm text-muted-foreground mb-1">작업일지 양식</div>
|
||||
<div className="space-y-1 lg:col-span-2">
|
||||
<div className="text-sm text-muted-foreground">작업일지 양식</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{process.workLogTemplate || '-'}
|
||||
@@ -115,13 +107,13 @@ export function ProcessDetail({ process }: ProcessDetailProps) {
|
||||
<CardTitle className="text-base">등록 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">등록일</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
||||
<div className="space-y-1 lg:col-span-3">
|
||||
<div className="text-sm text-muted-foreground">등록일</div>
|
||||
<div className="font-medium">{process.createdAt}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">최종수정일</div>
|
||||
<div className="space-y-1 lg:col-span-3">
|
||||
<div className="text-sm text-muted-foreground">최종수정일</div>
|
||||
<div className="font-medium">{process.updatedAt}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -249,25 +241,37 @@ export function ProcessDetail({ process }: ProcessDetailProps) {
|
||||
<CardHeader className="bg-muted/50">
|
||||
<CardTitle className="text-base">작업 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">필요인원</div>
|
||||
<div className="font-medium">{process.requiredWorkers}명</div>
|
||||
</div>
|
||||
{process.equipmentInfo && (
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">설비정보</div>
|
||||
<div className="font-medium">{process.equipmentInfo}</div>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-muted-foreground">필요인원</div>
|
||||
<div className="font-medium">{process.requiredWorkers}명</div>
|
||||
</div>
|
||||
<div className="space-y-1 lg:col-span-2">
|
||||
<div className="text-sm text-muted-foreground">설비정보</div>
|
||||
<div className="font-medium">{process.equipmentInfo || '-'}</div>
|
||||
</div>
|
||||
<div className="space-y-1 lg:col-span-3">
|
||||
<div className="text-sm text-muted-foreground">설명</div>
|
||||
<div className="font-medium">{process.description || '-'}</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">설명</div>
|
||||
<div className="font-medium">{process.description || '-'}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 하단 액션 버튼 (sticky) */}
|
||||
<div className={`fixed bottom-6 ${sidebarCollapsed ? 'left-[156px]' : 'left-[316px]'} right-[48px] px-6 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 flex items-center justify-between`}>
|
||||
<Button variant="outline" onClick={handleList}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
<Button onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 작업일지 양식 미리보기 모달 */}
|
||||
<ProcessWorkLogPreviewModal
|
||||
open={workLogModalOpen}
|
||||
|
||||
@@ -207,8 +207,10 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
{/* 반응형 6열 그리드: PC 6열, 태블릿 4열, 작은태블릿 2열, 모바일 1열 */}
|
||||
{/* 4개 필드 → 2+1+2+1 = 6열 채움 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
||||
<div className="space-y-2 lg:col-span-2">
|
||||
<Label htmlFor="processName">공정명 *</Label>
|
||||
<Input
|
||||
id="processName"
|
||||
@@ -235,7 +237,7 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 lg:col-span-2">
|
||||
<Label>담당부서 *</Label>
|
||||
<Select value={department} onValueChange={setDepartment} disabled={isDepartmentsLoading}>
|
||||
<SelectTrigger>
|
||||
@@ -243,7 +245,7 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{departmentOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
<SelectItem key={opt.id} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -383,31 +385,33 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
<CardHeader className="bg-muted/50">
|
||||
<CardTitle className="text-base">작업 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>필요인원</Label>
|
||||
<QuantityInput
|
||||
value={requiredWorkers}
|
||||
onChange={(value) => setRequiredWorkers(value ?? 1)}
|
||||
min={1}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>설비정보</Label>
|
||||
<Input
|
||||
value={equipmentInfo}
|
||||
onChange={(e) => setEquipmentInfo(e.target.value)}
|
||||
placeholder="예: 미싱기 3대, 절단기 1대"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>세부 작업단계 (쉼표로 구분)</Label>
|
||||
<Input
|
||||
value={workSteps}
|
||||
onChange={(e) => setWorkSteps(e.target.value)}
|
||||
placeholder="예: 원단절단, 미싱, 핸드작업, 중간검사, 포장"
|
||||
/>
|
||||
<CardContent className="pt-6">
|
||||
{/* 반응형 6열 그리드: PC 6열, 태블릿 4열, 작은태블릿 2열, 모바일 1열 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>필요인원</Label>
|
||||
<QuantityInput
|
||||
value={requiredWorkers}
|
||||
onChange={(value) => setRequiredWorkers(value ?? 1)}
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 lg:col-span-2">
|
||||
<Label>설비정보</Label>
|
||||
<Input
|
||||
value={equipmentInfo}
|
||||
onChange={(e) => setEquipmentInfo(e.target.value)}
|
||||
placeholder="예: 미싱기 3대, 절단기 1대"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 lg:col-span-3">
|
||||
<Label>세부 작업단계 (쉼표로 구분)</Label>
|
||||
<Input
|
||||
value={workSteps}
|
||||
onChange={(e) => setWorkSteps(e.target.value)}
|
||||
placeholder="예: 원단절단, 미싱, 핸드작업, 중간검사, 포장"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -546,6 +546,7 @@ export async function getProcessStats(): Promise<{
|
||||
// ============================================================================
|
||||
|
||||
export interface DepartmentOption {
|
||||
id: string;
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
@@ -566,19 +567,30 @@ export async function getDepartmentOptions(): Promise<DepartmentOption[]> {
|
||||
if (error || !response?.ok) {
|
||||
// 기본 부서 옵션 반환
|
||||
return [
|
||||
{ value: '생산부', label: '생산부' },
|
||||
{ value: '품질관리부', label: '품질관리부' },
|
||||
{ value: '물류부', label: '물류부' },
|
||||
{ value: '영업부', label: '영업부' },
|
||||
{ id: 'default-1', value: '생산부', label: '생산부' },
|
||||
{ id: 'default-2', value: '품질관리부', label: '품질관리부' },
|
||||
{ id: 'default-3', value: '물류부', label: '물류부' },
|
||||
{ id: 'default-4', value: '영업부', label: '영업부' },
|
||||
];
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success && result.data?.data) {
|
||||
return result.data.data.map((dept: { id: number; name: string }) => ({
|
||||
value: dept.name,
|
||||
label: dept.name,
|
||||
}));
|
||||
// 중복 부서명 제거 (같은 이름이 여러 개일 경우 첫 번째만 사용)
|
||||
const seenNames = new Set<string>();
|
||||
return result.data.data
|
||||
.filter((dept: { id: number; name: string }) => {
|
||||
if (seenNames.has(dept.name)) {
|
||||
return false;
|
||||
}
|
||||
seenNames.add(dept.name);
|
||||
return true;
|
||||
})
|
||||
.map((dept: { id: number; name: string }) => ({
|
||||
id: String(dept.id),
|
||||
value: dept.name,
|
||||
label: dept.name,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
|
||||
@@ -231,11 +231,12 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
||||
const mode = isEditMode ? 'edit' : 'view';
|
||||
|
||||
// 동적 config (모드에 따른 타이틀 변경)
|
||||
// IntegratedDetailTemplate: edit 모드에서 자동으로 "{title} 수정" 붙음
|
||||
const dynamicConfig = useMemo(() => {
|
||||
if (isEditMode) {
|
||||
return {
|
||||
...inspectionConfig,
|
||||
title: '검사 수정',
|
||||
title: '검사',
|
||||
};
|
||||
}
|
||||
return inspectionConfig;
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
XCircle,
|
||||
RotateCcw,
|
||||
Loader2,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { DetailPageSkeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -125,6 +127,7 @@ const PERMISSION_LABELS_MAP: Record<PermissionType, string> = {
|
||||
|
||||
export function PermissionDetailClient({ permissionId, isNew = false, mode = 'view' }: PermissionDetailClientProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
|
||||
// 역할 데이터
|
||||
const [role, setRole] = useState<Role | null>(null);
|
||||
@@ -481,50 +484,9 @@ export function PermissionDetailClient({ permissionId, isNew = false, mode = 'vi
|
||||
title={isNew ? '역할 등록' : mode === 'edit' ? '역할 수정' : '역할 상세'}
|
||||
description={isNew ? '새 역할을 등록합니다' : mode === 'edit' ? '역할 정보를 수정합니다' : '역할 정보와 권한을 관리합니다'}
|
||||
icon={Shield}
|
||||
actions={
|
||||
<Button variant="ghost" size="sm" onClick={handleBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 저장/삭제 버튼 */}
|
||||
<div className="flex justify-end gap-2">
|
||||
{isNew ? (
|
||||
<Button onClick={handleSaveNew} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
저장 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleUpdateRole} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
정보 저장
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 pb-24">
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
@@ -658,6 +620,53 @@ export function PermissionDetailClient({ permissionId, isNew = false, mode = 'vi
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 액션 버튼 (sticky) */}
|
||||
<div className={`fixed bottom-6 ${sidebarCollapsed ? 'left-[156px]' : 'left-[316px]'} right-[48px] px-6 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 flex items-center justify-between`}>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{isNew ? (
|
||||
<Button onClick={handleSaveNew} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
저장 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
등록
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleUpdateRole} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
정보 저장
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDelete}
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
{!isNew && role && (
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* DetailActions - 상세 페이지 버튼 영역 컴포넌트
|
||||
*
|
||||
* 공통 레이아웃:
|
||||
* - 왼쪽: 목록으로/취소 (뒤로가기 성격)
|
||||
* - 오른쪽: [추가액션] 삭제 | 수정/저장/등록 (액션 성격)
|
||||
*
|
||||
* View 모드: 목록으로 | [추가액션] 삭제 | 수정
|
||||
* Form 모드: 취소 | 저장/등록
|
||||
* Edit 모드: 취소 | [추가액션] 삭제 | 저장
|
||||
* Create 모드: 취소 | [추가액션] 등록
|
||||
*/
|
||||
|
||||
'use client';
|
||||
@@ -11,6 +16,7 @@ import type { ReactNode } from 'react';
|
||||
import { ArrowLeft, Save, Trash2, X, Edit } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
|
||||
export interface DetailActionsProps {
|
||||
/** 현재 모드 */
|
||||
@@ -43,8 +49,10 @@ export interface DetailActionsProps {
|
||||
onDelete?: () => void;
|
||||
onEdit?: () => void;
|
||||
onSubmit?: () => void;
|
||||
/** 추가 액션 (view 모드에서 삭제 버튼 앞에 표시) */
|
||||
/** 추가 액션 (삭제 버튼 앞에 표시) */
|
||||
extraActions?: ReactNode;
|
||||
/** 하단 고정 (sticky) 모드 */
|
||||
sticky?: boolean;
|
||||
/** 추가 클래스 */
|
||||
className?: string;
|
||||
}
|
||||
@@ -61,10 +69,15 @@ export function DetailActions({
|
||||
onEdit,
|
||||
onSubmit,
|
||||
extraActions,
|
||||
sticky = false,
|
||||
className,
|
||||
}: DetailActionsProps) {
|
||||
const isViewMode = mode === 'view';
|
||||
const isCreateMode = mode === 'create';
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
// 사이드바 상태 가져오기 (sticky 모드에서 left 값 동적 계산용)
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
|
||||
const {
|
||||
canEdit = true,
|
||||
@@ -89,56 +102,59 @@ export function DetailActions({
|
||||
// 실제 submit 라벨 (create 모드면 '등록', 아니면 '저장')
|
||||
const actualSubmitLabel = submitLabel || (isCreateMode ? '등록' : '저장');
|
||||
|
||||
if (isViewMode) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-between', className)}>
|
||||
{/* 왼쪽: 목록으로 */}
|
||||
{showBack && onBack ? (
|
||||
// Fixed 스타일: 화면 하단에 고정 (사이드바 상태에 따라 동적 계산)
|
||||
// 사이드바 펼침: w-64(256px), 접힘: w-24(96px), 차이: 160px
|
||||
const stickyStyles = sticky
|
||||
? `fixed bottom-6 ${sidebarCollapsed ? 'left-[156px]' : 'left-[316px]'} right-[48px] px-6 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300`
|
||||
: '';
|
||||
|
||||
// 공통 레이아웃: 왼쪽(뒤로) | 오른쪽(액션들)
|
||||
return (
|
||||
<div className={cn('flex items-center justify-between', stickyStyles, className)}>
|
||||
{/* 왼쪽: 목록으로 (view) 또는 취소 (edit/create) */}
|
||||
{isViewMode ? (
|
||||
showBack && onBack ? (
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{backLabel}
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
)
|
||||
) : (
|
||||
<Button variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 오른쪽: 추가액션 + 삭제 + 수정 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{extraActions}
|
||||
{canDelete && showDelete && onDelete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDelete}
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{deleteLabel}
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && showEdit && onEdit && (
|
||||
<Button onClick={onEdit}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
{editLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Form 모드 (edit/create)
|
||||
return (
|
||||
<div className={cn('flex items-center justify-between', className)}>
|
||||
{/* 왼쪽: 취소 */}
|
||||
<Button variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
|
||||
{/* 오른쪽: 추가액션 + 저장/등록 */}
|
||||
{/* 오른쪽: 추가액션 + 삭제 + 수정/저장/등록 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{extraActions}
|
||||
{showSave && onSubmit && (
|
||||
|
||||
{/* 삭제 버튼: view, edit 모드에서 표시 (create는 삭제할 대상 없음) */}
|
||||
{!isCreateMode && canDelete && showDelete && onDelete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDelete}
|
||||
disabled={isSubmitting}
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{deleteLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 수정 버튼: view 모드에서만 */}
|
||||
{isViewMode && canEdit && showEdit && onEdit && (
|
||||
<Button onClick={onEdit}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
{editLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 저장/등록 버튼: edit, create 모드에서만 */}
|
||||
{!isViewMode && showSave && onSubmit && (
|
||||
<Button onClick={onSubmit} disabled={isSubmitting}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{actualSubmitLabel}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
export interface DetailGridProps {
|
||||
/** 그리드 열 수 (default: 2) */
|
||||
cols?: 1 | 2 | 3 | 4;
|
||||
cols?: 1 | 2 | 3 | 4 | 5 | 6;
|
||||
/** 그리드 간격 (default: 'md') */
|
||||
gap?: 'sm' | 'md' | 'lg';
|
||||
/** 그리드 내용 */
|
||||
@@ -22,11 +22,14 @@ export interface DetailGridProps {
|
||||
}
|
||||
|
||||
// 열 수에 따른 그리드 클래스
|
||||
// PC(lg): 설정값, 태블릿(md): 4열, 작은태블릿(sm): 2열, 모바일: 1열
|
||||
const colsClasses = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 md:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
|
||||
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
|
||||
5: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5',
|
||||
6: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-6',
|
||||
};
|
||||
|
||||
// 간격에 따른 gap 클래스
|
||||
|
||||
@@ -46,6 +46,7 @@ function IntegratedDetailTemplateInner<T extends Record<string, unknown>>(
|
||||
beforeContent,
|
||||
afterContent,
|
||||
buttonPosition = 'bottom',
|
||||
stickyButtons = true,
|
||||
}: IntegratedDetailTemplateProps<T>,
|
||||
ref: React.ForwardedRef<IntegratedDetailTemplateRef>
|
||||
) {
|
||||
@@ -320,32 +321,10 @@ function IntegratedDetailTemplateInner<T extends Record<string, unknown>>(
|
||||
const isTopButtons = buttonPosition === 'top';
|
||||
|
||||
// ===== 액션 버튼 렌더링 헬퍼 =====
|
||||
const renderActionButtons = useCallback((additionalClass?: string) => {
|
||||
if (isViewMode) {
|
||||
return (
|
||||
<DetailActions
|
||||
mode="view"
|
||||
permissions={permissions}
|
||||
showButtons={{
|
||||
back: actions.showBack !== false,
|
||||
delete: actions.showDelete !== false && !!onDelete,
|
||||
edit: actions.showEdit !== false,
|
||||
}}
|
||||
labels={{
|
||||
back: actions.backLabel,
|
||||
delete: actions.deleteLabel,
|
||||
edit: actions.editLabel,
|
||||
}}
|
||||
onBack={navigateToList}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
extraActions={headerActions}
|
||||
className={additionalClass}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// sticky는 하단 배치(buttonPosition='bottom')일 때만 적용
|
||||
const shouldSticky = stickyButtons && !isTopButtons;
|
||||
|
||||
// Form 모드 (edit/create)
|
||||
const renderActionButtons = useCallback((additionalClass?: string) => {
|
||||
return (
|
||||
<DetailActions
|
||||
mode={mode}
|
||||
@@ -370,11 +349,12 @@ function IntegratedDetailTemplateInner<T extends Record<string, unknown>>(
|
||||
onEdit={handleEdit}
|
||||
onSubmit={handleSubmit}
|
||||
extraActions={headerActions}
|
||||
sticky={shouldSticky}
|
||||
className={additionalClass}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
isViewMode, mode, isSubmitting, permissions, actions, headerActions,
|
||||
mode, isSubmitting, permissions, actions, headerActions, shouldSticky,
|
||||
navigateToList, handleDelete, handleEdit, handleCancel, handleSubmit, onDelete
|
||||
]);
|
||||
|
||||
@@ -416,9 +396,11 @@ function IntegratedDetailTemplateInner<T extends Record<string, unknown>>(
|
||||
icon={config.icon}
|
||||
actions={isTopButtons ? renderActionButtons() : undefined}
|
||||
/>
|
||||
{beforeContent}
|
||||
{renderView(initialData)}
|
||||
{afterContent}
|
||||
<div className={shouldSticky ? 'pb-24' : ''}>
|
||||
{beforeContent}
|
||||
{renderView(initialData)}
|
||||
{afterContent}
|
||||
</div>
|
||||
{/* 버튼 영역 - 하단 배치 시만 */}
|
||||
{!isTopButtons && renderActionButtons('mt-6')}
|
||||
<DeleteConfirmDialog
|
||||
@@ -443,14 +425,16 @@ function IntegratedDetailTemplateInner<T extends Record<string, unknown>>(
|
||||
icon={config.icon}
|
||||
actions={isTopButtons ? renderActionButtons() : undefined}
|
||||
/>
|
||||
{beforeContent}
|
||||
{renderForm({
|
||||
formData,
|
||||
onChange: handleChange,
|
||||
mode,
|
||||
errors,
|
||||
})}
|
||||
{afterContent}
|
||||
<div className={shouldSticky ? 'pb-24' : ''}>
|
||||
{beforeContent}
|
||||
{renderForm({
|
||||
formData,
|
||||
onChange: handleChange,
|
||||
mode,
|
||||
errors,
|
||||
})}
|
||||
{afterContent}
|
||||
</div>
|
||||
{/* 버튼 영역 - 하단 배치 시만 */}
|
||||
{!isTopButtons && renderActionButtons('mt-6')}
|
||||
{/* View 모드에서 renderForm 폴백 시 삭제 다이얼로그 필요 */}
|
||||
@@ -483,9 +467,9 @@ function IntegratedDetailTemplateInner<T extends Record<string, unknown>>(
|
||||
actions={isTopButtons ? renderActionButtons() : undefined}
|
||||
/>
|
||||
|
||||
{beforeContent}
|
||||
<div className={`space-y-6 ${shouldSticky ? 'pb-24' : ''}`}>
|
||||
{beforeContent}
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 섹션이 있으면 섹션별로, 없으면 단일 카드 */}
|
||||
{config.sections && config.sections.length > 0 ? (
|
||||
config.sections.map((section) => (
|
||||
|
||||
@@ -222,6 +222,8 @@ export interface IntegratedDetailTemplateProps<T = Record<string, unknown>> {
|
||||
afterContent?: ReactNode;
|
||||
/** 버튼 위치 (기본값: 'bottom') */
|
||||
buttonPosition?: 'top' | 'bottom';
|
||||
/** 버튼 하단 고정 (sticky) - buttonPosition이 'bottom'일 때만 적용 */
|
||||
stickyButtons?: boolean;
|
||||
}
|
||||
|
||||
// ===== API 응답 타입 =====
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, Fragment, useState, useEffect, useRef, useCallback } from "react";
|
||||
import { LucideIcon, Trash2, Plus, Loader2 } from "lucide-react";
|
||||
import { LucideIcon, Trash2, Plus, Loader2, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
|
||||
import { DateRangeSelector } from "@/components/molecules/DateRangeSelector";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
@@ -50,6 +50,8 @@ export interface TableColumn {
|
||||
className?: string;
|
||||
hideOnMobile?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
/** 정렬 가능 여부 */
|
||||
sortable?: boolean;
|
||||
}
|
||||
|
||||
export interface PaginationConfig {
|
||||
@@ -168,6 +170,14 @@ export interface IntegratedListTemplateV2Props<T = any> {
|
||||
tableColumns: TableColumn[];
|
||||
tableTitle?: string; // "전체 목록 (100개)" 같은 타이틀
|
||||
|
||||
// ===== 정렬 설정 =====
|
||||
/** 현재 정렬 컬럼 키 */
|
||||
sortBy?: string;
|
||||
/** 정렬 방향 */
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
/** 정렬 변경 핸들러 */
|
||||
onSort?: (key: string) => void;
|
||||
|
||||
// 커스텀 테이블 헤더 렌더링 (동적 컬럼용)
|
||||
renderCustomTableHeader?: () => ReactNode;
|
||||
|
||||
@@ -241,6 +251,9 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
beforeTableContent,
|
||||
tableColumns,
|
||||
tableTitle,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onSort,
|
||||
renderCustomTableHeader,
|
||||
tableFooter,
|
||||
data,
|
||||
@@ -805,12 +818,33 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
)}
|
||||
{tableColumns.map((column) => {
|
||||
// "actions" 컬럼은 항상 렌더링하되, 선택된 항목이 없을 때는 빈 헤더로 표시
|
||||
const isSortable = column.sortable && onSort;
|
||||
const isCurrentSort = sortBy === column.key;
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
key={column.key}
|
||||
className={column.className}
|
||||
className={`${column.className || ''} ${isSortable ? 'cursor-pointer select-none hover:bg-muted/50' : ''}`}
|
||||
onClick={isSortable ? () => onSort(column.key) : undefined}
|
||||
>
|
||||
{column.key === "actions" && selectedItems.size === 0 ? "" : column.label}
|
||||
{column.key === "actions" && selectedItems.size === 0 ? "" : (
|
||||
<div className={`flex items-center gap-1 ${isSortable ? 'group' : ''}`}>
|
||||
<span>{column.label}</span>
|
||||
{isSortable && (
|
||||
<span className="text-muted-foreground">
|
||||
{isCurrentSort ? (
|
||||
sortOrder === 'asc' ? (
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="h-4 w-4 opacity-0 group-hover:opacity-50" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
TabOption,
|
||||
FilterValues,
|
||||
} from './types';
|
||||
import { NON_SORTABLE_KEYS } from './types';
|
||||
|
||||
export function UniversalListPage<T>({
|
||||
config,
|
||||
@@ -57,6 +58,10 @@ export function UniversalListPage<T>({
|
||||
);
|
||||
const [tabs, setTabs] = useState<TabOption[]>(config.tabs || []);
|
||||
|
||||
// 정렬 상태
|
||||
const [sortBy, setSortBy] = useState<string | undefined>(undefined);
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||
|
||||
// 모달 상태 (detailMode === 'modal'일 때 사용)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<T | null>(null);
|
||||
@@ -116,8 +121,32 @@ export function UniversalListPage<T>({
|
||||
filtered = config.customSortFn(filtered, filters);
|
||||
}
|
||||
|
||||
// 컬럼 기반 정렬 (sortBy가 있을 때)
|
||||
if (sortBy) {
|
||||
filtered = [...filtered].sort((a, b) => {
|
||||
const aValue = (a as Record<string, unknown>)[sortBy];
|
||||
const bValue = (b as Record<string, unknown>)[sortBy];
|
||||
|
||||
// null/undefined 처리
|
||||
if (aValue == null && bValue == null) return 0;
|
||||
if (aValue == null) return sortOrder === 'asc' ? 1 : -1;
|
||||
if (bValue == null) return sortOrder === 'asc' ? -1 : 1;
|
||||
|
||||
// 숫자 비교
|
||||
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||
return sortOrder === 'asc' ? aValue - bValue : bValue - aValue;
|
||||
}
|
||||
|
||||
// 문자열 비교 (한글 지원)
|
||||
const aStr = String(aValue);
|
||||
const bStr = String(bValue);
|
||||
const comparison = aStr.localeCompare(bStr, 'ko');
|
||||
return sortOrder === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [rawData, activeTab, searchValue, filters, config.clientSideFiltering, config.tabFilter, config.searchFilter, config.customFilterFn, config.customSortFn]);
|
||||
}, [rawData, activeTab, searchValue, filters, sortBy, sortOrder, config.clientSideFiltering, config.tabFilter, config.searchFilter, config.customFilterFn, config.customSortFn]);
|
||||
|
||||
// 클라이언트 사이드 페이지네이션
|
||||
const paginatedData = useMemo(() => {
|
||||
@@ -424,6 +453,24 @@ export function UniversalListPage<T>({
|
||||
setSelectedItems(new Set());
|
||||
}, [config.initialFilters]);
|
||||
|
||||
// ===== 정렬 핸들러 =====
|
||||
const handleSort = useCallback((key: string) => {
|
||||
if (sortBy === key) {
|
||||
// 같은 컬럼 클릭: asc → desc → 정렬 해제
|
||||
if (sortOrder === 'asc') {
|
||||
setSortOrder('desc');
|
||||
} else {
|
||||
setSortBy(undefined);
|
||||
setSortOrder('asc');
|
||||
}
|
||||
} else {
|
||||
// 다른 컬럼 클릭: 해당 컬럼으로 asc 정렬
|
||||
setSortBy(key);
|
||||
setSortOrder('asc');
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, [sortBy, sortOrder]);
|
||||
|
||||
// ===== 탭 핸들러 =====
|
||||
const handleTabChange = useCallback((value: string) => {
|
||||
setActiveTab(value);
|
||||
@@ -455,12 +502,20 @@ export function UniversalListPage<T>({
|
||||
return filters as FilterValues;
|
||||
}, [filters]);
|
||||
|
||||
// ===== 탭별 컬럼 선택 =====
|
||||
// ===== 탭별 컬럼 선택 + sortable 기본값 적용 =====
|
||||
const effectiveColumns = useMemo(() => {
|
||||
if (config.columnsPerTab && activeTab && config.columnsPerTab[activeTab]) {
|
||||
return config.columnsPerTab[activeTab];
|
||||
}
|
||||
return config.columns;
|
||||
const baseColumns = config.columnsPerTab && activeTab && config.columnsPerTab[activeTab]
|
||||
? config.columnsPerTab[activeTab]
|
||||
: config.columns;
|
||||
|
||||
// sortable 기본값 적용:
|
||||
// - NON_SORTABLE_KEYS에 해당하는 키: 기본 false
|
||||
// - 그 외 모든 데이터 컬럼: 기본 true
|
||||
// - 명시적으로 지정된 값이 있으면 그 값 사용
|
||||
return baseColumns.map(col => ({
|
||||
...col,
|
||||
sortable: col.sortable ?? !NON_SORTABLE_KEYS.includes(col.key),
|
||||
}));
|
||||
}, [config.columns, config.columnsPerTab, activeTab]);
|
||||
|
||||
// ===== ID로 아이템 찾기 헬퍼 =====
|
||||
@@ -580,6 +635,10 @@ export function UniversalListPage<T>({
|
||||
}
|
||||
// 테이블 컬럼 (탭별 다른 컬럼 지원)
|
||||
tableColumns={effectiveColumns}
|
||||
// 정렬 설정 (클라이언트 사이드 필터링 시에만 활성화)
|
||||
sortBy={config.clientSideFiltering ? sortBy : undefined}
|
||||
sortOrder={config.clientSideFiltering ? sortOrder : undefined}
|
||||
onSort={config.clientSideFiltering ? handleSort : undefined}
|
||||
// 커스텀 테이블 헤더 (동적 컬럼용)
|
||||
renderCustomTableHeader={
|
||||
config.renderCustomTableHeader
|
||||
|
||||
@@ -25,8 +25,28 @@ export interface TableColumn {
|
||||
className?: string;
|
||||
hideOnMobile?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
/** 정렬 가능 여부 (기본값: true, NON_SORTABLE_KEYS에 해당하는 키는 자동 false) */
|
||||
sortable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정렬이 불필요한 컬럼 키 목록
|
||||
* - 이 키들은 sortable 기본값이 false로 처리됨
|
||||
* - 명시적으로 sortable: true를 지정하면 오버라이드 가능
|
||||
*/
|
||||
export const NON_SORTABLE_KEYS = [
|
||||
'no', // 번호 컬럼
|
||||
'rowNumber', // 행 번호
|
||||
'actions', // 작업 버튼
|
||||
'action', // 작업 버튼 (단수)
|
||||
'checkbox', // 체크박스
|
||||
'invoice', // 거래명세서 버튼
|
||||
'setting', // 설정 버튼
|
||||
'taxInvoice', // 세금계산서 버튼
|
||||
'transactionStatement', // 거래명세서 발행 버튼
|
||||
'sourceDocument', // 연결문서 버튼
|
||||
];
|
||||
|
||||
export interface StatCard {
|
||||
label: string;
|
||||
value: string | number;
|
||||
|
||||
Reference in New Issue
Block a user