diff --git a/src/app/[locale]/(protected)/quality/qms/actions.ts b/src/app/[locale]/(protected)/quality/qms/actions.ts new file mode 100644 index 00000000..0fbfb8bf --- /dev/null +++ b/src/app/[locale]/(protected)/quality/qms/actions.ts @@ -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: '기준 문서 연결 해제에 실패했습니다.', + }); +} diff --git a/src/app/[locale]/(protected)/quality/qms/components/Day1ChecklistPanel.tsx b/src/app/[locale]/(protected)/quality/qms/components/Day1ChecklistPanel.tsx index 92cb2d9f..6c4f5500 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/Day1ChecklistPanel.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/Day1ChecklistPanel.tsx @@ -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>( new Set(categories.map(c => c.id)) // 기본적으로 모두 펼침 @@ -95,7 +97,14 @@ export function Day1ChecklistPanel({
{/* 헤더 */}
-

점검표 항목

+
+

점검표 항목

+ {isMock && ( + + Mock + + )} +
{/* 검색 결과 카운트 */} {searchTerm && (
diff --git a/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentSection.tsx b/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentSection.tsx index e35f9c15..81e8b588 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentSection.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentSection.tsx @@ -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({
{/* 헤더 */}
-

기준 문서화

+
+

기준 문서화

+ {isMock && ( + + Mock + + )} +
{/* 콘텐츠 */} diff --git a/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentViewer.tsx b/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentViewer.tsx index 008d2719..871b6b72 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentViewer.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentViewer.tsx @@ -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 (
@@ -38,7 +39,14 @@ export function Day1DocumentViewer({ document }: Day1DocumentViewerProps) {
-

{document.title}

+
+

{document.title}

+ {isMock && ( + + Mock + + )} +

{document.version !== '-' && {document.version}} {document.date} diff --git a/src/app/[locale]/(protected)/quality/qms/components/DocumentList.tsx b/src/app/[locale]/(protected)/quality/qms/components/DocumentList.tsx index 604219fc..7853be0b 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/DocumentList.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/DocumentList.tsx @@ -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(null); // 문서 카테고리 클릭 핸들러 @@ -52,12 +53,19 @@ export const DocumentList = ({ documents, routeCode, onViewDocument }: DocumentL return (

-

- 관련 서류{' '} - {routeCode && ( - ({routeCode}) +
+

+ 관련 서류{' '} + {routeCode && ( + ({routeCode}) + )} +

+ {isMock && ( + + Mock + )} -

+
{!routeCode ? ( diff --git a/src/app/[locale]/(protected)/quality/qms/components/ReportList.tsx b/src/app/[locale]/(protected)/quality/qms/components/ReportList.tsx index 90c7f231..cc073165 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/ReportList.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/ReportList.tsx @@ -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 (
-

품질관리서 목록

+
+

품질관리서 목록

+ {isMock && ( + + Mock + + )} +
{reports.length}건 diff --git a/src/app/[locale]/(protected)/quality/qms/components/RouteList.tsx b/src/app/[locale]/(protected)/quality/qms/components/RouteList.tsx index a1be689d..805d4ac6 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/RouteList.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/RouteList.tsx @@ -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(null); const handleClick = (route: RouteItem) => { @@ -28,12 +29,19 @@ export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCo return (
-

- 수주루트 목록{' '} - {reportCode && ( - ({reportCode}) +
+

+ 수주루트 목록{' '} + {reportCode && ( + ({reportCode}) + )} +

+ {isMock && ( + + Mock + )} -

+
{routes.length === 0 ? ( diff --git a/src/app/[locale]/(protected)/quality/qms/hooks/useDay1Audit.ts b/src/app/[locale]/(protected)/quality/qms/hooks/useDay1Audit.ts new file mode 100644 index 00000000..58435b96 --- /dev/null +++ b/src/app/[locale]/(protected)/quality/qms/hooks/useDay1Audit.ts @@ -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(USE_MOCK ? MOCK_DAY1_CATEGORIES : []); + const [checkItems] = useState(USE_MOCK ? MOCK_DAY1_CHECK_ITEMS : []); + const [standardDocuments] = useState>(USE_MOCK ? MOCK_DAY1_STANDARD_DOCUMENTS : {}); + + // 선택 상태 + const [selectedSubItemId, setSelectedSubItemId] = useState(null); + const [selectedCategoryId, setSelectedCategoryId] = useState(null); + const [selectedStandardDocId, setSelectedStandardDocId] = useState(null); + + // 로딩 상태 + const [loadingChecklist, setLoadingChecklist] = useState(false); + const [pendingToggleIds, setPendingToggleIds] = useState>(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, + }; +} diff --git a/src/app/[locale]/(protected)/quality/qms/hooks/useDay2LotAudit.ts b/src/app/[locale]/(protected)/quality/qms/hooks/useDay2LotAudit.ts new file mode 100644 index 00000000..db8e5f4f --- /dev/null +++ b/src/app/[locale]/(protected)/quality/qms/hooks/useDay2LotAudit.ts @@ -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(USE_MOCK ? MOCK_REPORTS : []); + const [routesData, setRoutesData] = useState>(USE_MOCK ? MOCK_ROUTES_INITIAL : {}); + const [documents, setDocuments] = useState([]); + + // 선택 상태 + const [selectedReport, setSelectedReport] = useState(null); + const [selectedRoute, setSelectedRoute] = useState(null); + + // 모달 상태 + const [modalOpen, setModalOpen] = useState(false); + const [selectedDoc, setSelectedDoc] = useState(null); + const [selectedDocItem, setSelectedDocItem] = useState(null); + + // 로딩 상태 + const [loadingReports, setLoadingReports] = useState(false); + const [loadingRoutes, setLoadingRoutes] = useState(false); + const [loadingDocuments, setLoadingDocuments] = useState(false); + const [pendingConfirmIds, setPendingConfirmIds] = useState>(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, + }; +} diff --git a/src/app/[locale]/(protected)/quality/qms/page.tsx b/src/app/[locale]/(protected)/quality/qms/page.tsx index 1658882f..7596b216 100644 --- a/src/app/[locale]/(protected)/quality/qms/page.tsx +++ b/src/app/[locale]/(protected)/quality/qms/page.tsx @@ -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(DEFAULT_SETTINGS); - // 1일차 상태 - const [day1Categories, setDay1Categories] = useState(MOCK_DAY1_CATEGORIES); - const [selectedSubItemId, setSelectedSubItemId] = useState(null); - const [selectedCategoryId, setSelectedCategoryId] = useState(null); - const [selectedStandardDocId, setSelectedStandardDocId] = useState(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(null); - const [selectedRoute, setSelectedRoute] = useState(null); - - // 2일차 루트 데이터 상태 (완료 토글용) - const [routesData, setRoutesData] = useState>(MOCK_ROUTES_INITIAL); - - // 모달 상태 - const [modalOpen, setModalOpen] = useState(false); - const [selectedDoc, setSelectedDoc] = useState(null); - const [selectedDocItem, setSelectedDocItem] = useState(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 (
@@ -298,6 +153,7 @@ export default function QualityInspectionPage() { searchTerm={searchTerm} onSubItemSelect={handleSubItemSelect} onSubItemToggle={handleSubItemToggle} + isMock={day1IsMock} />
@@ -312,6 +168,7 @@ export default function QualityInspectionPage() { onDocumentSelect={setSelectedStandardDocId} onConfirmComplete={handleConfirmComplete} isCompleted={isSelectedItemCompleted} + isMock={day1IsMock} />
)} @@ -321,7 +178,7 @@ export default function QualityInspectionPage() {
- +
)}
@@ -333,6 +190,7 @@ export default function QualityInspectionPage() { reports={filteredReports} selectedId={selectedReport?.id || null} onSelect={handleReportSelect} + isMock={day2IsMock} />
@@ -343,6 +201,7 @@ export default function QualityInspectionPage() { onSelect={handleRouteSelect} onToggleItem={handleToggleItem} reportCode={selectedReport?.code || null} + isMock={day2IsMock} />
@@ -351,6 +210,7 @@ export default function QualityInspectionPage() { documents={currentDocuments} routeCode={selectedRoute?.code || null} onViewDocument={handleViewDocument} + isMock={day2IsMock} />
diff --git a/src/app/[locale]/auto-login/page.tsx b/src/app/[locale]/auto-login/page.tsx new file mode 100644 index 00000000..c185e07e --- /dev/null +++ b/src/app/[locale]/auto-login/page.tsx @@ -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 ( +
+
+
자동 로그인 실패
+

{errorMessage}

+ +
+
+ ); + } + + return ( +
+
+
+

자동 로그인 중...

+
+
+ ); +} \ No newline at end of file diff --git a/src/app/api/auth/token-login/route.ts b/src/app/api/auth/token-login/route.ts new file mode 100644 index 00000000..eaabfa73 --- /dev/null +++ b/src/app/api/auth/token-login/route.ts @@ -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 } + ); + } +} \ No newline at end of file diff --git a/src/components/accounting/BillManagement/BillManagementClient.tsx b/src/components/accounting/BillManagement/BillManagementClient.tsx index 434b4a38..58e5f827 100644 --- a/src/components/accounting/BillManagement/BillManagementClient.tsx +++ b/src/components/accounting/BillManagement/BillManagementClient.tsx @@ -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(initialBillType || 'received'); const [vendorFilter, setVendorFilter] = useState(initialVendorId || 'all'); const [statusFilter, setStatusFilter] = useState('all'); + const [targetStatus, setTargetStatus] = useState(''); const [selectedItems, setSelectedItems] = useState>(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 = useMemo( @@ -377,12 +386,30 @@ export function BillManagementClient({ icon: Plus, }, - // 헤더 액션: 저장 버튼만 (필터는 tableHeaderActions에서 통합 관리) - headerActions: () => ( - + // 선택 시 상태 변경 액션 + selectionActions: () => ( +
+ + +
), // 테이블 헤더 액션 (필터) @@ -447,7 +474,9 @@ export function BillManagementClient({ router, loadData, currentPage, - handleSave, + handleStatusChange, + statusChangeOptions, + targetStatus, renderTableRow, renderMobileCard, ] diff --git a/src/components/auth/LoginPage.tsx b/src/components/auth/LoginPage.tsx index aab81288..6aa2776a 100644 --- a/src/components/auth/LoginPage.tsx +++ b/src/components/auth/LoginPage.tsx @@ -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'); diff --git a/src/components/board/BoardForm/index.tsx b/src/components/board/BoardForm/index.tsx index 9462d06c..8d4a68ef 100644 --- a/src/components/board/BoardForm/index.tsx +++ b/src/components/board/BoardForm/index.tsx @@ -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) {
diff --git a/src/components/board/BoardManagement/BoardForm.tsx b/src/components/board/BoardManagement/BoardForm.tsx index 77d51d3f..af25f518 100644 --- a/src/components/board/BoardManagement/BoardForm.tsx +++ b/src/components/board/BoardManagement/BoardForm.tsx @@ -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(); diff --git a/src/components/business/CEODashboard/sections/CalendarSection.tsx b/src/components/business/CEODashboard/sections/CalendarSection.tsx index 662914e3..fbe61573 100644 --- a/src/components/business/CEODashboard/sections/CalendarSection.tsx +++ b/src/components/business/CEODashboard/sections/CalendarSection.tsx @@ -38,17 +38,35 @@ const SCHEDULE_TYPE_COLORS: Record = { 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 = { + 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 = { order: '생산', construction: '시공', schedule: '일정', + bill: '어음', + expected_expense: '결제예정', + delivery: '납기', + shipment: '출고', other: '기타', }; @@ -57,6 +75,10 @@ const SCHEDULE_TYPE_BADGE_COLORS: Record = { 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 (
@@ -474,7 +520,18 @@ export function CalendarSection({ {SCHEDULE_TYPE_LABELS[evType] || ''} )} - {cleanTitle} + {cleanTitle} + {mobileScheduleLink && ( + { + e.stopPropagation(); + router.push(mobileScheduleLink); + }} + > + + + )}
); })} @@ -569,21 +626,38 @@ export function CalendarSection({ ); })} - {selectedDateItems.schedules.map((schedule) => ( -
onScheduleClick?.(schedule)} - > -
- - {SCHEDULE_TYPE_LABELS[schedule.type] || '일정'} - - {schedule.title} + {selectedDateItems.schedules.map((schedule) => { + const scheduleLink = getScheduleLink(schedule); + return ( +
onScheduleClick?.(schedule)} + > +
+ + {SCHEDULE_TYPE_LABELS[schedule.type] || '일정'} + + {schedule.title} +
+
+ {formatScheduleDetail(schedule)} + {scheduleLink && ( + { + e.stopPropagation(); + router.push(scheduleLink); + }} + > + 상세보기 + + + )} +
-
{formatScheduleDetail(schedule)}
-
- ))} + ); + })} {selectedDateItems.issues.map((issue) => (
([]); + 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 ( + + ); +} diff --git a/src/components/common/NoticePopupModal/actions.ts b/src/components/common/NoticePopupModal/actions.ts new file mode 100644 index 00000000..bcb8be1b --- /dev/null +++ b/src/components/common/NoticePopupModal/actions.ts @@ -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 { + 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 ?? []) : []; +} diff --git a/src/components/production/WorkerScreen/index.tsx b/src/components/production/WorkerScreen/index.tsx index 335835af..288f679e 100644 --- a/src/components/production/WorkerScreen/index.tsx +++ b/src/components/production/WorkerScreen/index.tsx @@ -82,173 +82,6 @@ const InspectionReportModal = dynamic( () => import('../WorkOrders/documents').then(mod => ({ default: mod.InspectionReportModal })), ); -// ===== 목업 데이터 ===== -const MOCK_ITEMS: Record = { - 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 = { - 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 = { 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() { ) : (
- {(() => { - const apiCount = workItems.filter((i) => !i.id.startsWith('mock-')).length; - return apiCount > 0 ? ( - - 실제 데이터 ({apiCount}건) - - ) : null; - })()} - {scopedWorkItems.map((item, index) => { - const isFirstMock = item.id.startsWith('mock-') && - (index === 0 || !scopedWorkItems[index - 1]?.id.startsWith('mock-')); - return ( -
- {isFirstMock && ( -
- {scopedWorkItems.some((i) => !i.id.startsWith('mock-')) &&
} - - 목업 데이터 - -
- )} + {scopedWorkItems.map((item) => ( +
-
- ); - })} +
+ ))}
)}
@@ -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({

수주 목록

- {/* API 실제 데이터 */} - {apiOrders.length > 0 && ( -
- - 실제 데이터 ({apiOrders.length}건) - - {renderOrders(apiOrders)} -
- )} - - {/* 구분선 */} - {apiOrders.length > 0 && mockOrders.length > 0 && ( -
- )} - - {/* 목업 데이터 */} - {mockOrders.length > 0 && ( -
- - 목업 데이터 - - {renderOrders(mockOrders)} -
+ {apiOrders.length > 0 ? ( + renderOrders(apiOrders) + ) : ( +

수주 데이터가 없습니다.

)}
); diff --git a/src/components/settings/PopupManagement/PopupDetailClientV2.tsx b/src/components/settings/PopupManagement/PopupDetailClientV2.tsx index 0cb7851a..6b7d5f38 100644 --- a/src/components/settings/PopupManagement/PopupDetailClientV2.tsx +++ b/src/components/settings/PopupManagement/PopupDetailClientV2.tsx @@ -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) => { 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'), diff --git a/src/components/settings/PopupManagement/PopupForm.tsx b/src/components/settings/PopupManagement/PopupForm.tsx index b0854564..449efb36 100644 --- a/src/components/settings/PopupManagement/PopupForm.tsx +++ b/src/components/settings/PopupManagement/PopupForm.tsx @@ -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) {
diff --git a/src/components/settings/PopupManagement/actions.ts b/src/components/settings/PopupManagement/actions.ts index 17d15eed..ddb1c82f 100644 --- a/src/components/settings/PopupManagement/actions.ts +++ b/src/components/settings/PopupManagement/actions.ts @@ -97,6 +97,19 @@ export async function deletePopup(id: string): Promise { }); } +/** + * 부서 목록 조회 (팝업 대상 선택용) + */ +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 || []; +} + /** * 팝업 일괄 삭제 */ diff --git a/src/components/settings/PopupManagement/popupDetailConfig.ts b/src/components/settings/PopupManagement/popupDetailConfig.ts index 30dd3885..1faa1a0f 100644 --- a/src/components/settings/PopupManagement/popupDetailConfig.ts +++ b/src/components/settings/PopupManagement/popupDetailConfig.ts @@ -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 = { }, }, 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 = { author: data.author || '', createdAt: data.createdAt || '', }), - transformSubmitData: (formData): Partial => ({ - 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 => { + 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) + ) + ) + ) + ); +} diff --git a/src/components/settings/PopupManagement/types.ts b/src/components/settings/PopupManagement/types.ts index a2574a98..b66be21d 100644 --- a/src/components/settings/PopupManagement/types.ts +++ b/src/components/settings/PopupManagement/types.ts @@ -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; diff --git a/src/components/settings/PopupManagement/utils.ts b/src/components/settings/PopupManagement/utils.ts index 1c1a1ae0..52bfa784 100644 --- a/src/components/settings/PopupManagement/utils.ts +++ b/src/components/settings/PopupManagement/utils.ts @@ -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, diff --git a/src/layouts/AuthenticatedLayout.tsx b/src/layouts/AuthenticatedLayout.tsx index 983c6e18..ca84ec75 100644 --- a/src/layouts/AuthenticatedLayout.tsx +++ b/src/layouts/AuthenticatedLayout.tsx @@ -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) */} + + {/* 공지 팝업 자동 표시 */} +
); } @@ -1296,6 +1300,9 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro {/* 메뉴 검색 Command Palette (Ctrl+K / Cmd+K) */} + + {/* 공지 팝업 자동 표시 */} +
); } \ No newline at end of file diff --git a/src/lib/api/auth/auth-config.ts b/src/lib/api/auth/auth-config.ts index 6a142913..0a442041 100644 --- a/src/lib/api/auth/auth-config.ts +++ b/src/lib/api/auth/auth-config.ts @@ -14,7 +14,7 @@ export const AUTH_CONFIG = { // 명시적으로 여기에 추가된 경로만 비로그인 접근 가능 // 기본 정책: 모든 페이지는 인증 필요 publicRoutes: [ - // 비어있음 - 필요시 추가 (예: '/about', '/terms', '/privacy') + '/auto-login', // MNG → SAM 자동 로그인 (토큰 기반) ], // 🔒 보호된 라우트 (참고용, 실제로는 기본 정책으로 보호됨) diff --git a/src/lib/api/dashboard/types.ts b/src/lib/api/dashboard/types.ts index 4b5791b2..eaa66598 100644 --- a/src/lib/api/dashboard/types.ts +++ b/src/lib/api/dashboard/types.ts @@ -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 { diff --git a/src/lib/auth/logout.ts b/src/lib/auth/logout.ts index b5393017..10a1209c 100644 --- a/src/lib/auth/logout.ts +++ b/src/lib/auth/logout.ts @@ -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); } diff --git a/src/stores/utils/userStorage.ts b/src/stores/utils/userStorage.ts index ba077822..ed710782 100644 --- a/src/stores/utils/userStorage.ts +++ b/src/stores/utils/userStorage.ts @@ -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) => {