Merge remote-tracking branch 'origin/master'

This commit is contained in:
2026-01-26 15:07:50 +09:00
38 changed files with 927 additions and 443 deletions

View File

@@ -133,7 +133,6 @@ export default function CardTransactionDetailClient({
onSubmit={handleSubmit}
onDelete={handleDelete}
onModeChange={handleModeChange}
buttonPosition="top"
/>
);
}

View File

@@ -138,7 +138,6 @@ export default function DepositDetailClientV2({
onSubmit={handleSubmit}
onDelete={handleDelete}
onModeChange={handleModeChange}
buttonPosition="top"
/>
);
}

View File

@@ -675,7 +675,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
// ===== 동적 config =====
const dynamicConfig = {
...purchaseConfig,
title: isNewMode ? '매입 등록' : '매입 상세',
title: isNewMode ? '매입' : '매입 상세',
actions: {
...purchaseConfig.actions,
submitLabel: isNewMode ? '등록' : '저장',

View File

@@ -138,7 +138,6 @@ export default function WithdrawalDetailClientV2({
onSubmit={handleSubmit}
onDelete={handleDelete}
onModeChange={handleModeChange}
buttonPosition="top"
/>
);
}

View File

@@ -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: '복제된 문서를 수정 후 상신합니다',
};

View File

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

View File

@@ -27,7 +27,7 @@ export const boardCreateConfig: DetailConfig = {
*/
export const boardEditConfig: DetailConfig = {
...boardCreateConfig,
title: '게시글 수정',
title: '게시글',
description: '게시글을 수정합니다',
actions: {
...boardCreateConfig.actions,

View File

@@ -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>
{/* 삭제 확인 다이얼로그 */}

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ export const clientCreateConfig: DetailConfig = {
* 거래처 수정 페이지 Config
*/
export const clientEditConfig: DetailConfig = {
title: '거래처 수정',
title: '거래처',
description: '거래처 정보를 수정합니다',
icon: Building2,
basePath: '/sales/client-management',

View File

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

View File

@@ -1036,6 +1036,7 @@ export function EmployeeForm({
renderForm={renderFormContent}
renderView={renderFormContent}
initialData={employee as unknown as Record<string, unknown>}
stickyButtons={true}
/>
);
}

View File

@@ -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' },
],

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

View File

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

View File

@@ -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, // 수정 모드에서는 수정 버튼 숨김

View File

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

View File

@@ -23,7 +23,7 @@ export const orderSalesConfig: DetailConfig = {
actions: {
showBack: true,
showDelete: false, // 삭제 대신 취소 기능 사용
showEdit: true,
showEdit: false, // headerActions에서 상태별로 동적 관리
backLabel: '목록',
editLabel: '수정',
submitLabel: '저장',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 클래스

View File

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

View File

@@ -222,6 +222,8 @@ export interface IntegratedDetailTemplateProps<T = Record<string, unknown>> {
afterContent?: ReactNode;
/** 버튼 위치 (기본값: 'bottom') */
buttonPosition?: 'top' | 'bottom';
/** 버튼 하단 고정 (sticky) - buttonPosition이 'bottom'일 때만 적용 */
stickyButtons?: boolean;
}
// ===== API 응답 타입 =====

View File

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

View File

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

View File

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