feat(WEB): 글로벌 검색, 토큰 갱신 개선, 템플릿 기능 확장

- CommandMenuSearch 컴포넌트 추가 (Cmd+K 글로벌 메뉴 검색)
- AuthenticatedLayout: 검색 통합, 모바일/데스크톱 스켈레톤 분리
- middleware: 토큰 갱신 후 리다이렉트 방식으로 변경 (race condition 방지)
- IntegratedDetailTemplate: stickyButtons 옵션 추가 (하단 고정 버튼)
- UniversalListPage: 컬럼 정렬 기능 추가 (sortBy, sortOrder)
- Sidebar: 축소 모드 패딩/간격 최적화
- 각종 컴포넌트 버그 수정 및 경로 정규화

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-26 15:07:10 +09:00
parent cd060ec562
commit a15132d75d
38 changed files with 927 additions and 443 deletions

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