deploy: 2026-03-11 배포
- feat: MNG→SAM 자동 로그인 (auto-login 페이지, token-login API 프록시, auth-config) - feat: QMS 품질감사 API 연동 (actions, hooks, Day1/Day2 컴포넌트 개선) - feat: 공지 팝업 모달 (NoticePopupContainer, PopupManagement 설정 개선) - feat: CEO 대시보드 캘린더 섹션 개선 - fix: 게시판 폼, 로그인 페이지, 작업자 화면, 청구서 관리 수정 - chore: AuthenticatedLayout, logout, userStorage 정리
This commit is contained in:
306
src/app/[locale]/(protected)/quality/qms/actions.ts
Normal file
306
src/app/[locale]/(protected)/quality/qms/actions.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
'use server';
|
||||
|
||||
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
|
||||
// ===== API 원본 타입 (snake_case) =====
|
||||
// ⚠️ 'use server' 파일에서 타입 re-export 금지 (Turbopack 제한)
|
||||
|
||||
interface QualityReportApi {
|
||||
id: number;
|
||||
code: string;
|
||||
site_name: string;
|
||||
item: string;
|
||||
route_count: number;
|
||||
total_routes: number;
|
||||
quarter: string;
|
||||
year: number;
|
||||
quarter_num: number;
|
||||
}
|
||||
|
||||
interface RouteItemApi {
|
||||
id: number;
|
||||
code: string;
|
||||
date: string;
|
||||
site: string;
|
||||
location_count: number;
|
||||
sub_items: {
|
||||
id: number;
|
||||
name: string;
|
||||
location: string;
|
||||
is_completed: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface DocumentApi {
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
date?: string;
|
||||
count: number;
|
||||
items?: {
|
||||
id: number;
|
||||
title: string;
|
||||
date: string;
|
||||
code?: string;
|
||||
sub_type?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface ChecklistDetailApi {
|
||||
id: number;
|
||||
year: number;
|
||||
quarter: number;
|
||||
type: string;
|
||||
status: string;
|
||||
progress: { completed: number; total: number };
|
||||
categories: {
|
||||
id: number;
|
||||
title: string;
|
||||
sort_order: number;
|
||||
sub_items: {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
is_completed: boolean;
|
||||
completed_at?: string;
|
||||
sort_order: number;
|
||||
standard_documents: {
|
||||
id: number;
|
||||
title: string;
|
||||
version: string;
|
||||
date: string;
|
||||
file_name?: string;
|
||||
file_url?: string;
|
||||
}[];
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
|
||||
// ===== Transform 함수 (snake_case → camelCase) =====
|
||||
|
||||
function transformReportApi(api: QualityReportApi) {
|
||||
return {
|
||||
id: String(api.id),
|
||||
code: api.code,
|
||||
siteName: api.site_name,
|
||||
item: api.item,
|
||||
routeCount: api.route_count,
|
||||
totalRoutes: api.total_routes,
|
||||
quarter: api.quarter,
|
||||
year: api.year,
|
||||
quarterNum: api.quarter_num,
|
||||
};
|
||||
}
|
||||
|
||||
function transformRouteApi(api: RouteItemApi) {
|
||||
return {
|
||||
id: String(api.id),
|
||||
code: api.code,
|
||||
date: api.date,
|
||||
site: api.site,
|
||||
locationCount: api.location_count,
|
||||
subItems: api.sub_items.map((s) => ({
|
||||
id: String(s.id),
|
||||
name: s.name,
|
||||
location: s.location,
|
||||
isCompleted: s.is_completed,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function transformDocumentApi(api: DocumentApi) {
|
||||
return {
|
||||
id: String(api.id),
|
||||
type: api.type as 'import' | 'order' | 'log' | 'report' | 'confirmation' | 'shipping' | 'product' | 'quality',
|
||||
title: api.title,
|
||||
date: api.date,
|
||||
count: api.count,
|
||||
items: api.items?.map((i) => ({
|
||||
id: String(i.id),
|
||||
title: i.title,
|
||||
date: i.date,
|
||||
code: i.code,
|
||||
subType: i.sub_type as 'screen' | 'bending' | 'slat' | 'jointbar' | undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function transformChecklistDetail(api: ChecklistDetailApi) {
|
||||
return {
|
||||
progress: api.progress,
|
||||
categories: api.categories.map((cat) => ({
|
||||
id: String(cat.id),
|
||||
title: cat.title,
|
||||
subItems: cat.sub_items.map((item) => ({
|
||||
id: String(item.id),
|
||||
name: item.name,
|
||||
isCompleted: item.is_completed,
|
||||
})),
|
||||
})),
|
||||
checkItems: api.categories.flatMap((cat) =>
|
||||
cat.sub_items.map((item) => ({
|
||||
id: `check-${item.id}`,
|
||||
categoryId: String(cat.id),
|
||||
subItemId: String(item.id),
|
||||
title: item.name,
|
||||
description: item.description || '',
|
||||
buttonLabel: '기준/매뉴얼 확인',
|
||||
standardDocuments: item.standard_documents.map((doc) => ({
|
||||
id: String(doc.id),
|
||||
title: doc.title,
|
||||
version: doc.version,
|
||||
date: doc.date,
|
||||
fileName: doc.file_name,
|
||||
fileUrl: doc.file_url,
|
||||
})),
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 2일차: 로트 추적 심사 =====
|
||||
|
||||
export async function getQualityReports(params: {
|
||||
year: number;
|
||||
quarter?: number;
|
||||
q?: string;
|
||||
}) {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/qms/lot-audit/reports', {
|
||||
year: params.year,
|
||||
quarter: params.quarter,
|
||||
q: params.q,
|
||||
}),
|
||||
transform: (data: { items: QualityReportApi[] }) =>
|
||||
data.items.map(transformReportApi),
|
||||
errorMessage: '품질관리서 목록 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getReportRoutes(reportId: string) {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/qms/lot-audit/reports/${reportId}`),
|
||||
transform: (data: RouteItemApi[]) => data.map(transformRouteApi),
|
||||
errorMessage: '수주/개소 목록 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRouteDocuments(routeId: string) {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/qms/lot-audit/routes/${routeId}/documents`),
|
||||
transform: (data: DocumentApi[]) => data.map(transformDocumentApi),
|
||||
errorMessage: '서류 목록 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDocumentDetail(type: string, id: string) {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/qms/lot-audit/documents/${type}/${id}`),
|
||||
errorMessage: '서류 상세 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function confirmUnitInspection(unitId: string, confirmed: boolean) {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/qms/lot-audit/units/${unitId}/confirm`),
|
||||
method: 'PATCH',
|
||||
body: { confirmed },
|
||||
transform: (data: { id: number; name: string; location: string; is_completed: boolean }) => ({
|
||||
id: String(data.id),
|
||||
name: data.name,
|
||||
location: data.location,
|
||||
isCompleted: data.is_completed,
|
||||
}),
|
||||
errorMessage: '확인 상태 변경에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 1일차: 기준/매뉴얼 심사 =====
|
||||
|
||||
export async function getChecklistDetail(params: {
|
||||
year: number;
|
||||
quarter?: number;
|
||||
}) {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/qms/checklists', {
|
||||
year: params.year,
|
||||
quarter: params.quarter,
|
||||
}),
|
||||
transform: (data: { items: { id: number }[] }) => {
|
||||
if (data.items.length === 0) return null;
|
||||
return { checklistId: String(data.items[0].id) };
|
||||
},
|
||||
errorMessage: '점검표 목록 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getChecklistById(id: string) {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/qms/checklists/${id}`),
|
||||
transform: (data: ChecklistDetailApi) => transformChecklistDetail(data),
|
||||
errorMessage: '점검표 상세 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function toggleChecklistItem(itemId: string) {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/qms/checklist-items/${itemId}/toggle`),
|
||||
method: 'PATCH',
|
||||
transform: (data: { id: number; name: string; is_completed: boolean; completed_at?: string }) => ({
|
||||
id: String(data.id),
|
||||
name: data.name,
|
||||
isCompleted: data.is_completed,
|
||||
}),
|
||||
errorMessage: '항목 상태 변경에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCheckItemDocuments(itemId: string) {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/qms/checklist-items/${itemId}/documents`),
|
||||
transform: (data: { id: number; title: string; version: string; date: string; file_name?: string; file_url?: string }[]) =>
|
||||
data.map((d) => ({
|
||||
id: String(d.id),
|
||||
title: d.title,
|
||||
version: d.version,
|
||||
date: d.date,
|
||||
fileName: d.file_name,
|
||||
fileUrl: d.file_url,
|
||||
})),
|
||||
errorMessage: '기준 문서 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function attachStandardDocument(
|
||||
itemId: string,
|
||||
data: { title: string; version?: string; date?: string; documentId?: number },
|
||||
) {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/qms/checklist-items/${itemId}/documents`),
|
||||
method: 'POST',
|
||||
body: {
|
||||
title: data.title,
|
||||
version: data.version,
|
||||
date: data.date,
|
||||
document_id: data.documentId,
|
||||
},
|
||||
transform: (d: { id: number; title: string; version: string; date: string; file_name?: string; file_url?: string }) => ({
|
||||
id: String(d.id),
|
||||
title: d.title,
|
||||
version: d.version,
|
||||
date: d.date,
|
||||
fileName: d.file_name,
|
||||
fileUrl: d.file_url,
|
||||
}),
|
||||
errorMessage: '기준 문서 연결에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function detachStandardDocument(itemId: string, docId: string) {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/qms/checklist-items/${itemId}/documents/${docId}`),
|
||||
method: 'DELETE',
|
||||
errorMessage: '기준 문서 연결 해제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,7 @@ interface Day1ChecklistPanelProps {
|
||||
searchTerm: string;
|
||||
onSubItemSelect: (categoryId: string, subItemId: string) => void;
|
||||
onSubItemToggle: (categoryId: string, subItemId: string, isCompleted: boolean) => void;
|
||||
isMock?: boolean;
|
||||
}
|
||||
|
||||
export function Day1ChecklistPanel({
|
||||
@@ -19,6 +20,7 @@ export function Day1ChecklistPanel({
|
||||
searchTerm,
|
||||
onSubItemSelect,
|
||||
onSubItemToggle,
|
||||
isMock,
|
||||
}: Day1ChecklistPanelProps) {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||
new Set(categories.map(c => c.id)) // 기본적으로 모두 펼침
|
||||
@@ -95,7 +97,14 @@ export function Day1ChecklistPanel({
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-gray-100 px-2 sm:px-4 py-2 sm:py-3 border-b border-gray-200">
|
||||
<h3 className="font-semibold text-gray-900 text-sm sm:text-base">점검표 항목</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900 text-sm sm:text-base">점검표 항목</h3>
|
||||
{isMock && (
|
||||
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
|
||||
Mock
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 검색 결과 카운트 */}
|
||||
{searchTerm && (
|
||||
<div className="mt-1.5 sm:mt-2 text-xs text-gray-500">
|
||||
|
||||
@@ -12,6 +12,7 @@ interface Day1DocumentSectionProps {
|
||||
onDocumentSelect: (documentId: string) => void;
|
||||
onConfirmComplete: () => void;
|
||||
isCompleted: boolean;
|
||||
isMock?: boolean;
|
||||
}
|
||||
|
||||
export function Day1DocumentSection({
|
||||
@@ -20,6 +21,7 @@ export function Day1DocumentSection({
|
||||
onDocumentSelect,
|
||||
onConfirmComplete,
|
||||
isCompleted,
|
||||
isMock,
|
||||
}: Day1DocumentSectionProps) {
|
||||
if (!checkItem) {
|
||||
return (
|
||||
@@ -36,7 +38,14 @@ export function Day1DocumentSection({
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-gray-100 px-2 sm:px-4 py-2 sm:py-3 border-b border-gray-200">
|
||||
<h3 className="font-semibold text-gray-900 text-sm sm:text-base">기준 문서화</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900 text-sm sm:text-base">기준 문서화</h3>
|
||||
{isMock && (
|
||||
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
|
||||
Mock
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 콘텐츠 */}
|
||||
|
||||
@@ -7,9 +7,10 @@ import type { StandardDocument } from '../types';
|
||||
|
||||
interface Day1DocumentViewerProps {
|
||||
document: StandardDocument | null;
|
||||
isMock?: boolean;
|
||||
}
|
||||
|
||||
export function Day1DocumentViewer({ document }: Day1DocumentViewerProps) {
|
||||
export function Day1DocumentViewer({ document, isMock }: Day1DocumentViewerProps) {
|
||||
if (!document) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 h-full flex items-center justify-center">
|
||||
@@ -38,7 +39,14 @@ export function Day1DocumentViewer({ document }: Day1DocumentViewerProps) {
|
||||
<FileText className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 text-xs sm:text-sm">{document.title}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-gray-900 text-xs sm:text-sm">{document.title}</h3>
|
||||
{isMock && (
|
||||
<span className="bg-amber-100 text-amber-700 text-[9px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
|
||||
Mock
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] sm:text-xs text-gray-500">
|
||||
{document.version !== '-' && <span className="mr-2">{document.version}</span>}
|
||||
{document.date}
|
||||
|
||||
@@ -11,6 +11,7 @@ interface DocumentListProps {
|
||||
documents: Document[];
|
||||
routeCode: string | null;
|
||||
onViewDocument: (doc: Document, item?: DocumentItem) => void;
|
||||
isMock?: boolean;
|
||||
}
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
@@ -27,7 +28,7 @@ const getIcon = (type: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const DocumentList = ({ documents, routeCode, onViewDocument }: DocumentListProps) => {
|
||||
export const DocumentList = ({ documents, routeCode, onViewDocument, isMock }: DocumentListProps) => {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
// 문서 카테고리 클릭 핸들러
|
||||
@@ -52,12 +53,19 @@ export const DocumentList = ({ documents, routeCode, onViewDocument }: DocumentL
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col overflow-hidden">
|
||||
<h2 className="font-bold text-gray-800 text-xs sm:text-sm mb-3 sm:mb-4">
|
||||
관련 서류{' '}
|
||||
{routeCode && (
|
||||
<span className="text-gray-400 font-normal ml-1">({routeCode})</span>
|
||||
<div className="flex items-center gap-2 mb-3 sm:mb-4">
|
||||
<h2 className="font-bold text-gray-800 text-xs sm:text-sm">
|
||||
관련 서류{' '}
|
||||
{routeCode && (
|
||||
<span className="text-gray-400 font-normal ml-1">({routeCode})</span>
|
||||
)}
|
||||
</h2>
|
||||
{isMock && (
|
||||
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
|
||||
Mock
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:space-y-3 overflow-y-auto flex-1">
|
||||
{!routeCode ? (
|
||||
|
||||
@@ -8,13 +8,21 @@ interface ReportListProps {
|
||||
reports: InspectionReport[];
|
||||
selectedId: string | null;
|
||||
onSelect: (report: InspectionReport) => void;
|
||||
isMock?: boolean;
|
||||
}
|
||||
|
||||
export const ReportList = ({ reports, selectedId, onSelect }: ReportListProps) => {
|
||||
export const ReportList = ({ reports, selectedId, onSelect, isMock }: ReportListProps) => {
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-3 sm:mb-4">
|
||||
<h2 className="font-bold text-sm sm:text-lg text-gray-800">품질관리서 목록</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-bold text-sm sm:text-lg text-gray-800">품질관리서 목록</h2>
|
||||
{isMock && (
|
||||
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
|
||||
Mock
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="bg-blue-100 text-blue-800 text-[10px] sm:text-xs font-bold px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full">
|
||||
{reports.length}건
|
||||
</span>
|
||||
|
||||
@@ -11,9 +11,10 @@ interface RouteListProps {
|
||||
onSelect: (route: RouteItem) => void;
|
||||
onToggleItem: (routeId: string, itemId: string, isCompleted: boolean) => void;
|
||||
reportCode: string | null;
|
||||
isMock?: boolean;
|
||||
}
|
||||
|
||||
export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCode }: RouteListProps) => {
|
||||
export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCode, isMock }: RouteListProps) => {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
const handleClick = (route: RouteItem) => {
|
||||
@@ -28,12 +29,19 @@ export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCo
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col overflow-hidden">
|
||||
<h2 className="font-bold text-gray-800 text-xs sm:text-sm mb-3 sm:mb-4">
|
||||
수주루트 목록{' '}
|
||||
{reportCode && (
|
||||
<span className="text-gray-400 font-normal ml-1">({reportCode})</span>
|
||||
<div className="flex items-center gap-2 mb-3 sm:mb-4">
|
||||
<h2 className="font-bold text-gray-800 text-xs sm:text-sm">
|
||||
수주루트 목록{' '}
|
||||
{reportCode && (
|
||||
<span className="text-gray-400 font-normal ml-1">({reportCode})</span>
|
||||
)}
|
||||
</h2>
|
||||
{isMock && (
|
||||
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
|
||||
Mock
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:space-y-3 overflow-y-auto flex-1">
|
||||
{routes.length === 0 ? (
|
||||
|
||||
172
src/app/[locale]/(protected)/quality/qms/hooks/useDay1Audit.ts
Normal file
172
src/app/[locale]/(protected)/quality/qms/hooks/useDay1Audit.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { ChecklistCategory, Day1CheckItem, StandardDocument } from '../types';
|
||||
import {
|
||||
getChecklistById,
|
||||
toggleChecklistItem,
|
||||
getCheckItemDocuments,
|
||||
} from '../actions';
|
||||
import {
|
||||
MOCK_DAY1_CATEGORIES,
|
||||
MOCK_DAY1_CHECK_ITEMS,
|
||||
MOCK_DAY1_STANDARD_DOCUMENTS,
|
||||
} from '../mockData';
|
||||
|
||||
const USE_MOCK = true; // API 연동 완료 시 false로 변경
|
||||
|
||||
export function useDay1Audit() {
|
||||
// 데이터 상태
|
||||
const [categories, setCategories] = useState<ChecklistCategory[]>(USE_MOCK ? MOCK_DAY1_CATEGORIES : []);
|
||||
const [checkItems] = useState<Day1CheckItem[]>(USE_MOCK ? MOCK_DAY1_CHECK_ITEMS : []);
|
||||
const [standardDocuments] = useState<Record<string, StandardDocument[]>>(USE_MOCK ? MOCK_DAY1_STANDARD_DOCUMENTS : {});
|
||||
|
||||
// 선택 상태
|
||||
const [selectedSubItemId, setSelectedSubItemId] = useState<string | null>(null);
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
|
||||
const [selectedStandardDocId, setSelectedStandardDocId] = useState<string | null>(null);
|
||||
|
||||
// 로딩 상태
|
||||
const [loadingChecklist, setLoadingChecklist] = useState(false);
|
||||
const [pendingToggleIds, setPendingToggleIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 진행률 계산
|
||||
const day1Progress = useMemo(() => {
|
||||
const total = categories.reduce((sum, cat) => sum + cat.subItems.length, 0);
|
||||
const completed = categories.reduce(
|
||||
(sum, cat) => sum + cat.subItems.filter((item) => item.isCompleted).length,
|
||||
0,
|
||||
);
|
||||
return { completed, total };
|
||||
}, [categories]);
|
||||
|
||||
// 선택된 점검 항목
|
||||
const selectedCheckItem = useMemo(() => {
|
||||
if (!selectedSubItemId) return null;
|
||||
return checkItems.find((item) => item.subItemId === selectedSubItemId) || null;
|
||||
}, [selectedSubItemId, checkItems]);
|
||||
|
||||
// 선택된 표준 문서
|
||||
const selectedStandardDoc = useMemo(() => {
|
||||
if (!selectedStandardDocId || !selectedSubItemId) return null;
|
||||
const docs = standardDocuments[selectedSubItemId] || [];
|
||||
return docs.find((doc) => doc.id === selectedStandardDocId) || null;
|
||||
}, [selectedStandardDocId, selectedSubItemId, standardDocuments]);
|
||||
|
||||
// 선택된 항목의 완료 여부
|
||||
const isSelectedItemCompleted = useMemo(() => {
|
||||
if (!selectedSubItemId) return false;
|
||||
for (const cat of categories) {
|
||||
const item = cat.subItems.find((sub) => sub.id === selectedSubItemId);
|
||||
if (item) return item.isCompleted;
|
||||
}
|
||||
return false;
|
||||
}, [categories, selectedSubItemId]);
|
||||
|
||||
// === 핸들러 ===
|
||||
|
||||
const handleSubItemSelect = useCallback((categoryId: string, subItemId: string) => {
|
||||
setSelectedCategoryId(categoryId);
|
||||
setSelectedSubItemId(subItemId);
|
||||
setSelectedStandardDocId(null);
|
||||
}, []);
|
||||
|
||||
const handleSubItemToggle = useCallback(async (categoryId: string, subItemId: string, isCompleted: boolean) => {
|
||||
if (USE_MOCK) {
|
||||
// Mock: 로컬 상태만 업데이트
|
||||
setCategories((prev) =>
|
||||
prev.map((cat) => {
|
||||
if (cat.id !== categoryId) return cat;
|
||||
return {
|
||||
...cat,
|
||||
subItems: cat.subItems.map((item) => {
|
||||
if (item.id !== subItemId) return item;
|
||||
return { ...item, isCompleted };
|
||||
}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// API: 비관적 업데이트
|
||||
if (pendingToggleIds.has(subItemId)) return;
|
||||
setPendingToggleIds((prev) => new Set(prev).add(subItemId));
|
||||
|
||||
try {
|
||||
const result = await toggleChecklistItem(subItemId);
|
||||
if (result.success && result.data) {
|
||||
setCategories((prev) =>
|
||||
prev.map((cat) => {
|
||||
if (cat.id !== categoryId) return cat;
|
||||
return {
|
||||
...cat,
|
||||
subItems: cat.subItems.map((item) => {
|
||||
if (item.id !== subItemId) return item;
|
||||
return { ...item, isCompleted: result.data!.isCompleted };
|
||||
}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.error(result.error || '항목 상태 변경에 실패했습니다.');
|
||||
}
|
||||
} finally {
|
||||
setPendingToggleIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(subItemId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [pendingToggleIds]);
|
||||
|
||||
const handleConfirmComplete = useCallback(() => {
|
||||
if (selectedCategoryId && selectedSubItemId) {
|
||||
handleSubItemToggle(selectedCategoryId, selectedSubItemId, true);
|
||||
}
|
||||
}, [selectedCategoryId, selectedSubItemId, handleSubItemToggle]);
|
||||
|
||||
const fetchChecklist = useCallback(async (checklistId: string) => {
|
||||
if (USE_MOCK) return;
|
||||
setLoadingChecklist(true);
|
||||
try {
|
||||
const result = await getChecklistById(checklistId);
|
||||
if (result.success && result.data) {
|
||||
setCategories(result.data.categories);
|
||||
}
|
||||
} finally {
|
||||
setLoadingChecklist(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 데이터
|
||||
categories,
|
||||
day1Progress,
|
||||
selectedCheckItem,
|
||||
selectedStandardDoc,
|
||||
isSelectedItemCompleted,
|
||||
|
||||
// 선택
|
||||
selectedSubItemId,
|
||||
selectedCategoryId,
|
||||
selectedStandardDocId,
|
||||
setSelectedStandardDocId,
|
||||
handleSubItemSelect,
|
||||
|
||||
// 토글
|
||||
handleSubItemToggle,
|
||||
handleConfirmComplete,
|
||||
pendingToggleIds,
|
||||
|
||||
// 로딩
|
||||
loadingChecklist,
|
||||
|
||||
// API
|
||||
fetchChecklist,
|
||||
|
||||
// Mock 여부
|
||||
isMock: USE_MOCK,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { InspectionReport, RouteItem, Document, DocumentItem } from '../types';
|
||||
import {
|
||||
getQualityReports,
|
||||
getReportRoutes,
|
||||
getRouteDocuments,
|
||||
confirmUnitInspection,
|
||||
} from '../actions';
|
||||
import {
|
||||
MOCK_REPORTS,
|
||||
MOCK_ROUTES_INITIAL,
|
||||
MOCK_DOCUMENTS,
|
||||
DEFAULT_DOCUMENTS,
|
||||
} from '../mockData';
|
||||
|
||||
const USE_MOCK = true; // API 연동 완료 시 false로 변경
|
||||
|
||||
export function useDay2LotAudit() {
|
||||
// 필터 상태
|
||||
const [selectedYear, setSelectedYear] = useState(2025);
|
||||
const [selectedQuarter, setSelectedQuarter] = useState<'Q1' | 'Q2' | 'Q3' | 'Q4' | '전체'>('전체');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// 데이터 상태
|
||||
const [reports, setReports] = useState<InspectionReport[]>(USE_MOCK ? MOCK_REPORTS : []);
|
||||
const [routesData, setRoutesData] = useState<Record<string, RouteItem[]>>(USE_MOCK ? MOCK_ROUTES_INITIAL : {});
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
|
||||
// 선택 상태
|
||||
const [selectedReport, setSelectedReport] = useState<InspectionReport | null>(null);
|
||||
const [selectedRoute, setSelectedRoute] = useState<RouteItem | null>(null);
|
||||
|
||||
// 모달 상태
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
|
||||
const [selectedDocItem, setSelectedDocItem] = useState<DocumentItem | null>(null);
|
||||
|
||||
// 로딩 상태
|
||||
const [loadingReports, setLoadingReports] = useState(false);
|
||||
const [loadingRoutes, setLoadingRoutes] = useState(false);
|
||||
const [loadingDocuments, setLoadingDocuments] = useState(false);
|
||||
const [pendingConfirmIds, setPendingConfirmIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 진행률 계산
|
||||
const day2Progress = useMemo(() => {
|
||||
let completed = 0;
|
||||
let total = 0;
|
||||
Object.values(routesData).forEach((routes) => {
|
||||
routes.forEach((route) => {
|
||||
route.subItems.forEach((item) => {
|
||||
total++;
|
||||
if (item.isCompleted) completed++;
|
||||
});
|
||||
});
|
||||
});
|
||||
return { completed, total };
|
||||
}, [routesData]);
|
||||
|
||||
// 필터링된 보고서
|
||||
const filteredReports = useMemo(() => {
|
||||
return reports.filter((report) => {
|
||||
if (report.year !== selectedYear) return false;
|
||||
if (selectedQuarter !== '전체') {
|
||||
const quarterNum = parseInt(selectedQuarter.replace('Q', ''));
|
||||
if (report.quarterNum !== quarterNum) return false;
|
||||
}
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
const matchesCode = report.code.toLowerCase().includes(term);
|
||||
const matchesSite = report.siteName.toLowerCase().includes(term);
|
||||
const matchesItem = report.item.toLowerCase().includes(term);
|
||||
if (!matchesCode && !matchesSite && !matchesItem) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [reports, selectedYear, selectedQuarter, searchTerm]);
|
||||
|
||||
// 현재 루트/문서
|
||||
const currentRoutes = useMemo(() => {
|
||||
if (!selectedReport) return [];
|
||||
return routesData[selectedReport.id] || [];
|
||||
}, [selectedReport, routesData]);
|
||||
|
||||
const currentDocuments = useMemo(() => {
|
||||
if (USE_MOCK) {
|
||||
if (!selectedRoute) return DEFAULT_DOCUMENTS;
|
||||
return MOCK_DOCUMENTS[selectedRoute.id] || DEFAULT_DOCUMENTS;
|
||||
}
|
||||
return documents;
|
||||
}, [selectedRoute, documents]);
|
||||
|
||||
// === API 호출 핸들러 ===
|
||||
|
||||
const fetchReports = useCallback(async (year: number, quarter?: number, q?: string) => {
|
||||
if (USE_MOCK) return;
|
||||
setLoadingReports(true);
|
||||
try {
|
||||
const result = await getQualityReports({ year, quarter, q });
|
||||
if (result.success && result.data) {
|
||||
setReports(result.data);
|
||||
}
|
||||
} finally {
|
||||
setLoadingReports(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleReportSelect = useCallback(async (report: InspectionReport) => {
|
||||
setSelectedReport(report);
|
||||
setSelectedRoute(null);
|
||||
setDocuments([]);
|
||||
|
||||
if (USE_MOCK) return;
|
||||
|
||||
setLoadingRoutes(true);
|
||||
try {
|
||||
const result = await getReportRoutes(report.id);
|
||||
if (result.success && result.data) {
|
||||
setRoutesData((prev) => ({ ...prev, [report.id]: result.data! }));
|
||||
}
|
||||
} finally {
|
||||
setLoadingRoutes(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRouteSelect = useCallback(async (route: RouteItem) => {
|
||||
setSelectedRoute(route);
|
||||
|
||||
if (USE_MOCK) return;
|
||||
|
||||
setLoadingDocuments(true);
|
||||
try {
|
||||
const result = await getRouteDocuments(route.id);
|
||||
if (result.success && result.data) {
|
||||
setDocuments(result.data);
|
||||
}
|
||||
} finally {
|
||||
setLoadingDocuments(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleViewDocument = useCallback((doc: Document, item?: DocumentItem) => {
|
||||
setSelectedDoc(doc);
|
||||
setSelectedDocItem(item || null);
|
||||
setModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleToggleItem = useCallback(async (routeId: string, itemId: string, isCompleted: boolean) => {
|
||||
if (USE_MOCK) {
|
||||
// Mock: 로컬 상태만 업데이트
|
||||
setRoutesData((prev) => {
|
||||
const newData = { ...prev };
|
||||
for (const reportId of Object.keys(newData)) {
|
||||
newData[reportId] = newData[reportId].map((route) => {
|
||||
if (route.id !== routeId) return route;
|
||||
return {
|
||||
...route,
|
||||
subItems: route.subItems.map((item) => {
|
||||
if (item.id !== itemId) return item;
|
||||
return { ...item, isCompleted };
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// API: 비관적 업데이트
|
||||
if (pendingConfirmIds.has(itemId)) return;
|
||||
setPendingConfirmIds((prev) => new Set(prev).add(itemId));
|
||||
|
||||
try {
|
||||
const result = await confirmUnitInspection(itemId, isCompleted);
|
||||
if (result.success && result.data) {
|
||||
setRoutesData((prev) => {
|
||||
const newData = { ...prev };
|
||||
for (const reportId of Object.keys(newData)) {
|
||||
newData[reportId] = newData[reportId].map((route) => {
|
||||
if (route.id !== routeId) return route;
|
||||
return {
|
||||
...route,
|
||||
subItems: route.subItems.map((item) => {
|
||||
if (item.id !== itemId) return item;
|
||||
return { ...item, isCompleted: result.data!.isCompleted };
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '확인 상태 변경에 실패했습니다.');
|
||||
}
|
||||
} finally {
|
||||
setPendingConfirmIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(itemId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [pendingConfirmIds]);
|
||||
|
||||
const handleYearChange = useCallback((year: number) => {
|
||||
setSelectedYear(year);
|
||||
setSelectedReport(null);
|
||||
setSelectedRoute(null);
|
||||
setDocuments([]);
|
||||
}, []);
|
||||
|
||||
const handleQuarterChange = useCallback((quarter: 'Q1' | 'Q2' | 'Q3' | 'Q4' | '전체') => {
|
||||
setSelectedQuarter(quarter);
|
||||
setSelectedReport(null);
|
||||
setSelectedRoute(null);
|
||||
setDocuments([]);
|
||||
}, []);
|
||||
|
||||
const handleSearchChange = useCallback((term: string) => {
|
||||
setSearchTerm(term);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 필터
|
||||
selectedYear,
|
||||
selectedQuarter,
|
||||
searchTerm,
|
||||
handleYearChange,
|
||||
handleQuarterChange,
|
||||
handleSearchChange,
|
||||
|
||||
// 데이터
|
||||
filteredReports,
|
||||
currentRoutes,
|
||||
currentDocuments,
|
||||
day2Progress,
|
||||
|
||||
// 선택
|
||||
selectedReport,
|
||||
selectedRoute,
|
||||
handleReportSelect,
|
||||
handleRouteSelect,
|
||||
|
||||
// 모달
|
||||
modalOpen,
|
||||
selectedDoc,
|
||||
selectedDocItem,
|
||||
handleViewDocument,
|
||||
setModalOpen,
|
||||
|
||||
// 토글
|
||||
handleToggleItem,
|
||||
pendingConfirmIds,
|
||||
|
||||
// 로딩
|
||||
loadingReports,
|
||||
loadingRoutes,
|
||||
loadingDocuments,
|
||||
|
||||
// API
|
||||
fetchReports,
|
||||
|
||||
// Mock 여부
|
||||
isMock: USE_MOCK,
|
||||
};
|
||||
}
|
||||
@@ -1,28 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Header } from './components/Header';
|
||||
import { Filters } from './components/Filters';
|
||||
import { ReportList } from './components/ReportList';
|
||||
import { RouteList } from './components/RouteList';
|
||||
import { DocumentList } from './components/DocumentList';
|
||||
// import { InspectionModal } from './components/InspectionModal';
|
||||
import { InspectionModal } from './components/InspectionModal';
|
||||
import { DayTabs } from './components/DayTabs';
|
||||
import { Day1ChecklistPanel } from './components/Day1ChecklistPanel';
|
||||
import { Day1DocumentSection } from './components/Day1DocumentSection';
|
||||
import { Day1DocumentViewer } from './components/Day1DocumentViewer';
|
||||
import { AuditSettingsPanel, SettingsButton, type AuditDisplaySettings } from './components/AuditSettingsPanel';
|
||||
import { InspectionReport, RouteItem, Document, DocumentItem, ChecklistCategory } from './types';
|
||||
import {
|
||||
MOCK_REPORTS,
|
||||
MOCK_ROUTES_INITIAL,
|
||||
MOCK_DOCUMENTS,
|
||||
DEFAULT_DOCUMENTS,
|
||||
MOCK_DAY1_CATEGORIES,
|
||||
MOCK_DAY1_CHECK_ITEMS,
|
||||
MOCK_DAY1_STANDARD_DOCUMENTS,
|
||||
} from './mockData';
|
||||
import { useDay1Audit } from './hooks/useDay1Audit';
|
||||
import { useDay2LotAudit } from './hooks/useDay2LotAudit';
|
||||
|
||||
// 기본 설정값
|
||||
const DEFAULT_SETTINGS: AuditDisplaySettings = {
|
||||
@@ -41,192 +32,56 @@ export default function QualityInspectionPage() {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [displaySettings, setDisplaySettings] = useState<AuditDisplaySettings>(DEFAULT_SETTINGS);
|
||||
|
||||
// 1일차 상태
|
||||
const [day1Categories, setDay1Categories] = useState<ChecklistCategory[]>(MOCK_DAY1_CATEGORIES);
|
||||
const [selectedSubItemId, setSelectedSubItemId] = useState<string | null>(null);
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
|
||||
const [selectedStandardDocId, setSelectedStandardDocId] = useState<string | null>(null);
|
||||
// 1일차 커스텀 훅
|
||||
const {
|
||||
categories,
|
||||
day1Progress,
|
||||
selectedCheckItem,
|
||||
selectedStandardDoc,
|
||||
isSelectedItemCompleted,
|
||||
selectedSubItemId,
|
||||
selectedStandardDocId,
|
||||
setSelectedStandardDocId,
|
||||
handleSubItemSelect,
|
||||
handleSubItemToggle,
|
||||
handleConfirmComplete,
|
||||
isMock: day1IsMock,
|
||||
} = useDay1Audit();
|
||||
|
||||
// 2일차(로트추적) 필터 상태
|
||||
const [selectedYear, setSelectedYear] = useState(2025);
|
||||
const [selectedQuarter, setSelectedQuarter] = useState<'Q1' | 'Q2' | 'Q3' | 'Q4' | '전체'>('전체');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
// 2일차 커스텀 훅
|
||||
const {
|
||||
selectedYear,
|
||||
selectedQuarter,
|
||||
searchTerm,
|
||||
handleYearChange,
|
||||
handleQuarterChange,
|
||||
handleSearchChange,
|
||||
filteredReports,
|
||||
currentRoutes,
|
||||
currentDocuments,
|
||||
day2Progress,
|
||||
selectedReport,
|
||||
selectedRoute,
|
||||
handleReportSelect,
|
||||
handleRouteSelect,
|
||||
modalOpen,
|
||||
selectedDoc,
|
||||
selectedDocItem,
|
||||
handleViewDocument,
|
||||
setModalOpen,
|
||||
handleToggleItem,
|
||||
isMock: day2IsMock,
|
||||
} = useDay2LotAudit();
|
||||
|
||||
// 2일차 선택 상태
|
||||
const [selectedReport, setSelectedReport] = useState<InspectionReport | null>(null);
|
||||
const [selectedRoute, setSelectedRoute] = useState<RouteItem | null>(null);
|
||||
|
||||
// 2일차 루트 데이터 상태 (완료 토글용)
|
||||
const [routesData, setRoutesData] = useState<Record<string, RouteItem[]>>(MOCK_ROUTES_INITIAL);
|
||||
|
||||
// 모달 상태
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
|
||||
const [selectedDocItem, setSelectedDocItem] = useState<DocumentItem | null>(null);
|
||||
|
||||
// ===== 1일차 진행률 계산 =====
|
||||
const day1Progress = useMemo(() => {
|
||||
const total = day1Categories.reduce((sum, cat) => sum + cat.subItems.length, 0);
|
||||
const completed = day1Categories.reduce(
|
||||
(sum, cat) => sum + cat.subItems.filter(item => item.isCompleted).length,
|
||||
0
|
||||
);
|
||||
return { completed, total };
|
||||
}, [day1Categories]);
|
||||
|
||||
// ===== 2일차 진행률 계산 (개소별 완료 기준) =====
|
||||
const day2Progress = useMemo(() => {
|
||||
let completed = 0;
|
||||
let total = 0;
|
||||
Object.values(routesData).forEach(routes => {
|
||||
routes.forEach(route => {
|
||||
route.subItems.forEach(item => {
|
||||
total++;
|
||||
if (item.isCompleted) completed++;
|
||||
});
|
||||
});
|
||||
});
|
||||
return { completed, total };
|
||||
}, [routesData]);
|
||||
|
||||
// ===== 1일차 필터링된 카테고리 (완료 항목 숨기기 옵션) =====
|
||||
// 1일차 필터링된 카테고리 (완료 항목 숨기기 옵션)
|
||||
const filteredDay1Categories = useMemo(() => {
|
||||
if (displaySettings.showCompletedItems) return day1Categories;
|
||||
if (displaySettings.showCompletedItems) return categories;
|
||||
|
||||
return day1Categories.map(category => ({
|
||||
return categories.map(category => ({
|
||||
...category,
|
||||
subItems: category.subItems.filter(item => !item.isCompleted),
|
||||
})).filter(category => category.subItems.length > 0);
|
||||
}, [day1Categories, displaySettings.showCompletedItems]);
|
||||
|
||||
// ===== 1일차 핸들러 =====
|
||||
const handleSubItemSelect = useCallback((categoryId: string, subItemId: string) => {
|
||||
setSelectedCategoryId(categoryId);
|
||||
setSelectedSubItemId(subItemId);
|
||||
setSelectedStandardDocId(null);
|
||||
}, []);
|
||||
|
||||
const handleSubItemToggle = useCallback((categoryId: string, subItemId: string, isCompleted: boolean) => {
|
||||
setDay1Categories(prev => prev.map(cat => {
|
||||
if (cat.id !== categoryId) return cat;
|
||||
return {
|
||||
...cat,
|
||||
subItems: cat.subItems.map(item => {
|
||||
if (item.id !== subItemId) return item;
|
||||
return { ...item, isCompleted };
|
||||
}),
|
||||
};
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleConfirmComplete = useCallback(() => {
|
||||
if (selectedCategoryId && selectedSubItemId) {
|
||||
handleSubItemToggle(selectedCategoryId, selectedSubItemId, true);
|
||||
}
|
||||
}, [selectedCategoryId, selectedSubItemId, handleSubItemToggle]);
|
||||
|
||||
// 선택된 1일차 점검 항목
|
||||
const selectedCheckItem = useMemo(() => {
|
||||
if (!selectedSubItemId) return null;
|
||||
return MOCK_DAY1_CHECK_ITEMS.find(item => item.subItemId === selectedSubItemId) || null;
|
||||
}, [selectedSubItemId]);
|
||||
|
||||
// 선택된 표준 문서
|
||||
const selectedStandardDoc = useMemo(() => {
|
||||
if (!selectedStandardDocId || !selectedSubItemId) return null;
|
||||
const docs = MOCK_DAY1_STANDARD_DOCUMENTS[selectedSubItemId] || [];
|
||||
return docs.find(doc => doc.id === selectedStandardDocId) || null;
|
||||
}, [selectedStandardDocId, selectedSubItemId]);
|
||||
|
||||
// 선택된 항목의 완료 여부
|
||||
const isSelectedItemCompleted = useMemo(() => {
|
||||
if (!selectedSubItemId) return false;
|
||||
for (const cat of day1Categories) {
|
||||
const item = cat.subItems.find(item => item.id === selectedSubItemId);
|
||||
if (item) return item.isCompleted;
|
||||
}
|
||||
return false;
|
||||
}, [day1Categories, selectedSubItemId]);
|
||||
|
||||
// ===== 2일차(로트추적) 로직 =====
|
||||
const filteredReports = useMemo(() => {
|
||||
return MOCK_REPORTS.filter((report) => {
|
||||
if (report.year !== selectedYear) return false;
|
||||
if (selectedQuarter !== '전체') {
|
||||
const quarterNum = parseInt(selectedQuarter.replace('Q', ''));
|
||||
if (report.quarterNum !== quarterNum) return false;
|
||||
}
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
const matchesCode = report.code.toLowerCase().includes(term);
|
||||
const matchesSite = report.siteName.toLowerCase().includes(term);
|
||||
const matchesItem = report.item.toLowerCase().includes(term);
|
||||
if (!matchesCode && !matchesSite && !matchesItem) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [selectedYear, selectedQuarter, searchTerm]);
|
||||
|
||||
const currentRoutes = useMemo(() => {
|
||||
if (!selectedReport) return [];
|
||||
return routesData[selectedReport.id] || [];
|
||||
}, [selectedReport, routesData]);
|
||||
|
||||
const currentDocuments = useMemo(() => {
|
||||
if (!selectedRoute) return DEFAULT_DOCUMENTS;
|
||||
return MOCK_DOCUMENTS[selectedRoute.id] || DEFAULT_DOCUMENTS;
|
||||
}, [selectedRoute]);
|
||||
|
||||
const handleReportSelect = (report: InspectionReport) => {
|
||||
setSelectedReport(report);
|
||||
setSelectedRoute(null);
|
||||
};
|
||||
|
||||
const handleRouteSelect = (route: RouteItem) => {
|
||||
setSelectedRoute(route);
|
||||
};
|
||||
|
||||
const handleViewDocument = (doc: Document, item?: DocumentItem) => {
|
||||
setSelectedDoc(doc);
|
||||
setSelectedDocItem(item || null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleYearChange = (year: number) => {
|
||||
setSelectedYear(year);
|
||||
setSelectedReport(null);
|
||||
setSelectedRoute(null);
|
||||
};
|
||||
|
||||
const handleQuarterChange = (quarter: 'Q1' | 'Q2' | 'Q3' | 'Q4' | '전체') => {
|
||||
setSelectedQuarter(quarter);
|
||||
setSelectedReport(null);
|
||||
setSelectedRoute(null);
|
||||
};
|
||||
|
||||
const handleSearchChange = (term: string) => {
|
||||
setSearchTerm(term);
|
||||
};
|
||||
|
||||
// ===== 2일차 개소별 완료 토글 =====
|
||||
const handleToggleItem = useCallback((routeId: string, itemId: string, isCompleted: boolean) => {
|
||||
setRoutesData(prev => {
|
||||
const newData = { ...prev };
|
||||
for (const reportId of Object.keys(newData)) {
|
||||
newData[reportId] = newData[reportId].map(route => {
|
||||
if (route.id !== routeId) return route;
|
||||
return {
|
||||
...route,
|
||||
subItems: route.subItems.map(item => {
|
||||
if (item.id !== itemId) return item;
|
||||
return { ...item, isCompleted };
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
}, []);
|
||||
}, [categories, displaySettings.showCompletedItems]);
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] p-3 sm:p-4 md:p-5 lg:p-6 bg-slate-100 flex flex-col lg:overflow-auto">
|
||||
@@ -298,6 +153,7 @@ export default function QualityInspectionPage() {
|
||||
searchTerm={searchTerm}
|
||||
onSubItemSelect={handleSubItemSelect}
|
||||
onSubItemToggle={handleSubItemToggle}
|
||||
isMock={day1IsMock}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -312,6 +168,7 @@ export default function QualityInspectionPage() {
|
||||
onDocumentSelect={setSelectedStandardDocId}
|
||||
onConfirmComplete={handleConfirmComplete}
|
||||
isCompleted={isSelectedItemCompleted}
|
||||
isMock={day1IsMock}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -321,7 +178,7 @@ export default function QualityInspectionPage() {
|
||||
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto ${
|
||||
displaySettings.showDocumentSection ? 'lg:col-span-5' : 'lg:col-span-8'
|
||||
}`}>
|
||||
<Day1DocumentViewer document={selectedStandardDoc} />
|
||||
<Day1DocumentViewer document={selectedStandardDoc} isMock={day1IsMock} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -333,6 +190,7 @@ export default function QualityInspectionPage() {
|
||||
reports={filteredReports}
|
||||
selectedId={selectedReport?.id || null}
|
||||
onSelect={handleReportSelect}
|
||||
isMock={day2IsMock}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -343,6 +201,7 @@ export default function QualityInspectionPage() {
|
||||
onSelect={handleRouteSelect}
|
||||
onToggleItem={handleToggleItem}
|
||||
reportCode={selectedReport?.code || null}
|
||||
isMock={day2IsMock}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -351,6 +210,7 @@ export default function QualityInspectionPage() {
|
||||
documents={currentDocuments}
|
||||
routeCode={selectedRoute?.code || null}
|
||||
onViewDocument={handleViewDocument}
|
||||
isMock={day2IsMock}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
112
src/app/[locale]/auto-login/page.tsx
Normal file
112
src/app/[locale]/auto-login/page.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { transformApiMenusToMenuItems } from '@/lib/utils/menuTransform';
|
||||
import { performFullLogout } from '@/lib/auth/logout';
|
||||
|
||||
/**
|
||||
* MNG 관리자 패널 → SAM 자동 로그인 페이지
|
||||
*
|
||||
* 흐름:
|
||||
* 1. MNG에서 "SAM 접속" 클릭 → /auto-login?token=xxx 로 새 창 열림
|
||||
* 2. 기존 세션 로그아웃 (쿠키 + localStorage + Zustand 초기화)
|
||||
* 3. One-Time Token으로 API 호출 → 새 세션 생성
|
||||
* 4. 사용자 정보 저장 후 /dashboard로 이동
|
||||
*/
|
||||
export default function AutoLoginPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [status, setStatus] = useState<'processing' | 'error'>('processing');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const token = searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
setStatus('error');
|
||||
setErrorMessage('로그인 토큰이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const performAutoLogin = async () => {
|
||||
try {
|
||||
// 1. 기존 세션 완전 로그아웃 (쿠키 삭제 + 스토어 초기화)
|
||||
await performFullLogout({ skipServerLogout: false });
|
||||
|
||||
// 2. One-Time Token으로 로그인
|
||||
const response = await fetch('/api/auth/token-login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '자동 로그인에 실패했습니다.');
|
||||
}
|
||||
|
||||
// 3. 사용자 정보 localStorage 저장 (LoginPage와 동일 패턴)
|
||||
const transformedMenus = transformApiMenusToMenuItems(data.menus || []);
|
||||
|
||||
const userData = {
|
||||
id: data.user?.id,
|
||||
name: data.user?.name,
|
||||
position: data.roles?.[0]?.description || '사용자',
|
||||
userId: data.user?.user_id,
|
||||
department: data.user?.department || null,
|
||||
department_id: data.user?.department_id || null,
|
||||
menu: transformedMenus,
|
||||
roles: data.roles || [],
|
||||
tenant: data.tenant || {},
|
||||
};
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
|
||||
// 4. persist store rehydrate
|
||||
const { useFavoritesStore } = await import('@/stores/favoritesStore');
|
||||
const { useTableColumnStore } = await import('@/stores/useTableColumnStore');
|
||||
useFavoritesStore.persist.rehydrate();
|
||||
useTableColumnStore.persist.rehydrate();
|
||||
|
||||
// 5. 로그인 플래그 설정
|
||||
sessionStorage.setItem('auth_just_logged_in', 'true');
|
||||
|
||||
// 6. 대시보드로 이동
|
||||
router.push('/dashboard');
|
||||
} catch (err) {
|
||||
console.error('자동 로그인 실패:', err);
|
||||
setStatus('error');
|
||||
setErrorMessage(err instanceof Error ? err.message : '자동 로그인에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
performAutoLogin();
|
||||
}, [searchParams, router]);
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center space-y-4 p-8">
|
||||
<div className="text-destructive text-lg font-semibold">자동 로그인 실패</div>
|
||||
<p className="text-muted-foreground">{errorMessage}</p>
|
||||
<button
|
||||
onClick={() => router.push('/login')}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition"
|
||||
>
|
||||
로그인 페이지로 이동
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="inline-block h-10 w-10 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent" />
|
||||
<p className="text-muted-foreground">자동 로그인 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
src/app/api/auth/token-login/route.ts
Normal file
107
src/app/api/auth/token-login/route.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* 🔵 Next.js 내부 API - 토큰 자동 로그인 프록시
|
||||
*
|
||||
* MNG 관리자 패널에서 "SAM 접속" 버튼 클릭 시 사용
|
||||
* One-Time Token으로 사용자 인증 후 HttpOnly 쿠키 설정
|
||||
*
|
||||
* 🔄 동작 흐름:
|
||||
* 1. 클라이언트 → Next.js /api/auth/token-login (token)
|
||||
* 2. Next.js → PHP /api/v1/token-login (토큰 검증)
|
||||
* 3. PHP → Next.js (access_token, refresh_token, 사용자 정보)
|
||||
* 4. Next.js: 토큰을 HttpOnly 쿠키로 설정
|
||||
* 5. Next.js → 클라이언트 (토큰 제외한 사용자 정보만 전달)
|
||||
*/
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { token } = body;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: '토큰이 필요합니다.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// PHP 백엔드 API 호출
|
||||
const backendResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/token-login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
},
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
if (!backendResponse.ok) {
|
||||
const errorData = await backendResponse.json().catch(() => ({}));
|
||||
return NextResponse.json(
|
||||
{ error: errorData.error || '토큰 인증에 실패했습니다.' },
|
||||
{ status: backendResponse.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await backendResponse.json();
|
||||
|
||||
// 클라이언트에 전달할 응답 (토큰 제외)
|
||||
const responseData = {
|
||||
message: data.message,
|
||||
user: data.user,
|
||||
tenant: data.tenant,
|
||||
menus: data.menus,
|
||||
roles: data.roles,
|
||||
token_type: data.token_type,
|
||||
expires_in: data.expires_in,
|
||||
expires_at: data.expires_at,
|
||||
};
|
||||
|
||||
// HttpOnly 쿠키 설정 (login/route.ts와 동일한 패턴)
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
const accessTokenCookie = [
|
||||
`access_token=${data.access_token}`,
|
||||
'HttpOnly',
|
||||
...(isProduction ? ['Secure'] : []),
|
||||
'SameSite=Lax',
|
||||
'Path=/',
|
||||
`Max-Age=${data.expires_in || 7200}`,
|
||||
].join('; ');
|
||||
|
||||
const refreshTokenCookie = [
|
||||
`refresh_token=${data.refresh_token}`,
|
||||
'HttpOnly',
|
||||
...(isProduction ? ['Secure'] : []),
|
||||
'SameSite=Lax',
|
||||
'Path=/',
|
||||
'Max-Age=604800',
|
||||
].join('; ');
|
||||
|
||||
const isAuthenticatedCookie = [
|
||||
'is_authenticated=true',
|
||||
...(isProduction ? ['Secure'] : []),
|
||||
'SameSite=Lax',
|
||||
'Path=/',
|
||||
`Max-Age=${data.expires_in || 7200}`,
|
||||
].join('; ');
|
||||
|
||||
const response = NextResponse.json(responseData, { status: 200 });
|
||||
|
||||
response.headers.append('Set-Cookie', accessTokenCookie);
|
||||
response.headers.append('Set-Cookie', refreshTokenCookie);
|
||||
response.headers.append('Set-Cookie', isAuthenticatedCookie);
|
||||
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Token login proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { useDateRange } from '@/hooks';
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
Save,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -51,6 +51,8 @@ import {
|
||||
BILL_TYPE_FILTER_OPTIONS,
|
||||
BILL_STATUS_COLORS,
|
||||
BILL_STATUS_FILTER_OPTIONS,
|
||||
RECEIVED_BILL_STATUS_OPTIONS,
|
||||
ISSUED_BILL_STATUS_OPTIONS,
|
||||
getBillStatusLabel,
|
||||
} from './types';
|
||||
import { getBills, deleteBill, updateBillStatus } from './actions';
|
||||
@@ -84,6 +86,7 @@ export function BillManagementClient({
|
||||
const [billTypeFilter, setBillTypeFilter] = useState<string>(initialBillType || 'received');
|
||||
const [vendorFilter, setVendorFilter] = useState<string>(initialVendorId || 'all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [targetStatus, setTargetStatus] = useState<string>('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(initialPagination.currentPage);
|
||||
const itemsPerPage = initialPagination.perPage;
|
||||
@@ -262,15 +265,15 @@ export function BillManagementClient({
|
||||
];
|
||||
}, [data]);
|
||||
|
||||
// ===== 저장 핸들러 =====
|
||||
const handleSave = useCallback(async () => {
|
||||
// ===== 상태 변경 핸들러 =====
|
||||
const handleStatusChange = useCallback(async () => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.warning('선택된 항목이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusFilter === 'all') {
|
||||
toast.warning('상태를 선택해주세요.');
|
||||
if (!targetStatus) {
|
||||
toast.warning('변경할 상태를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -278,7 +281,7 @@ export function BillManagementClient({
|
||||
let successCount = 0;
|
||||
|
||||
for (const id of selectedItems) {
|
||||
const result = await updateBillStatus(id, statusFilter as BillStatus);
|
||||
const result = await updateBillStatus(id, targetStatus as BillStatus);
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
}
|
||||
@@ -286,14 +289,20 @@ export function BillManagementClient({
|
||||
|
||||
if (successCount > 0) {
|
||||
invalidateDashboard('bill');
|
||||
toast.success(`${successCount}건이 저장되었습니다.`);
|
||||
toast.success(`${successCount}건의 상태가 변경되었습니다.`);
|
||||
loadData(currentPage);
|
||||
setSelectedItems(new Set());
|
||||
setTargetStatus('');
|
||||
} else {
|
||||
toast.error('저장에 실패했습니다.');
|
||||
toast.error('상태 변경에 실패했습니다.');
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, [selectedItems, statusFilter, loadData, currentPage]);
|
||||
}, [selectedItems, targetStatus, loadData, currentPage]);
|
||||
|
||||
// 구분에 따른 상태 옵션
|
||||
const statusChangeOptions = useMemo(() => {
|
||||
return billTypeFilter === 'issued' ? ISSUED_BILL_STATUS_OPTIONS : RECEIVED_BILL_STATUS_OPTIONS;
|
||||
}, [billTypeFilter]);
|
||||
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<BillRecord> = useMemo(
|
||||
@@ -377,12 +386,30 @@ export function BillManagementClient({
|
||||
icon: Plus,
|
||||
},
|
||||
|
||||
// 헤더 액션: 저장 버튼만 (필터는 tableHeaderActions에서 통합 관리)
|
||||
headerActions: () => (
|
||||
<Button onClick={handleSave} size="sm" disabled={isLoading}>
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
저장
|
||||
</Button>
|
||||
// 선택 시 상태 변경 액션
|
||||
selectionActions: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={targetStatus} onValueChange={setTargetStatus}>
|
||||
<SelectTrigger className="min-w-[130px] w-auto h-8">
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusChangeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleStatusChange}
|
||||
disabled={!targetStatus || isLoading}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
상태변경
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 테이블 헤더 액션 (필터)
|
||||
@@ -447,7 +474,9 @@ export function BillManagementClient({
|
||||
router,
|
||||
loadData,
|
||||
currentPage,
|
||||
handleSave,
|
||||
handleStatusChange,
|
||||
statusChangeOptions,
|
||||
targetStatus,
|
||||
renderTableRow,
|
||||
renderMobileCard,
|
||||
]
|
||||
|
||||
@@ -119,12 +119,20 @@ export function LoginPage() {
|
||||
name: data.user?.name || userId,
|
||||
position: data.roles?.[0]?.description || '사용자',
|
||||
userId: userId,
|
||||
department: data.user?.department || null,
|
||||
department_id: data.user?.department_id || null,
|
||||
menu: transformedMenus, // 변환된 메뉴 구조 저장
|
||||
roles: data.roles || [],
|
||||
tenant: data.tenant || {},
|
||||
};
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
|
||||
// 유저별 persist store를 새 유저 키로 rehydrate
|
||||
const { useFavoritesStore } = await import('@/stores/favoritesStore');
|
||||
const { useTableColumnStore } = await import('@/stores/useTableColumnStore');
|
||||
useFavoritesStore.persist.rehydrate();
|
||||
useTableColumnStore.persist.rehydrate();
|
||||
|
||||
// 메뉴 폴링 재시작 플래그 설정 (세션 만료 후 재로그인 시)
|
||||
sessionStorage.setItem('auth_just_logged_in', 'true');
|
||||
|
||||
|
||||
@@ -61,13 +61,14 @@ interface BoardFormProps {
|
||||
initialData?: Post;
|
||||
}
|
||||
|
||||
// 현재 로그인 사용자 정보 (실제로는 auth context에서 가져옴)
|
||||
const CURRENT_USER = {
|
||||
id: 'user1',
|
||||
name: '홍길동',
|
||||
department: '개발팀',
|
||||
position: '과장',
|
||||
};
|
||||
// 로그인 사용자 이름을 가져오는 헬퍼
|
||||
function getLoggedInUserName(): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
try {
|
||||
const userDataStr = localStorage.getItem('user');
|
||||
return userDataStr ? JSON.parse(userDataStr).name || '' : '';
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
// 상단 고정 최대 개수
|
||||
const MAX_PINNED_COUNT = 5;
|
||||
@@ -75,6 +76,12 @@ const MAX_PINNED_COUNT = 5;
|
||||
export function BoardForm({ mode, initialData }: BoardFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 로그인 사용자 이름
|
||||
const [currentUserName, setCurrentUserName] = useState('');
|
||||
useEffect(() => {
|
||||
setCurrentUserName(getLoggedInUserName());
|
||||
}, []);
|
||||
|
||||
// ===== 폼 상태 =====
|
||||
const [boardCode, setBoardCode] = useState(initialData?.boardCode || '');
|
||||
const [isPinned, setIsPinned] = useState(initialData?.isPinned ? 'true' : 'false');
|
||||
@@ -330,7 +337,7 @@ export function BoardForm({ mode, initialData }: BoardFormProps) {
|
||||
<div className="space-y-2">
|
||||
<Label>작성자</Label>
|
||||
<Input
|
||||
value={CURRENT_USER.name}
|
||||
value={currentUserName}
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
|
||||
@@ -117,8 +117,14 @@ export function BoardForm({ mode, board, onSubmit }: BoardFormProps) {
|
||||
}));
|
||||
};
|
||||
|
||||
// 작성자 (현재 로그인한 사용자 - mock)
|
||||
const currentUser = '홍길동';
|
||||
// 작성자 (로그인한 사용자)
|
||||
const [currentUser, setCurrentUser] = useState('');
|
||||
useEffect(() => {
|
||||
const userDataStr = typeof window !== 'undefined' ? localStorage.getItem('user') : null;
|
||||
if (userDataStr) {
|
||||
try { setCurrentUser(JSON.parse(userDataStr).name || ''); } catch { /* ignore */ }
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 등록일시
|
||||
const registeredAt = mode === 'edit' && board ? formatDateTime(board.createdAt) : getCurrentDateTime();
|
||||
|
||||
@@ -38,17 +38,35 @@ const SCHEDULE_TYPE_COLORS: Record<string, string> = {
|
||||
schedule: 'blue',
|
||||
order: 'green',
|
||||
construction: 'purple',
|
||||
bill: 'amber',
|
||||
expected_expense: 'rose',
|
||||
delivery: 'cyan',
|
||||
shipment: 'teal',
|
||||
issue: 'red',
|
||||
other: 'gray',
|
||||
holiday: 'red',
|
||||
tax: 'orange',
|
||||
};
|
||||
|
||||
// 일정 타입별 상세 페이지 라우트
|
||||
const SCHEDULE_TYPE_ROUTES: Record<string, string> = {
|
||||
bill: '/accounting/bills',
|
||||
order: '/production/work-orders',
|
||||
construction: '/construction/project/contract',
|
||||
expected_expense: '/accounting/expected-expenses',
|
||||
delivery: '/sales/order-management-sales',
|
||||
shipment: '/outbound/shipments',
|
||||
};
|
||||
|
||||
// 일정 타입별 라벨
|
||||
const SCHEDULE_TYPE_LABELS: Record<string, string> = {
|
||||
order: '생산',
|
||||
construction: '시공',
|
||||
schedule: '일정',
|
||||
bill: '어음',
|
||||
expected_expense: '결제예정',
|
||||
delivery: '납기',
|
||||
shipment: '출고',
|
||||
other: '기타',
|
||||
};
|
||||
|
||||
@@ -57,6 +75,10 @@ const SCHEDULE_TYPE_BADGE_COLORS: Record<string, string> = {
|
||||
order: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||
construction: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||
schedule: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
bill: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
expected_expense: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300',
|
||||
delivery: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300',
|
||||
shipment: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
|
||||
other: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
||||
};
|
||||
|
||||
@@ -88,6 +110,10 @@ const TASK_FILTER_OPTIONS: { value: ExtendedTaskFilterType; label: string }[] =
|
||||
{ value: 'schedule', label: '일정' },
|
||||
{ value: 'order', label: '발주' },
|
||||
{ value: 'construction', label: '시공' },
|
||||
{ value: 'bill', label: '어음' },
|
||||
{ value: 'expected_expense', label: '결제예정' },
|
||||
{ value: 'delivery', label: '납기' },
|
||||
{ value: 'shipment', label: '출고' },
|
||||
{ value: 'issue', label: '이슈' },
|
||||
];
|
||||
|
||||
@@ -245,6 +271,19 @@ export function CalendarSection({
|
||||
return parts.join(' | ');
|
||||
};
|
||||
|
||||
// 일정 타입별 상세 페이지 링크 생성 (bill_123 → /ko/accounting/bills/123)
|
||||
const getScheduleLink = (schedule: CalendarScheduleItem): string | null => {
|
||||
const basePath = SCHEDULE_TYPE_ROUTES[schedule.type];
|
||||
if (!basePath) return null;
|
||||
// expected_expense는 목록 페이지만 존재 (상세 페이지 없음)
|
||||
if (schedule.type === 'expected_expense') {
|
||||
return `/ko${basePath}`;
|
||||
}
|
||||
const numericId = schedule.id.split('_').pop();
|
||||
if (!numericId) return null;
|
||||
return `/ko${basePath}/${numericId}`;
|
||||
};
|
||||
|
||||
const handleDateClick = (date: Date) => {
|
||||
setSelectedDate(date);
|
||||
};
|
||||
@@ -461,11 +500,18 @@ export function CalendarSection({
|
||||
schedule: 'bg-blue-500',
|
||||
order: 'bg-green-500',
|
||||
construction: 'bg-purple-500',
|
||||
bill: 'bg-amber-500',
|
||||
expected_expense: 'bg-rose-500',
|
||||
delivery: 'bg-cyan-500',
|
||||
shipment: 'bg-teal-500',
|
||||
issue: 'bg-red-400',
|
||||
};
|
||||
const dotColor = colorMap[evType] || 'bg-gray-400';
|
||||
const title = evData?.name as string || evData?.title as string || ev.title;
|
||||
const cleanTitle = title?.replace(/^[🔴🟠]\s*/, '') || '';
|
||||
const mobileScheduleLink = isSelected && evType !== 'holiday' && evType !== 'tax' && evType !== 'issue'
|
||||
? getScheduleLink(evData as unknown as CalendarScheduleItem)
|
||||
: null;
|
||||
return (
|
||||
<div key={ev.id} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className={`w-2 h-2 rounded-full shrink-0 ${dotColor}`} />
|
||||
@@ -474,7 +520,18 @@ export function CalendarSection({
|
||||
{SCHEDULE_TYPE_LABELS[evType] || ''}
|
||||
</span>
|
||||
)}
|
||||
<span className={isSelected ? '' : 'truncate'}>{cleanTitle}</span>
|
||||
<span className={`${isSelected ? '' : 'truncate'} flex-1`}>{cleanTitle}</span>
|
||||
{mobileScheduleLink && (
|
||||
<span
|
||||
className="flex items-center gap-0.5 text-blue-600 dark:text-blue-400 hover:underline cursor-pointer shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
router.push(mobileScheduleLink);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -569,21 +626,38 @@ export function CalendarSection({
|
||||
);
|
||||
})}
|
||||
|
||||
{selectedDateItems.schedules.map((schedule) => (
|
||||
<div
|
||||
key={schedule.id}
|
||||
className="p-3 border border-border rounded-lg hover:bg-muted/50 transition-colors cursor-pointer"
|
||||
onClick={() => onScheduleClick?.(schedule)}
|
||||
>
|
||||
<div className="flex items-start gap-2 mb-1">
|
||||
<Badge variant="secondary" className={`shrink-0 text-xs ${SCHEDULE_TYPE_BADGE_COLORS[schedule.type]}`}>
|
||||
{SCHEDULE_TYPE_LABELS[schedule.type] || '일정'}
|
||||
</Badge>
|
||||
<span className="font-medium text-base text-foreground flex-1">{schedule.title}</span>
|
||||
{selectedDateItems.schedules.map((schedule) => {
|
||||
const scheduleLink = getScheduleLink(schedule);
|
||||
return (
|
||||
<div
|
||||
key={schedule.id}
|
||||
className="p-3 border border-border rounded-lg hover:bg-muted/50 transition-colors cursor-pointer"
|
||||
onClick={() => onScheduleClick?.(schedule)}
|
||||
>
|
||||
<div className="flex items-start gap-2 mb-1">
|
||||
<Badge variant="secondary" className={`shrink-0 text-xs ${SCHEDULE_TYPE_BADGE_COLORS[schedule.type]}`}>
|
||||
{SCHEDULE_TYPE_LABELS[schedule.type] || '일정'}
|
||||
</Badge>
|
||||
<span className="font-medium text-base text-foreground flex-1">{schedule.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>{formatScheduleDetail(schedule)}</span>
|
||||
{scheduleLink && (
|
||||
<span
|
||||
className="flex items-center gap-1 text-blue-600 dark:text-blue-400 hover:underline cursor-pointer shrink-0 ml-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
router.push(scheduleLink);
|
||||
}}
|
||||
>
|
||||
상세보기
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{formatScheduleDetail(schedule)}</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{selectedDateItems.issues.map((issue) => (
|
||||
<div
|
||||
|
||||
@@ -161,7 +161,8 @@ export interface CalendarScheduleItem {
|
||||
startTime?: string; // "09:00"
|
||||
endTime?: string; // "12:00"
|
||||
isAllDay?: boolean;
|
||||
type: 'schedule' | 'order' | 'construction' | 'other'; // 일정, 발주, 시공
|
||||
type: 'schedule' | 'order' | 'construction' | 'other' | 'bill'
|
||||
| 'expected_expense' | 'delivery' | 'shipment'; // 일정, 발주, 시공, 어음, 결제예정, 납기, 출고
|
||||
department?: string; // 부서명
|
||||
personName?: string; // 담당자명
|
||||
color?: string;
|
||||
@@ -174,7 +175,8 @@ export type CalendarViewType = 'week' | 'month';
|
||||
export type CalendarDeptFilterType = 'all' | 'department' | 'personal';
|
||||
|
||||
// 캘린더 업무 필터 타입
|
||||
export type CalendarTaskFilterType = 'all' | 'schedule' | 'order' | 'construction';
|
||||
export type CalendarTaskFilterType = 'all' | 'schedule' | 'order' | 'construction' | 'bill'
|
||||
| 'expected_expense' | 'delivery' | 'shipment';
|
||||
|
||||
// ===== 매출 현황 데이터 =====
|
||||
export interface SalesMonthlyTrend {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NoticePopupModal, isPopupDismissedForToday } from './NoticePopupModal';
|
||||
import { getActivePopups } from './actions';
|
||||
import type { NoticePopupData } from './NoticePopupModal';
|
||||
import type { Popup } from '@/components/settings/PopupManagement/types';
|
||||
|
||||
/**
|
||||
* 활성 팝업을 자동으로 가져와 순차적으로 표시하는 컨테이너
|
||||
* - AuthenticatedLayout에 마운트
|
||||
* - 오늘 하루 안 보기 처리된 팝업은 건너뜀
|
||||
* - 여러 개일 경우 하나 닫으면 다음 팝업 표시
|
||||
*/
|
||||
export default function NoticePopupContainer() {
|
||||
const [popups, setPopups] = useState<NoticePopupData[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function fetchPopups() {
|
||||
try {
|
||||
// localStorage에서 사용자 부서 ID 조회 (부서별 팝업 필터링용)
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
const activePopups = await getActivePopups(user.department_id ?? undefined);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
// 날짜 범위 + 오늘 하루 안 보기 필터링
|
||||
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
const visiblePopups = activePopups
|
||||
.filter((p) => {
|
||||
// 기간 내 팝업만 (startDate~endDate)
|
||||
if (p.startDate && today < p.startDate) return false;
|
||||
if (p.endDate && today > p.endDate) return false;
|
||||
// 오늘 하루 안 보기 처리된 팝업 제외
|
||||
if (isPopupDismissedForToday(p.id)) return false;
|
||||
return true;
|
||||
})
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
content: p.content,
|
||||
}));
|
||||
|
||||
if (visiblePopups.length > 0) {
|
||||
setPopups(visiblePopups);
|
||||
setCurrentIndex(0);
|
||||
setOpen(true);
|
||||
}
|
||||
} catch {
|
||||
// 팝업 로드 실패 시 무시 (핵심 기능 아님)
|
||||
}
|
||||
}
|
||||
|
||||
fetchPopups();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const currentPopup = popups[currentIndex];
|
||||
|
||||
if (!currentPopup) return null;
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
// 다음 팝업이 있으면 표시
|
||||
const nextIndex = currentIndex + 1;
|
||||
if (nextIndex < popups.length) {
|
||||
setCurrentIndex(nextIndex);
|
||||
// 약간의 딜레이로 자연스러운 전환
|
||||
setTimeout(() => setOpen(true), 200);
|
||||
} else {
|
||||
setOpen(false);
|
||||
}
|
||||
} else {
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<NoticePopupModal
|
||||
popup={currentPopup}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
31
src/components/common/NoticePopupModal/actions.ts
Normal file
31
src/components/common/NoticePopupModal/actions.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
'use server';
|
||||
|
||||
/**
|
||||
* 공지 팝업 서버 액션
|
||||
*
|
||||
* API Endpoints:
|
||||
* - GET /api/v1/popups/active - 사용자용 활성 팝업 조회 (날짜+부서 필터 백엔드 처리)
|
||||
*/
|
||||
|
||||
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import { type PopupApiData, transformApiToFrontend } from '@/components/settings/PopupManagement/utils';
|
||||
import type { Popup } from '@/components/settings/PopupManagement/types';
|
||||
|
||||
/**
|
||||
* 활성 팝업 목록 조회 (사용자용)
|
||||
* - 백엔드 scopeActive(): status=active + 날짜 범위 내
|
||||
* - 백엔드 scopeForUser(): 전사 OR 사용자 부서
|
||||
* @param departmentId - 사용자 소속 부서 ID (부서별 팝업 필터용)
|
||||
*/
|
||||
export async function getActivePopups(departmentId?: number): Promise<Popup[]> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl('/api/v1/popups/active', {
|
||||
department_id: departmentId,
|
||||
}),
|
||||
transform: (data: PopupApiData[]) => data.map(transformApiToFrontend),
|
||||
errorMessage: '활성 팝업 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
return result.success ? (result.data ?? []) : [];
|
||||
}
|
||||
@@ -82,173 +82,6 @@ const InspectionReportModal = dynamic(
|
||||
() => import('../WorkOrders/documents').then(mod => ({ default: mod.InspectionReportModal })),
|
||||
);
|
||||
|
||||
// ===== 목업 데이터 =====
|
||||
const MOCK_ITEMS: Record<ProcessTab, WorkItemData[]> = {
|
||||
screen: [
|
||||
{
|
||||
id: 'mock-s1', itemNo: 1, itemCode: 'KWWS03', itemName: '와이어', floor: '1층', code: 'FSS-01',
|
||||
width: 8260, height: 8350, quantity: 2, processType: 'screen',
|
||||
cuttingInfo: { width: 1210, sheets: 8 },
|
||||
steps: [
|
||||
{ id: 's1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
|
||||
{ id: 's1-2', name: '절단', isMaterialInput: false, isCompleted: true },
|
||||
{ id: 's1-3', name: '미싱', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 's1-4', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
|
||||
{ id: 's1-5', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
materialInputs: [
|
||||
{ id: 'm1', lotNo: 'LOT-2026-001', itemName: '스크린 원단 A', quantity: 500, unit: 'm' },
|
||||
{ id: 'm2', lotNo: 'LOT-2026-002', itemName: '와이어 B', quantity: 120, unit: 'EA' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mock-s2', itemNo: 2, itemCode: 'KWWS05', itemName: '메쉬', floor: '2층', code: 'FSS-03',
|
||||
width: 6400, height: 5200, quantity: 4, processType: 'screen',
|
||||
cuttingInfo: { width: 1600, sheets: 4 },
|
||||
steps: [
|
||||
{ id: 's2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
|
||||
{ id: 's2-2', name: '절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 's2-3', name: '미싱', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 's2-4', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
|
||||
{ id: 's2-5', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mock-s3', itemNo: 3, itemCode: 'KWWS08', itemName: '와이어(광폭)', floor: '3층', code: 'FSS-05',
|
||||
width: 12000, height: 4500, quantity: 1, processType: 'screen',
|
||||
cuttingInfo: { width: 2400, sheets: 5 },
|
||||
steps: [
|
||||
{ id: 's3-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
|
||||
{ id: 's3-2', name: '절단', isMaterialInput: false, isCompleted: true },
|
||||
{ id: 's3-3', name: '미싱', isMaterialInput: false, isCompleted: true },
|
||||
{ id: 's3-4', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
|
||||
{ id: 's3-5', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
materialInputs: [
|
||||
{ id: 'm3', lotNo: 'LOT-2026-005', itemName: '광폭 원단', quantity: 300, unit: 'm' },
|
||||
],
|
||||
},
|
||||
],
|
||||
slat: [
|
||||
{
|
||||
id: 'mock-l1', itemNo: 1, itemCode: 'KQTS01', itemName: '슬랫코일', floor: '1층', code: 'FSS-01',
|
||||
width: 8260, height: 8350, quantity: 2, processType: 'slat',
|
||||
slatInfo: { length: 3910, slatCount: 40, jointBar: 4, glassQty: 2 },
|
||||
steps: [
|
||||
{ id: 'l1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
|
||||
{ id: 'l1-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'l1-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
|
||||
{ id: 'l1-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
materialInputs: [
|
||||
{ id: 'm4', lotNo: 'LOT-2026-010', itemName: '슬랫 코일 A', quantity: 200, unit: 'kg' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mock-l2', itemNo: 2, itemCode: 'KQTS03', itemName: '슬랫코일(광폭)', floor: '2층', code: 'FSS-02',
|
||||
width: 10500, height: 6200, quantity: 3, processType: 'slat',
|
||||
slatInfo: { length: 5200, slatCount: 55, jointBar: 6, glassQty: 3 },
|
||||
steps: [
|
||||
{ id: 'l2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
|
||||
{ id: 'l2-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'l2-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
|
||||
{ id: 'l2-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
bending: [
|
||||
{
|
||||
id: 'mock-b1', itemNo: 1, itemCode: 'KWWS03', itemName: '가이드레일', floor: '1층', code: 'FSS-01',
|
||||
width: 0, height: 0, quantity: 6, processType: 'bending',
|
||||
bendingInfo: {
|
||||
common: {
|
||||
kind: '혼합형 120X70', type: '혼합형',
|
||||
lengthQuantities: [{ length: 4000, quantity: 6 }, { length: 3000, quantity: 6 }],
|
||||
},
|
||||
detailParts: [
|
||||
{ partName: '엘바', material: 'EGI 1.6T', barcyInfo: '16 I 75' },
|
||||
{ partName: '하장바', material: 'EGI 1.6T', barcyInfo: '16|75|16|75|16(A각)' },
|
||||
],
|
||||
},
|
||||
steps: [
|
||||
{ id: 'b1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
|
||||
{ id: 'b1-2', name: '절단', isMaterialInput: false, isCompleted: true },
|
||||
{ id: 'b1-3', name: '절곡', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'b1-4', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 절곡 재공품 전용 목업 데이터 (토글로 전환)
|
||||
const MOCK_ITEMS_BENDING_WIP: WorkItemData[] = [
|
||||
{
|
||||
id: 'mock-bw1', itemNo: 1, itemCode: 'KWWS03', itemName: '케이스 - 전면부', floor: '-', code: '-',
|
||||
width: 0, height: 0, quantity: 6, processType: 'bending',
|
||||
isWip: true,
|
||||
wipInfo: { specification: 'EGI 1.55T (W576)', lengthQuantity: '4,000mm X 6개' },
|
||||
steps: [
|
||||
{ id: 'bw1-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
|
||||
{ id: 'bw1-2', name: '절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'bw1-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mock-bw2', itemNo: 2, itemCode: 'KWWS04', itemName: '케이스 - 후면부', floor: '-', code: '-',
|
||||
width: 0, height: 0, quantity: 4, processType: 'bending',
|
||||
isWip: true,
|
||||
wipInfo: { specification: 'EGI 1.55T (W576)', lengthQuantity: '3,500mm X 4개' },
|
||||
steps: [
|
||||
{ id: 'bw2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
|
||||
{ id: 'bw2-2', name: '절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'bw2-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mock-bw3', itemNo: 3, itemCode: 'KWWS05', itemName: '하단마감재', floor: '-', code: '-',
|
||||
width: 0, height: 0, quantity: 10, processType: 'bending',
|
||||
isWip: true,
|
||||
wipInfo: { specification: 'EGI 1.2T (W400)', lengthQuantity: '2,800mm X 10개' },
|
||||
steps: [
|
||||
{ id: 'bw3-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
|
||||
{ id: 'bw3-2', name: '절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'bw3-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 슬랫 조인트바 전용 목업 데이터 (토글로 전환)
|
||||
const MOCK_ITEMS_SLAT_JOINTBAR: WorkItemData[] = [
|
||||
{
|
||||
id: 'mock-jb1', itemNo: 1, itemCode: 'KQJB01', itemName: '조인트바 A', floor: '1층', code: 'FSS-01',
|
||||
width: 0, height: 0, quantity: 8, processType: 'slat',
|
||||
isJointBar: true,
|
||||
slatJointBarInfo: { specification: 'EGI 1.6T', length: 3910, quantity: 8 },
|
||||
steps: [
|
||||
{ id: 'jb1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
|
||||
{ id: 'jb1-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'jb1-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
|
||||
{ id: 'jb1-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
materialInputs: [
|
||||
{ id: 'mjb1', lotNo: 'LOT-2026-020', itemName: '조인트바 코일', quantity: 100, unit: 'kg' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mock-jb2', itemNo: 2, itemCode: 'KQJB02', itemName: '조인트바 B', floor: '2층', code: 'FSS-02',
|
||||
width: 0, height: 0, quantity: 12, processType: 'slat',
|
||||
isJointBar: true,
|
||||
slatJointBarInfo: { specification: 'EGI 1.6T', length: 5200, quantity: 12 },
|
||||
steps: [
|
||||
{ id: 'jb2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
|
||||
{ id: 'jb2-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'jb2-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
|
||||
{ id: 'jb2-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 사이드바 작업지시 목업 데이터
|
||||
interface SidebarOrder {
|
||||
id: string;
|
||||
siteName: string;
|
||||
@@ -259,28 +92,6 @@ interface SidebarOrder {
|
||||
subType?: 'slat' | 'jointbar' | 'bending' | 'wip';
|
||||
}
|
||||
|
||||
// 스크린: subType 없음 / 슬랫: slat|jointbar / 절곡: bending|wip
|
||||
const MOCK_SIDEBAR_ORDERS: Record<ProcessTab, SidebarOrder[]> = {
|
||||
screen: [
|
||||
{ id: 'order-s1', siteName: '현장명', date: '2024-09-24', quantity: 7, shutterCount: 5, priority: 'urgent' },
|
||||
{ id: 'order-s2', siteName: '현장명', date: '2024-09-24', quantity: 7, shutterCount: 5, priority: 'priority' },
|
||||
{ id: 'order-s3', siteName: '현장명', date: '2024-09-24', quantity: 7, shutterCount: 5, priority: 'normal' },
|
||||
{ id: 'order-s4', siteName: '현장명', date: '2024-09-24', quantity: 7, shutterCount: 5, priority: 'normal' },
|
||||
],
|
||||
slat: [
|
||||
{ id: 'order-l1', siteName: '현장명A', date: '2024-09-24', quantity: 7, shutterCount: 5, priority: 'urgent', subType: 'slat' },
|
||||
{ id: 'order-l2', siteName: '현장명B', date: '2024-09-24', quantity: 3, shutterCount: 2, priority: 'priority', subType: 'jointbar' },
|
||||
{ id: 'order-l3', siteName: '현장명C', date: '2024-09-24', quantity: 5, shutterCount: 4, priority: 'normal', subType: 'slat' },
|
||||
{ id: 'order-l4', siteName: '현장명D', date: '2024-09-24', quantity: 4, shutterCount: 3, priority: 'normal', subType: 'jointbar' },
|
||||
],
|
||||
bending: [
|
||||
{ id: 'order-b1', siteName: '현장명A', date: '2024-09-24', quantity: 7, shutterCount: 5, priority: 'urgent', subType: 'bending' },
|
||||
{ id: 'order-b2', siteName: '현장명B', date: '2024-09-24', quantity: 3, shutterCount: 2, priority: 'priority', subType: 'wip' },
|
||||
{ id: 'order-b3', siteName: '현장명C', date: '2024-09-24', quantity: 5, shutterCount: 4, priority: 'normal', subType: 'bending' },
|
||||
{ id: 'order-b4', siteName: '현장명D', date: '2024-09-24', quantity: 4, shutterCount: 3, priority: 'normal', subType: 'wip' },
|
||||
],
|
||||
};
|
||||
|
||||
const SUB_TYPE_TAGS: Record<string, { label: string; className: string }> = {
|
||||
slat: { label: '슬랫', className: 'bg-blue-100 text-blue-700' },
|
||||
jointbar: { label: '조인트바', className: 'bg-purple-100 text-purple-700' },
|
||||
@@ -563,7 +374,7 @@ export default function WorkerScreen() {
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
const allOrders: SidebarOrder[] = [...apiSidebarOrders, ...MOCK_SIDEBAR_ORDERS[activeProcessTabKey]];
|
||||
const allOrders: SidebarOrder[] = [...apiSidebarOrders];
|
||||
|
||||
// 현재 선택이 유효하면 자동 전환하지 않음 (데이터 새로고침 시 선택 유지)
|
||||
if (selectedSidebarOrderId && allOrders.some((o) => o.id === selectedSidebarOrderId)) {
|
||||
@@ -784,27 +595,7 @@ export default function WorkerScreen() {
|
||||
});
|
||||
}
|
||||
|
||||
// 목업 데이터 합치기 (API 데이터 뒤에 번호 이어서)
|
||||
// 절곡 탭에서 재공품 서브모드면 WIP 전용 목업 사용
|
||||
// 슬랫 탭에서 조인트바 서브모드면 조인트바 전용 목업 사용
|
||||
const baseMockItems = (activeProcessTabKey === 'bending' && bendingSubMode === 'wip')
|
||||
? MOCK_ITEMS_BENDING_WIP
|
||||
: (activeProcessTabKey === 'slat' && slatSubMode === 'jointbar')
|
||||
? MOCK_ITEMS_SLAT_JOINTBAR
|
||||
: MOCK_ITEMS[activeProcessTabKey];
|
||||
const mockItems = baseMockItems.map((item, i) => ({
|
||||
...item,
|
||||
itemNo: apiItems.length + i + 1,
|
||||
steps: item.steps.map((step) => {
|
||||
const stepKey = `${item.id}-${step.name}`;
|
||||
return {
|
||||
...step,
|
||||
isCompleted: stepCompletionMap[stepKey] ?? step.isCompleted,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
return [...apiItems, ...mockItems];
|
||||
return apiItems;
|
||||
}, [filteredWorkOrders, selectedSidebarOrderId, activeProcessTabKey, stepCompletionMap, bendingSubMode, slatSubMode, activeProcessSteps, inputMaterialsMap, stepProgressMap]);
|
||||
|
||||
// ===== 검사 범위(scope) 기반 검사 단계 활성화/비활성화 =====
|
||||
@@ -956,21 +747,7 @@ export default function WorkerScreen() {
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 목업 사이드바에서 찾기
|
||||
const mockOrder = MOCK_SIDEBAR_ORDERS[activeProcessTabKey].find((o) => o.id === selectedSidebarOrderId);
|
||||
if (mockOrder) {
|
||||
return {
|
||||
orderDate: mockOrder.date,
|
||||
salesOrderNo: 'SO-2024-0001',
|
||||
siteName: mockOrder.siteName,
|
||||
client: '-',
|
||||
salesManager: '-',
|
||||
managerPhone: '-',
|
||||
shippingDate: '-',
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 폴백: 첫 번째 작업
|
||||
// 2. 폴백: 첫 번째 작업
|
||||
const first = filteredWorkOrders[0];
|
||||
if (!first) return null;
|
||||
return {
|
||||
@@ -1427,9 +1204,6 @@ export default function WorkerScreen() {
|
||||
toast.error('검사 데이터 저장 중 오류가 발생했습니다.');
|
||||
}
|
||||
} else if (inspectionStepName) {
|
||||
// 목업 데이터는 메모리만 저장 + 로컬 완료 처리
|
||||
setStepCompletionMap((prev) => ({ ...prev, [buildStepKey(inspectionStepName)]: true }));
|
||||
toast.success('중간검사가 완료되었습니다.');
|
||||
}
|
||||
}, [selectedOrder, workItems, getInspectionProcessType, inspectionStepName]);
|
||||
|
||||
@@ -1666,27 +1440,8 @@ export default function WorkerScreen() {
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{(() => {
|
||||
const apiCount = workItems.filter((i) => !i.id.startsWith('mock-')).length;
|
||||
return apiCount > 0 ? (
|
||||
<span className="text-[10px] font-semibold text-blue-600 bg-blue-50 px-2 py-0.5 rounded inline-block">
|
||||
실제 데이터 ({apiCount}건)
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
{scopedWorkItems.map((item, index) => {
|
||||
const isFirstMock = item.id.startsWith('mock-') &&
|
||||
(index === 0 || !scopedWorkItems[index - 1]?.id.startsWith('mock-'));
|
||||
return (
|
||||
<div key={item.id}>
|
||||
{isFirstMock && (
|
||||
<div className="mb-3 pt-1 space-y-2">
|
||||
{scopedWorkItems.some((i) => !i.id.startsWith('mock-')) && <div className="border-t border-dashed border-gray-300" />}
|
||||
<span className="text-[10px] font-semibold text-orange-600 bg-orange-50 px-2 py-0.5 rounded inline-block">
|
||||
목업 데이터
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{scopedWorkItems.map((item) => (
|
||||
<div key={item.id}>
|
||||
<WorkItemCard
|
||||
item={item}
|
||||
onStepClick={handleStepClick}
|
||||
@@ -1694,9 +1449,8 @@ export default function WorkerScreen() {
|
||||
onDeleteMaterial={handleDeleteMaterial}
|
||||
onInspectionClick={handleInspectionClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1863,8 +1617,6 @@ function SidebarContent({
|
||||
onSelectOrder,
|
||||
apiOrders,
|
||||
}: SidebarContentProps) {
|
||||
const mockOrders = MOCK_SIDEBAR_ORDERS[tab];
|
||||
|
||||
const renderOrders = (orders: SidebarOrder[]) => (
|
||||
<>
|
||||
{PRIORITY_GROUPS.map((group) => {
|
||||
@@ -1914,29 +1666,10 @@ function SidebarContent({
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900">수주 목록</h3>
|
||||
|
||||
{/* API 실제 데이터 */}
|
||||
{apiOrders.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-[10px] font-semibold text-blue-600 bg-blue-50 px-2 py-0.5 rounded">
|
||||
실제 데이터 ({apiOrders.length}건)
|
||||
</span>
|
||||
{renderOrders(apiOrders)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 구분선 */}
|
||||
{apiOrders.length > 0 && mockOrders.length > 0 && (
|
||||
<div className="border-t border-dashed border-gray-300 my-1" />
|
||||
)}
|
||||
|
||||
{/* 목업 데이터 */}
|
||||
{mockOrders.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-[10px] font-semibold text-orange-600 bg-orange-50 px-2 py-0.5 rounded">
|
||||
목업 데이터
|
||||
</span>
|
||||
{renderOrders(mockOrders)}
|
||||
</div>
|
||||
{apiOrders.length > 0 ? (
|
||||
renderOrders(apiOrders)
|
||||
) : (
|
||||
<p className="text-xs text-gray-400 text-center py-4">수주 데이터가 없습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetai
|
||||
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
import type { Popup, PopupFormData } from './types';
|
||||
import { getPopupById, createPopup, updatePopup, deletePopup } from './actions';
|
||||
import { popupDetailConfig } from './popupDetailConfig';
|
||||
import { popupDetailConfig, decodeTargetValue } from './popupDetailConfig';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface PopupDetailClientV2Props {
|
||||
@@ -20,11 +20,14 @@ interface PopupDetailClientV2Props {
|
||||
initialMode?: DetailMode;
|
||||
}
|
||||
|
||||
// 현재 로그인 사용자 정보 (실제로는 auth context에서 가져옴)
|
||||
const CURRENT_USER = {
|
||||
id: 'user1',
|
||||
name: '홍길동',
|
||||
};
|
||||
// 로그인 사용자 이름을 가져오는 헬퍼
|
||||
function getLoggedInUserName(): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
try {
|
||||
const userDataStr = localStorage.getItem('user');
|
||||
return userDataStr ? JSON.parse(userDataStr).name || '' : '';
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
export function PopupDetailClientV2({ popupId, initialMode }: PopupDetailClientV2Props) {
|
||||
const router = useRouter();
|
||||
@@ -99,8 +102,10 @@ export function PopupDetailClientV2({ popupId, initialMode }: PopupDetailClientV
|
||||
const handleSubmit = useCallback(
|
||||
async (formData: Record<string, unknown>) => {
|
||||
try {
|
||||
const { targetType, departmentId } = decodeTargetValue((formData.target as string) || 'all');
|
||||
const popupFormData: PopupFormData = {
|
||||
target: (formData.target as PopupFormData['target']) || 'all',
|
||||
target: targetType,
|
||||
targetDepartmentId: departmentId ? String(departmentId) : undefined,
|
||||
title: formData.title as string,
|
||||
content: formData.content as string,
|
||||
status: (formData.status as PopupFormData['status']) || 'inactive',
|
||||
@@ -167,7 +172,7 @@ export function PopupDetailClientV2({ popupId, initialMode }: PopupDetailClientV
|
||||
? ({
|
||||
target: 'all',
|
||||
status: 'inactive',
|
||||
author: CURRENT_USER.name,
|
||||
author: getLoggedInUserName(),
|
||||
createdAt: format(new Date(), 'yyyy-MM-dd HH:mm'),
|
||||
startDate: format(new Date(), 'yyyy-MM-dd'),
|
||||
endDate: format(new Date(), 'yyyy-MM-dd'),
|
||||
|
||||
@@ -51,11 +51,14 @@ interface PopupFormProps {
|
||||
initialData?: Popup;
|
||||
}
|
||||
|
||||
// 현재 로그인 사용자 정보 (실제로는 auth context에서 가져옴)
|
||||
const CURRENT_USER = {
|
||||
id: 'user1',
|
||||
name: '홍길동',
|
||||
};
|
||||
// 로그인 사용자 이름을 가져오는 헬퍼
|
||||
function getLoggedInUserName(): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
try {
|
||||
const userDataStr = localStorage.getItem('user');
|
||||
return userDataStr ? JSON.parse(userDataStr).name || '' : '';
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
export function PopupForm({ mode, initialData }: PopupFormProps) {
|
||||
const router = useRouter();
|
||||
@@ -268,7 +271,7 @@ export function PopupForm({ mode, initialData }: PopupFormProps) {
|
||||
<div className="space-y-2">
|
||||
<Label>작성자</Label>
|
||||
<Input
|
||||
value={initialData?.author || CURRENT_USER.name}
|
||||
value={initialData?.author || getLoggedInUserName()}
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
|
||||
@@ -97,6 +97,19 @@ export async function deletePopup(id: string): Promise<ActionResult> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 목록 조회 (팝업 대상 선택용)
|
||||
*/
|
||||
export async function getDepartmentList(): Promise<{ id: number; name: string }[]> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl('/api/v1/departments'),
|
||||
transform: (data: { data: { id: number; name: string }[] }) =>
|
||||
data.data.map((d) => ({ id: d.id, name: d.name })),
|
||||
errorMessage: '부서 목록 조회에 실패했습니다.',
|
||||
});
|
||||
return result.data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 팝업 일괄 삭제
|
||||
*/
|
||||
|
||||
@@ -7,8 +7,10 @@ import { Megaphone } from 'lucide-react';
|
||||
import type { DetailConfig, FieldDefinition, SectionDefinition } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
import type { Popup, PopupFormData, PopupTarget, PopupStatus } from './types';
|
||||
import { RichTextEditor } from '@/components/board/RichTextEditor';
|
||||
import { createElement } from 'react';
|
||||
import { createElement, useState, useEffect } from 'react';
|
||||
import { sanitizeHTML } from '@/lib/sanitize';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||
import { getDepartmentList } from './actions';
|
||||
|
||||
// ===== 대상 옵션 =====
|
||||
const TARGET_OPTIONS = [
|
||||
@@ -22,18 +24,76 @@ const STATUS_OPTIONS = [
|
||||
{ value: 'active', label: '사용함' },
|
||||
];
|
||||
|
||||
/**
|
||||
* target 값 인코딩/디코딩 헬퍼
|
||||
* 'all' → target_type: all, target_id: null
|
||||
* 'department:13' → target_type: department, target_id: 13
|
||||
*/
|
||||
export function encodeTargetValue(targetType: string, departmentId?: number | null): string {
|
||||
if (targetType === 'department' && departmentId) {
|
||||
return `department:${departmentId}`;
|
||||
}
|
||||
return targetType;
|
||||
}
|
||||
|
||||
export function decodeTargetValue(value: string): { targetType: PopupTarget; departmentId: number | null } {
|
||||
if (value.startsWith('department:')) {
|
||||
const id = parseInt(value.split(':')[1]);
|
||||
return { targetType: 'department', departmentId: isNaN(id) ? null : id };
|
||||
}
|
||||
if (value === 'department') {
|
||||
return { targetType: 'department', departmentId: null };
|
||||
}
|
||||
return { targetType: 'all', departmentId: null };
|
||||
}
|
||||
|
||||
// ===== 필드 정의 =====
|
||||
export const popupFields: FieldDefinition[] = [
|
||||
{
|
||||
key: 'target',
|
||||
label: '대상',
|
||||
type: 'select',
|
||||
type: 'custom',
|
||||
required: true,
|
||||
options: TARGET_OPTIONS,
|
||||
placeholder: '대상을 선택해주세요',
|
||||
validation: [
|
||||
{ type: 'required', message: '대상을 선택해주세요.' },
|
||||
{
|
||||
type: 'custom',
|
||||
message: '대상을 선택해주세요.',
|
||||
validate: (value) => !!value && value !== '',
|
||||
},
|
||||
{
|
||||
type: 'custom',
|
||||
message: '부서를 선택해주세요.',
|
||||
validate: (value) => {
|
||||
const str = value as string;
|
||||
if (str === 'department') return false; // 부서 미선택
|
||||
return true;
|
||||
},
|
||||
},
|
||||
],
|
||||
renderField: ({ value, onChange, mode, disabled }) => {
|
||||
const strValue = (value as string) || 'all';
|
||||
const { targetType, departmentId } = decodeTargetValue(strValue);
|
||||
|
||||
if (mode === 'view') {
|
||||
// view 모드에서는 formatValue로 처리
|
||||
return null;
|
||||
}
|
||||
|
||||
// Edit/Create 모드: 대상 타입 Select + 조건부 부서 Select
|
||||
return createElement(TargetSelectorField, {
|
||||
targetType,
|
||||
departmentId,
|
||||
onChange,
|
||||
disabled: !!disabled,
|
||||
});
|
||||
},
|
||||
formatValue: (value) => {
|
||||
// view 모드에서 표시할 텍스트 — 실제 부서명은 PopupDetailClientV2에서 처리
|
||||
const strValue = (value as string) || 'all';
|
||||
if (strValue === 'all') return '전사';
|
||||
if (strValue.startsWith('department:')) return '부서별'; // 부서명은 아래서 덮어씌움
|
||||
return '부서별';
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'startDate',
|
||||
@@ -92,13 +152,11 @@ export const popupFields: FieldDefinition[] = [
|
||||
],
|
||||
renderField: ({ value, onChange, mode, disabled }) => {
|
||||
if (mode === 'view') {
|
||||
// View 모드: HTML 렌더링
|
||||
return createElement('div', {
|
||||
className: 'border border-gray-200 rounded-md p-4 bg-gray-50 min-h-[100px] prose prose-sm max-w-none',
|
||||
dangerouslySetInnerHTML: { __html: sanitizeHTML((value as string) || '') },
|
||||
});
|
||||
}
|
||||
// Edit/Create 모드: RichTextEditor
|
||||
return createElement(RichTextEditor, {
|
||||
value: (value as string) || '',
|
||||
onChange: onChange,
|
||||
@@ -172,7 +230,7 @@ export const popupDetailConfig: DetailConfig<Popup> = {
|
||||
},
|
||||
},
|
||||
transformInitialData: (data: Popup) => ({
|
||||
target: data.target || 'all',
|
||||
target: encodeTargetValue(data.target, data.targetId),
|
||||
startDate: data.startDate || '',
|
||||
endDate: data.endDate || '',
|
||||
title: data.title || '',
|
||||
@@ -181,12 +239,86 @@ export const popupDetailConfig: DetailConfig<Popup> = {
|
||||
author: data.author || '',
|
||||
createdAt: data.createdAt || '',
|
||||
}),
|
||||
transformSubmitData: (formData): Partial<PopupFormData> => ({
|
||||
target: formData.target as PopupTarget,
|
||||
title: formData.title as string,
|
||||
content: formData.content as string,
|
||||
status: formData.status as PopupStatus,
|
||||
startDate: formData.startDate as string,
|
||||
endDate: formData.endDate as string,
|
||||
}),
|
||||
transformSubmitData: (formData): Partial<PopupFormData> => {
|
||||
const { targetType, departmentId } = decodeTargetValue(formData.target as string);
|
||||
return {
|
||||
target: targetType,
|
||||
targetDepartmentId: departmentId ? String(departmentId) : undefined,
|
||||
title: formData.title as string,
|
||||
content: formData.content as string,
|
||||
status: formData.status as PopupStatus,
|
||||
startDate: formData.startDate as string,
|
||||
endDate: formData.endDate as string,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// ===== 대상 선택 필드 컴포넌트 =====
|
||||
|
||||
interface TargetSelectorFieldProps {
|
||||
targetType: string;
|
||||
departmentId: number | null;
|
||||
onChange: (value: unknown) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
function TargetSelectorField({ targetType, departmentId, onChange, disabled }: TargetSelectorFieldProps) {
|
||||
const [departments, setDepartments] = useState<{ id: number; name: string }[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (targetType === 'department' && departments.length === 0) {
|
||||
setLoading(true);
|
||||
getDepartmentList()
|
||||
.then((list: { id: number; name: string }[]) => setDepartments(list))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [targetType]);
|
||||
|
||||
const handleTypeChange = (newType: string) => {
|
||||
if (newType === 'all') {
|
||||
onChange('all');
|
||||
} else {
|
||||
onChange('department');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDepartmentChange = (deptId: string) => {
|
||||
onChange(`department:${deptId}`);
|
||||
};
|
||||
|
||||
return createElement('div', { className: 'space-y-2' },
|
||||
// 대상 타입 Select
|
||||
createElement(Select, {
|
||||
value: targetType,
|
||||
onValueChange: handleTypeChange,
|
||||
disabled,
|
||||
},
|
||||
createElement(SelectTrigger, null,
|
||||
createElement(SelectValue, { placeholder: '대상을 선택해주세요' })
|
||||
),
|
||||
createElement(SelectContent, null,
|
||||
TARGET_OPTIONS.map(opt =>
|
||||
createElement(SelectItem, { key: opt.value, value: opt.value }, opt.label)
|
||||
)
|
||||
)
|
||||
),
|
||||
// 부서별 선택 시 부서 Select 추가
|
||||
targetType === 'department' && createElement(Select, {
|
||||
value: departmentId ? String(departmentId) : undefined,
|
||||
onValueChange: handleDepartmentChange,
|
||||
disabled: disabled || loading,
|
||||
},
|
||||
createElement(SelectTrigger, null,
|
||||
createElement(SelectValue, {
|
||||
placeholder: loading ? '부서 목록 로딩 중...' : '부서를 선택해주세요',
|
||||
})
|
||||
),
|
||||
createElement(SelectContent, null,
|
||||
departments.map((dept: { id: number; name: string }) =>
|
||||
createElement(SelectItem, { key: dept.id, value: String(dept.id) }, dept.name)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export type PopupStatus = 'active' | 'inactive';
|
||||
export interface Popup {
|
||||
id: string;
|
||||
target: PopupTarget;
|
||||
targetId?: number | null; // 부서 ID (대상이 department인 경우)
|
||||
targetName?: string; // 부서명 (대상이 department인 경우)
|
||||
title: string;
|
||||
content: string;
|
||||
|
||||
@@ -48,6 +48,7 @@ export function transformApiToFrontend(apiData: PopupApiData): Popup {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
target: apiData.target_type as PopupTarget,
|
||||
targetId: apiData.target_id,
|
||||
targetName: apiData.target_type === 'department'
|
||||
? apiData.department?.name
|
||||
: undefined,
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import HeaderFavoritesBar from '@/components/layout/HeaderFavoritesBar';
|
||||
import CommandMenuSearch, { type CommandMenuSearchRef } from '@/components/layout/CommandMenuSearch';
|
||||
import NoticePopupContainer from '@/components/common/NoticePopupModal/NoticePopupContainer';
|
||||
import { useTheme, useSetTheme } from '@/stores/themeStore';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { deserializeMenuItems } from '@/lib/utils/menuTransform';
|
||||
@@ -1010,6 +1011,9 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
|
||||
{/* 메뉴 검색 Command Palette (Ctrl+K / Cmd+K) */}
|
||||
<CommandMenuSearch ref={commandMenuRef} />
|
||||
|
||||
{/* 공지 팝업 자동 표시 */}
|
||||
<NoticePopupContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1296,6 +1300,9 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
|
||||
{/* 메뉴 검색 Command Palette (Ctrl+K / Cmd+K) */}
|
||||
<CommandMenuSearch ref={commandMenuRef} />
|
||||
|
||||
{/* 공지 팝업 자동 표시 */}
|
||||
<NoticePopupContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export const AUTH_CONFIG = {
|
||||
// 명시적으로 여기에 추가된 경로만 비로그인 접근 가능
|
||||
// 기본 정책: 모든 페이지는 인증 필요
|
||||
publicRoutes: [
|
||||
// 비어있음 - 필요시 추가 (예: '/about', '/terms', '/privacy')
|
||||
'/auto-login', // MNG → SAM 자동 로그인 (토큰 기반)
|
||||
],
|
||||
|
||||
// 🔒 보호된 라우트 (참고용, 실제로는 기본 정책으로 보호됨)
|
||||
|
||||
@@ -257,7 +257,8 @@ export interface TodayIssueApiResponse {
|
||||
// ============================================
|
||||
|
||||
/** 캘린더 일정 타입 */
|
||||
export type CalendarScheduleType = 'schedule' | 'order' | 'construction' | 'other';
|
||||
export type CalendarScheduleType = 'schedule' | 'order' | 'construction' | 'other' | 'bill'
|
||||
| 'expected_expense' | 'delivery' | 'shipment';
|
||||
|
||||
/** 캘린더 일정 아이템 */
|
||||
export interface CalendarScheduleItemApiResponse {
|
||||
|
||||
@@ -98,6 +98,9 @@ export function resetZustandStores(): void {
|
||||
// itemMasterStore 초기화
|
||||
const itemMasterStore = useItemMasterStore.getState();
|
||||
itemMasterStore.reset();
|
||||
|
||||
// favoritesStore는 persist 연동이라 setFavorites([])하면 localStorage까지 비워짐
|
||||
// 로그인 시 rehydrate로 새 유저 데이터 로드하므로 여기서는 건드리지 않음
|
||||
} catch (error) {
|
||||
console.error('[Logout] Failed to reset Zustand stores:', error);
|
||||
}
|
||||
|
||||
@@ -14,13 +14,10 @@ export function getStorageKey(baseKey: string): string {
|
||||
|
||||
export function createUserStorage(baseKey: string) {
|
||||
return {
|
||||
getItem: (name: string) => {
|
||||
getItem: (_name: string) => {
|
||||
const key = getStorageKey(baseKey);
|
||||
const str = localStorage.getItem(key);
|
||||
if (!str) {
|
||||
const fallback = localStorage.getItem(name);
|
||||
return fallback ? JSON.parse(fallback) : null;
|
||||
}
|
||||
if (!str) return null;
|
||||
return JSON.parse(str);
|
||||
},
|
||||
setItem: (name: string, value: unknown) => {
|
||||
|
||||
Reference in New Issue
Block a user