Merge pull request 'lhj' (#338) from lhj into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/338
This commit is contained in:
hjlee 2026-01-07 16:07:50 +09:00
commit 1b633e55d2
32 changed files with 5708 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,10 @@
import "@/app/globals.css";
export const metadata = {
title: "POP - 생산실적관리",
description: "생산 현장 실적 관리 시스템",
};
export default function PopLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@ -0,0 +1,7 @@
"use client";
import { PopDashboard } from "@/components/pop/dashboard";
export default function PopPage() {
return <PopDashboard />;
}

View File

@ -0,0 +1,8 @@
"use client";
import { PopApp } from "@/components/pop";
export default function PopWorkPage() {
return <PopApp />;
}

View File

@ -0,0 +1,8 @@
"use client";
import { PopApp } from "@/components/pop";
export default function PopWorkPage() {
return <PopApp />;
}

View File

@ -388,4 +388,183 @@ select {
border-spacing: 0 !important;
}
/* ===== POP (Production Operation Panel) Styles ===== */
/* POP 전용 다크 테마 변수 */
.pop-dark {
/* 배경 색상 */
--pop-bg-deepest: 8 12 21;
--pop-bg-deep: 10 15 28;
--pop-bg-primary: 13 19 35;
--pop-bg-secondary: 18 26 47;
--pop-bg-tertiary: 25 35 60;
--pop-bg-elevated: 32 45 75;
/* 네온 강조색 */
--pop-neon-cyan: 0 212 255;
--pop-neon-cyan-bright: 0 240 255;
--pop-neon-cyan-dim: 0 150 190;
--pop-neon-pink: 255 0 102;
--pop-neon-purple: 138 43 226;
/* 상태 색상 */
--pop-success: 0 255 136;
--pop-success-dim: 0 180 100;
--pop-warning: 255 170 0;
--pop-warning-dim: 200 130 0;
--pop-danger: 255 51 51;
--pop-danger-dim: 200 40 40;
/* 텍스트 색상 */
--pop-text-primary: 255 255 255;
--pop-text-secondary: 180 195 220;
--pop-text-muted: 100 120 150;
/* 테두리 색상 */
--pop-border: 40 55 85;
--pop-border-light: 55 75 110;
}
/* POP 전용 라이트 테마 변수 */
.pop-light {
--pop-bg-deepest: 245 247 250;
--pop-bg-deep: 240 243 248;
--pop-bg-primary: 250 251 253;
--pop-bg-secondary: 255 255 255;
--pop-bg-tertiary: 245 247 250;
--pop-bg-elevated: 235 238 245;
--pop-neon-cyan: 0 122 204;
--pop-neon-cyan-bright: 0 140 230;
--pop-neon-cyan-dim: 0 100 170;
--pop-neon-pink: 220 38 127;
--pop-neon-purple: 118 38 200;
--pop-success: 22 163 74;
--pop-success-dim: 21 128 61;
--pop-warning: 245 158 11;
--pop-warning-dim: 217 119 6;
--pop-danger: 220 38 38;
--pop-danger-dim: 185 28 28;
--pop-text-primary: 15 23 42;
--pop-text-secondary: 71 85 105;
--pop-text-muted: 148 163 184;
--pop-border: 226 232 240;
--pop-border-light: 203 213 225;
}
/* POP 배경 그리드 패턴 */
.pop-bg-pattern::before {
content: "";
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(90deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
repeating-linear-gradient(0deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 60%);
pointer-events: none;
z-index: 0;
}
.pop-light .pop-bg-pattern::before {
background: repeating-linear-gradient(90deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px),
repeating-linear-gradient(0deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px),
radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 122, 204, 0.05) 0%, transparent 60%);
}
/* POP 글로우 효과 */
.pop-glow-cyan {
box-shadow: 0 0 20px rgba(0, 212, 255, 0.5), 0 0 40px rgba(0, 212, 255, 0.3);
}
.pop-glow-cyan-strong {
box-shadow: 0 0 10px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.5), 0 0 50px rgba(0, 212, 255, 0.3);
}
.pop-glow-success {
box-shadow: 0 0 15px rgba(0, 255, 136, 0.5);
}
.pop-glow-warning {
box-shadow: 0 0 15px rgba(255, 170, 0, 0.5);
}
.pop-glow-danger {
box-shadow: 0 0 15px rgba(255, 51, 51, 0.5);
}
/* POP 펄스 글로우 애니메이션 */
@keyframes pop-pulse-glow {
0%,
100% {
box-shadow: 0 0 5px rgba(0, 212, 255, 0.5);
}
50% {
box-shadow: 0 0 20px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.4);
}
}
.pop-animate-pulse-glow {
animation: pop-pulse-glow 2s ease-in-out infinite;
}
/* POP 프로그레스 바 샤인 애니메이션 */
@keyframes pop-progress-shine {
0% {
opacity: 0;
transform: translateX(-20px);
}
50% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateX(20px);
}
}
.pop-progress-shine::after {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 20px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3));
animation: pop-progress-shine 1.5s ease-in-out infinite;
}
/* POP 스크롤바 스타일 */
.pop-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.pop-scrollbar::-webkit-scrollbar-track {
background: rgb(var(--pop-bg-secondary));
}
.pop-scrollbar::-webkit-scrollbar-thumb {
background: rgb(var(--pop-border-light));
border-radius: 9999px;
}
.pop-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgb(var(--pop-neon-cyan-dim));
}
/* POP 스크롤바 숨기기 */
.pop-hide-scrollbar::-webkit-scrollbar {
display: none;
}
.pop-hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* ===== End of Global Styles ===== */

View File

@ -0,0 +1,131 @@
"use client";
import React from "react";
import { X, Info } from "lucide-react";
import { WorkOrder } from "./types";
interface PopAcceptModalProps {
isOpen: boolean;
workOrder: WorkOrder | null;
quantity: number;
onQuantityChange: (qty: number) => void;
onConfirm: (quantity: number) => void;
onClose: () => void;
}
export function PopAcceptModal({
isOpen,
workOrder,
quantity,
onQuantityChange,
onConfirm,
onClose,
}: PopAcceptModalProps) {
if (!isOpen || !workOrder) return null;
const acceptedQty = workOrder.acceptedQuantity || 0;
const remainingQty = workOrder.orderQuantity - acceptedQty;
const handleAdjust = (delta: number) => {
const newQty = Math.max(1, Math.min(quantity + delta, remainingQty));
onQuantityChange(newQty);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseInt(e.target.value) || 0;
const newQty = Math.max(0, Math.min(val, remainingQty));
onQuantityChange(newQty);
};
const handleConfirm = () => {
if (quantity > 0) {
onConfirm(quantity);
}
};
return (
<div className="pop-modal-overlay active" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="pop-modal">
<div className="pop-modal-header">
<h2 className="pop-modal-title"> </h2>
<button className="pop-modal-close" onClick={onClose}>
<X size={16} />
</button>
</div>
<div className="pop-modal-body">
<div className="pop-accept-modal-content">
{/* 작업지시 정보 */}
<div className="pop-accept-work-info">
<div className="work-id">{workOrder.id}</div>
<div className="work-name">
{workOrder.itemName} ({workOrder.spec})
</div>
<div style={{ marginTop: "var(--spacing-sm)", fontSize: "var(--text-xs)", color: "rgb(var(--text-muted))" }}>
: {workOrder.orderQuantity} EA | : {acceptedQty} EA
</div>
</div>
{/* 수량 입력 */}
<div>
<label className="pop-form-label"> </label>
<div className="pop-quantity-input-wrapper">
<button className="pop-qty-btn minus" onClick={() => handleAdjust(-10)}>
-10
</button>
<button className="pop-qty-btn minus" onClick={() => handleAdjust(-1)}>
-1
</button>
<input
type="number"
className="pop-qty-input"
value={quantity}
onChange={handleInputChange}
min={1}
max={remainingQty}
/>
<button className="pop-qty-btn" onClick={() => handleAdjust(1)}>
+1
</button>
<button className="pop-qty-btn" onClick={() => handleAdjust(10)}>
+10
</button>
</div>
<div className="pop-qty-hint"> : {remainingQty} EA</div>
</div>
{/* 분할접수 안내 */}
{quantity < remainingQty && (
<div className="pop-accept-info-box">
<span className="info-icon">
<Info size={20} />
</span>
<div>
<div className="info-title"> </div>
<div className="info-desc">
{quantity}EA {remainingQty - quantity}EA가 .
</div>
</div>
</div>
)}
</div>
</div>
<div className="pop-modal-footer">
<button className="pop-btn pop-btn-outline" style={{ flex: 1 }} onClick={onClose}>
</button>
<button
className="pop-btn pop-btn-primary"
style={{ flex: 1 }}
onClick={handleConfirm}
disabled={quantity <= 0}
>
({quantity} EA)
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,462 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import "./styles.css";
import {
AppState,
ModalState,
PanelState,
StatusType,
ProductionType,
WorkOrder,
WorkStep,
Equipment,
Process,
} from "./types";
import { WORK_ORDERS, EQUIPMENTS, PROCESSES, WORK_STEP_TEMPLATES, STATUS_TEXT } from "./data";
import { PopHeader } from "./PopHeader";
import { PopStatusTabs } from "./PopStatusTabs";
import { PopWorkCard } from "./PopWorkCard";
import { PopBottomNav } from "./PopBottomNav";
import { PopEquipmentModal } from "./PopEquipmentModal";
import { PopProcessModal } from "./PopProcessModal";
import { PopAcceptModal } from "./PopAcceptModal";
import { PopSettingsModal } from "./PopSettingsModal";
import { PopProductionPanel } from "./PopProductionPanel";
export function PopApp() {
// 앱 상태
const [appState, setAppState] = useState<AppState>({
currentStatus: "waiting",
selectedEquipment: null,
selectedProcess: null,
selectedWorkOrder: null,
showMyWorkOnly: false,
currentWorkSteps: [],
currentStepIndex: 0,
currentProductionType: "work-order",
selectionMode: "single",
completionAction: "close",
acceptTargetWorkOrder: null,
acceptQuantity: 0,
theme: "dark",
});
// 모달 상태
const [modalState, setModalState] = useState<ModalState>({
equipment: false,
process: false,
accept: false,
settings: false,
});
// 패널 상태
const [panelState, setPanelState] = useState<PanelState>({
production: false,
});
// 현재 시간 (hydration 에러 방지를 위해 초기값 null)
const [currentDateTime, setCurrentDateTime] = useState<Date | null>(null);
const [isClient, setIsClient] = useState(false);
// 작업지시 목록 (상태 변경을 위해 로컬 상태로 관리)
const [workOrders, setWorkOrders] = useState<WorkOrder[]>(WORK_ORDERS);
// 클라이언트 마운트 확인 및 시계 업데이트
useEffect(() => {
setIsClient(true);
setCurrentDateTime(new Date());
const timer = setInterval(() => {
setCurrentDateTime(new Date());
}, 1000);
return () => clearInterval(timer);
}, []);
// 로컬 스토리지에서 설정 로드
useEffect(() => {
const savedSelectionMode = localStorage.getItem("selectionMode") as "single" | "multi" | null;
const savedCompletionAction = localStorage.getItem("completionAction") as "close" | "stay" | null;
const savedTheme = localStorage.getItem("popTheme") as "dark" | "light" | null;
setAppState((prev) => ({
...prev,
selectionMode: savedSelectionMode || "single",
completionAction: savedCompletionAction || "close",
theme: savedTheme || "dark",
}));
}, []);
// 상태별 카운트 계산
const getStatusCounts = useCallback(() => {
const myProcessId = appState.selectedProcess?.id;
let waitingCount = 0;
let pendingAcceptCount = 0;
let inProgressCount = 0;
let completedCount = 0;
workOrders.forEach((wo) => {
if (!wo.processFlow) return;
const myProcessIndex = myProcessId
? wo.processFlow.findIndex((step) => step.id === myProcessId)
: -1;
if (wo.status === "completed") {
completedCount++;
} else if (wo.status === "in-progress" && wo.accepted) {
inProgressCount++;
} else if (myProcessIndex >= 0) {
const currentProcessIndex = wo.currentProcessIndex || 0;
const myStep = wo.processFlow[myProcessIndex];
if (currentProcessIndex < myProcessIndex) {
waitingCount++;
} else if (currentProcessIndex === myProcessIndex && myStep.status !== "completed") {
pendingAcceptCount++;
} else if (myStep.status === "completed") {
completedCount++;
}
} else {
if (wo.status === "waiting") waitingCount++;
else if (wo.status === "in-progress") inProgressCount++;
}
});
return { waitingCount, pendingAcceptCount, inProgressCount, completedCount };
}, [workOrders, appState.selectedProcess]);
// 필터링된 작업 목록
const getFilteredWorkOrders = useCallback(() => {
const myProcessId = appState.selectedProcess?.id;
let filtered: WorkOrder[] = [];
workOrders.forEach((wo) => {
if (!wo.processFlow) return;
const myProcessIndex = myProcessId
? wo.processFlow.findIndex((step) => step.id === myProcessId)
: -1;
const currentProcessIndex = wo.currentProcessIndex || 0;
const myStep = myProcessIndex >= 0 ? wo.processFlow[myProcessIndex] : null;
switch (appState.currentStatus) {
case "waiting":
if (myProcessIndex >= 0 && currentProcessIndex < myProcessIndex) {
filtered.push(wo);
} else if (!myProcessId && wo.status === "waiting") {
filtered.push(wo);
}
break;
case "pending-accept":
if (
myProcessIndex >= 0 &&
currentProcessIndex === myProcessIndex &&
myStep &&
myStep.status !== "completed" &&
!wo.accepted
) {
filtered.push(wo);
}
break;
case "in-progress":
if (wo.accepted && wo.status === "in-progress") {
filtered.push(wo);
} else if (!myProcessId && wo.status === "in-progress") {
filtered.push(wo);
}
break;
case "completed":
if (wo.status === "completed") {
filtered.push(wo);
} else if (myStep && myStep.status === "completed") {
filtered.push(wo);
}
break;
}
});
// 내 작업만 보기 필터
if (appState.showMyWorkOnly && myProcessId) {
filtered = filtered.filter((wo) => {
const mySteps = wo.processFlow.filter((step) => step.id === myProcessId);
if (mySteps.length === 0) return false;
return !mySteps.every((step) => step.status === "completed");
});
}
return filtered;
}, [workOrders, appState.currentStatus, appState.selectedProcess, appState.showMyWorkOnly]);
// 상태 탭 변경
const handleStatusChange = (status: StatusType) => {
setAppState((prev) => ({ ...prev, currentStatus: status }));
};
// 생산 유형 변경
const handleProductionTypeChange = (type: ProductionType) => {
setAppState((prev) => ({ ...prev, currentProductionType: type }));
};
// 내 작업만 보기 토글
const handleMyWorkToggle = () => {
setAppState((prev) => ({ ...prev, showMyWorkOnly: !prev.showMyWorkOnly }));
};
// 테마 토글
const handleThemeToggle = () => {
const newTheme = appState.theme === "dark" ? "light" : "dark";
setAppState((prev) => ({ ...prev, theme: newTheme }));
localStorage.setItem("popTheme", newTheme);
};
// 모달 열기/닫기
const openModal = (type: keyof ModalState) => {
setModalState((prev) => ({ ...prev, [type]: true }));
};
const closeModal = (type: keyof ModalState) => {
setModalState((prev) => ({ ...prev, [type]: false }));
};
// 설비 선택
const handleEquipmentSelect = (equipment: Equipment) => {
setAppState((prev) => ({
...prev,
selectedEquipment: equipment,
// 공정이 1개면 자동 선택
selectedProcess:
equipment.processIds.length === 1
? PROCESSES.find((p) => p.id === equipment.processIds[0]) || null
: null,
}));
};
// 공정 선택
const handleProcessSelect = (process: Process) => {
setAppState((prev) => ({ ...prev, selectedProcess: process }));
};
// 작업 접수 모달 열기
const handleOpenAcceptModal = (workOrder: WorkOrder) => {
const acceptedQty = workOrder.acceptedQuantity || 0;
const remainingQty = workOrder.orderQuantity - acceptedQty;
setAppState((prev) => ({
...prev,
acceptTargetWorkOrder: workOrder,
acceptQuantity: remainingQty,
}));
openModal("accept");
};
// 접수 확인
const handleConfirmAccept = (quantity: number) => {
if (!appState.acceptTargetWorkOrder) return;
setWorkOrders((prev) =>
prev.map((wo) => {
if (wo.id === appState.acceptTargetWorkOrder!.id) {
const previousAccepted = wo.acceptedQuantity || 0;
const newAccepted = previousAccepted + quantity;
return {
...wo,
acceptedQuantity: newAccepted,
remainingQuantity: wo.orderQuantity - newAccepted,
accepted: true,
status: "in-progress" as const,
isPartialAccept: newAccepted < wo.orderQuantity,
};
}
return wo;
})
);
closeModal("accept");
setAppState((prev) => ({
...prev,
acceptTargetWorkOrder: null,
acceptQuantity: 0,
}));
};
// 접수 취소
const handleCancelAccept = (workOrderId: string) => {
setWorkOrders((prev) =>
prev.map((wo) => {
if (wo.id === workOrderId) {
return {
...wo,
accepted: false,
acceptedQuantity: 0,
remainingQuantity: wo.orderQuantity,
isPartialAccept: false,
status: "waiting" as const,
};
}
return wo;
})
);
};
// 생산진행 패널 열기
const handleOpenProductionPanel = (workOrder: WorkOrder) => {
const template = WORK_STEP_TEMPLATES[workOrder.process] || WORK_STEP_TEMPLATES["default"];
const workSteps: WorkStep[] = template.map((step) => ({
...step,
status: "pending" as const,
startTime: null,
endTime: null,
data: {},
}));
setAppState((prev) => ({
...prev,
selectedWorkOrder: workOrder,
currentWorkSteps: workSteps,
currentStepIndex: 0,
}));
setPanelState((prev) => ({ ...prev, production: true }));
};
// 생산진행 패널 닫기
const handleCloseProductionPanel = () => {
setPanelState((prev) => ({ ...prev, production: false }));
setAppState((prev) => ({
...prev,
selectedWorkOrder: null,
currentWorkSteps: [],
currentStepIndex: 0,
}));
};
// 설정 저장
const handleSaveSettings = (selectionMode: "single" | "multi", completionAction: "close" | "stay") => {
setAppState((prev) => ({ ...prev, selectionMode, completionAction }));
localStorage.setItem("selectionMode", selectionMode);
localStorage.setItem("completionAction", completionAction);
closeModal("settings");
};
const statusCounts = getStatusCounts();
const filteredWorkOrders = getFilteredWorkOrders();
return (
<div className={`pop-container ${appState.theme === "light" ? "light" : ""}`}>
<div className="pop-app">
{/* 헤더 */}
<PopHeader
currentDateTime={currentDateTime || new Date()}
productionType={appState.currentProductionType}
selectedEquipment={appState.selectedEquipment}
selectedProcess={appState.selectedProcess}
showMyWorkOnly={appState.showMyWorkOnly}
theme={appState.theme}
onProductionTypeChange={handleProductionTypeChange}
onEquipmentClick={() => openModal("equipment")}
onProcessClick={() => openModal("process")}
onMyWorkToggle={handleMyWorkToggle}
onSearchClick={() => {
/* 조회 */
}}
onSettingsClick={() => openModal("settings")}
onThemeToggle={handleThemeToggle}
/>
{/* 상태 탭 */}
<PopStatusTabs
currentStatus={appState.currentStatus}
counts={statusCounts}
onStatusChange={handleStatusChange}
/>
{/* 메인 콘텐츠 */}
<div className="pop-main-content">
{filteredWorkOrders.length === 0 ? (
<div className="pop-empty-state">
<div className="pop-empty-state-text"> </div>
<div className="pop-empty-state-desc">
{appState.currentStatus === "waiting" && "대기 중인 작업이 없습니다"}
{appState.currentStatus === "pending-accept" && "접수 대기 작업이 없습니다"}
{appState.currentStatus === "in-progress" && "진행 중인 작업이 없습니다"}
{appState.currentStatus === "completed" && "완료된 작업이 없습니다"}
</div>
</div>
) : (
<div className="pop-work-list">
{filteredWorkOrders.map((workOrder) => (
<PopWorkCard
key={workOrder.id}
workOrder={workOrder}
currentStatus={appState.currentStatus}
selectedProcess={appState.selectedProcess}
onAccept={() => handleOpenAcceptModal(workOrder)}
onCancelAccept={() => handleCancelAccept(workOrder.id)}
onStartProduction={() => handleOpenProductionPanel(workOrder)}
onClick={() => handleOpenProductionPanel(workOrder)}
/>
))}
</div>
)}
</div>
{/* 하단 네비게이션 */}
<PopBottomNav />
</div>
{/* 모달들 */}
<PopEquipmentModal
isOpen={modalState.equipment}
equipments={EQUIPMENTS}
selectedEquipment={appState.selectedEquipment}
onSelect={handleEquipmentSelect}
onClose={() => closeModal("equipment")}
/>
<PopProcessModal
isOpen={modalState.process}
selectedEquipment={appState.selectedEquipment}
selectedProcess={appState.selectedProcess}
processes={PROCESSES}
onSelect={handleProcessSelect}
onClose={() => closeModal("process")}
/>
<PopAcceptModal
isOpen={modalState.accept}
workOrder={appState.acceptTargetWorkOrder}
quantity={appState.acceptQuantity}
onQuantityChange={(qty) => setAppState((prev) => ({ ...prev, acceptQuantity: qty }))}
onConfirm={handleConfirmAccept}
onClose={() => closeModal("accept")}
/>
<PopSettingsModal
isOpen={modalState.settings}
selectionMode={appState.selectionMode}
completionAction={appState.completionAction}
onSave={handleSaveSettings}
onClose={() => closeModal("settings")}
/>
{/* 생산진행 패널 */}
<PopProductionPanel
isOpen={panelState.production}
workOrder={appState.selectedWorkOrder}
workSteps={appState.currentWorkSteps}
currentStepIndex={appState.currentStepIndex}
currentDateTime={currentDateTime || new Date()}
onStepChange={(index) => setAppState((prev) => ({ ...prev, currentStepIndex: index }))}
onStepsUpdate={(steps) => setAppState((prev) => ({ ...prev, currentWorkSteps: steps }))}
onClose={handleCloseProductionPanel}
/>
</div>
);
}

View File

@ -0,0 +1,30 @@
"use client";
import React from "react";
import { Clock, ClipboardList } from "lucide-react";
export function PopBottomNav() {
const handleHistoryClick = () => {
console.log("작업이력 클릭");
// TODO: 작업이력 페이지 이동 또는 모달 열기
};
const handleRegisterClick = () => {
console.log("실적등록 클릭");
// TODO: 실적등록 모달 열기
};
return (
<div className="pop-bottom-nav">
<button className="pop-nav-btn secondary" onClick={handleHistoryClick}>
<Clock size={18} />
</button>
<button className="pop-nav-btn primary" onClick={handleRegisterClick}>
<ClipboardList size={18} />
</button>
</div>
);
}

View File

@ -0,0 +1,80 @@
"use client";
import React from "react";
import { X } from "lucide-react";
import { Equipment } from "./types";
interface PopEquipmentModalProps {
isOpen: boolean;
equipments: Equipment[];
selectedEquipment: Equipment | null;
onSelect: (equipment: Equipment) => void;
onClose: () => void;
}
export function PopEquipmentModal({
isOpen,
equipments,
selectedEquipment,
onSelect,
onClose,
}: PopEquipmentModalProps) {
const [tempSelected, setTempSelected] = React.useState<Equipment | null>(selectedEquipment);
React.useEffect(() => {
setTempSelected(selectedEquipment);
}, [selectedEquipment, isOpen]);
const handleConfirm = () => {
if (tempSelected) {
onSelect(tempSelected);
onClose();
}
};
if (!isOpen) return null;
return (
<div className="pop-modal-overlay active" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="pop-modal">
<div className="pop-modal-header">
<h2 className="pop-modal-title"> </h2>
<button className="pop-modal-close" onClick={onClose}>
<X size={16} />
</button>
</div>
<div className="pop-modal-body">
<div className="pop-selection-grid">
{equipments.map((equip) => (
<div
key={equip.id}
className={`pop-selection-card ${tempSelected?.id === equip.id ? "selected" : ""}`}
onClick={() => setTempSelected(equip)}
>
<div className="pop-selection-card-check"></div>
<div className="pop-selection-card-name">{equip.name}</div>
<div className="pop-selection-card-info">{equip.processNames.join(", ")}</div>
</div>
))}
</div>
</div>
<div className="pop-modal-footer">
<button className="pop-btn pop-btn-outline" style={{ flex: 1 }} onClick={onClose}>
</button>
<button
className="pop-btn pop-btn-primary"
style={{ flex: 1 }}
onClick={handleConfirm}
disabled={!tempSelected}
>
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,123 @@
"use client";
import React, { useState, useEffect } from "react";
import { Moon, Sun } from "lucide-react";
import { Equipment, Process, ProductionType } from "./types";
interface PopHeaderProps {
currentDateTime: Date;
productionType: ProductionType;
selectedEquipment: Equipment | null;
selectedProcess: Process | null;
showMyWorkOnly: boolean;
theme: "dark" | "light";
onProductionTypeChange: (type: ProductionType) => void;
onEquipmentClick: () => void;
onProcessClick: () => void;
onMyWorkToggle: () => void;
onSearchClick: () => void;
onSettingsClick: () => void;
onThemeToggle: () => void;
}
export function PopHeader({
currentDateTime,
productionType,
selectedEquipment,
selectedProcess,
showMyWorkOnly,
theme,
onProductionTypeChange,
onEquipmentClick,
onProcessClick,
onMyWorkToggle,
onSearchClick,
onSettingsClick,
onThemeToggle,
}: PopHeaderProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
const formatTime = (date: Date) => {
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${hours}:${minutes}`;
};
return (
<div className="pop-header-container">
{/* 1행: 날짜/시간 + 테마 토글 + 작업지시/원자재 */}
<div className="pop-top-bar row-1">
<div className="pop-datetime">
<span className="pop-date">{mounted ? formatDate(currentDateTime) : "----.--.--"}</span>
<span className="pop-time">{mounted ? formatTime(currentDateTime) : "--:--"}</span>
</div>
{/* 테마 토글 버튼 */}
<button className="pop-theme-toggle-inline" onClick={onThemeToggle} title="테마 변경">
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
</button>
<div className="pop-spacer" />
<div className="pop-type-buttons">
<button
className={`pop-type-btn ${productionType === "work-order" ? "active" : ""}`}
onClick={() => onProductionTypeChange("work-order")}
>
</button>
<button
className={`pop-type-btn ${productionType === "material" ? "active" : ""}`}
onClick={() => onProductionTypeChange("material")}
>
</button>
</div>
</div>
{/* 2행: 필터 버튼들 */}
<div className="pop-top-bar row-2">
<button
className={`pop-filter-btn ${selectedEquipment ? "active" : ""}`}
onClick={onEquipmentClick}
>
<span>{selectedEquipment?.name || "설비"}</span>
</button>
<button
className={`pop-filter-btn ${selectedProcess ? "active" : ""}`}
onClick={onProcessClick}
disabled={!selectedEquipment}
>
<span>{selectedProcess?.name || "공정"}</span>
</button>
<button
className={`pop-filter-btn ${showMyWorkOnly ? "active" : ""}`}
onClick={onMyWorkToggle}
>
</button>
<div className="pop-spacer" />
<button className="pop-filter-btn primary" onClick={onSearchClick}>
</button>
<button className="pop-filter-btn" onClick={onSettingsClick}>
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,92 @@
"use client";
import React from "react";
import { X } from "lucide-react";
import { Equipment, Process } from "./types";
interface PopProcessModalProps {
isOpen: boolean;
selectedEquipment: Equipment | null;
selectedProcess: Process | null;
processes: Process[];
onSelect: (process: Process) => void;
onClose: () => void;
}
export function PopProcessModal({
isOpen,
selectedEquipment,
selectedProcess,
processes,
onSelect,
onClose,
}: PopProcessModalProps) {
const [tempSelected, setTempSelected] = React.useState<Process | null>(selectedProcess);
React.useEffect(() => {
setTempSelected(selectedProcess);
}, [selectedProcess, isOpen]);
const handleConfirm = () => {
if (tempSelected) {
onSelect(tempSelected);
onClose();
}
};
if (!isOpen || !selectedEquipment) return null;
// 선택된 설비의 공정만 필터링
const availableProcesses = selectedEquipment.processIds.map((processId, index) => {
const process = processes.find((p) => p.id === processId);
return {
id: processId,
name: selectedEquipment.processNames[index],
code: process?.code || "",
};
});
return (
<div className="pop-modal-overlay active" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="pop-modal">
<div className="pop-modal-header">
<h2 className="pop-modal-title"> </h2>
<button className="pop-modal-close" onClick={onClose}>
<X size={16} />
</button>
</div>
<div className="pop-modal-body">
<div className="pop-selection-grid">
{availableProcesses.map((process) => (
<div
key={process.id}
className={`pop-selection-card ${tempSelected?.id === process.id ? "selected" : ""}`}
onClick={() => setTempSelected(process as Process)}
>
<div className="pop-selection-card-check"></div>
<div className="pop-selection-card-name">{process.name}</div>
<div className="pop-selection-card-info">{process.code}</div>
</div>
))}
</div>
</div>
<div className="pop-modal-footer">
<button className="pop-btn pop-btn-outline" style={{ flex: 1 }} onClick={onClose}>
</button>
<button
className="pop-btn pop-btn-primary"
style={{ flex: 1 }}
onClick={handleConfirm}
disabled={!tempSelected}
>
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,346 @@
"use client";
import React from "react";
import { X, Play, Square, ChevronRight } from "lucide-react";
import { WorkOrder, WorkStep } from "./types";
interface PopProductionPanelProps {
isOpen: boolean;
workOrder: WorkOrder | null;
workSteps: WorkStep[];
currentStepIndex: number;
currentDateTime: Date;
onStepChange: (index: number) => void;
onStepsUpdate: (steps: WorkStep[]) => void;
onClose: () => void;
}
export function PopProductionPanel({
isOpen,
workOrder,
workSteps,
currentStepIndex,
currentDateTime,
onStepChange,
onStepsUpdate,
onClose,
}: PopProductionPanelProps) {
if (!isOpen || !workOrder) return null;
const currentStep = workSteps[currentStepIndex];
const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
const formatTime = (date: Date | null) => {
if (!date) return "--:--";
const d = new Date(date);
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
};
const handleStartStep = () => {
const newSteps = [...workSteps];
newSteps[currentStepIndex] = {
...newSteps[currentStepIndex],
status: "in-progress",
startTime: new Date(),
};
onStepsUpdate(newSteps);
};
const handleEndStep = () => {
const newSteps = [...workSteps];
newSteps[currentStepIndex] = {
...newSteps[currentStepIndex],
endTime: new Date(),
};
onStepsUpdate(newSteps);
};
const handleSaveAndNext = () => {
const newSteps = [...workSteps];
const step = newSteps[currentStepIndex];
// 시간 자동 설정
if (!step.startTime) step.startTime = new Date();
if (!step.endTime) step.endTime = new Date();
step.status = "completed";
onStepsUpdate(newSteps);
// 다음 단계로 이동
if (currentStepIndex < workSteps.length - 1) {
onStepChange(currentStepIndex + 1);
}
};
const renderStepForm = () => {
if (!currentStep) return null;
const isCompleted = currentStep.status === "completed";
if (currentStep.type === "work" || currentStep.type === "record") {
return (
<div className="pop-step-form-section">
<h4 className="pop-step-form-title"> </h4>
<div className="pop-form-row">
<div className="pop-form-group">
<label className="pop-form-label"></label>
<input
type="number"
className="pop-input"
placeholder="0"
disabled={isCompleted}
/>
</div>
<div className="pop-form-group">
<label className="pop-form-label"></label>
<input
type="number"
className="pop-input"
placeholder="0"
disabled={isCompleted}
/>
</div>
</div>
<div className="pop-form-group">
<label className="pop-form-label"></label>
<textarea
className="pop-input"
rows={2}
placeholder="특이사항을 입력하세요"
disabled={isCompleted}
/>
</div>
</div>
);
}
if (currentStep.type === "equipment-check" || currentStep.type === "inspection") {
return (
<div className="pop-step-form-section">
<h4 className="pop-step-form-title"> </h4>
<div style={{ display: "flex", flexDirection: "column", gap: "var(--spacing-sm)" }}>
<label style={{ display: "flex", alignItems: "center", gap: "var(--spacing-sm)" }}>
<input type="checkbox" disabled={isCompleted} />
<span> </span>
</label>
<label style={{ display: "flex", alignItems: "center", gap: "var(--spacing-sm)" }}>
<input type="checkbox" disabled={isCompleted} />
<span> </span>
</label>
<label style={{ display: "flex", alignItems: "center", gap: "var(--spacing-sm)" }}>
<input type="checkbox" disabled={isCompleted} />
<span> </span>
</label>
</div>
<div className="pop-form-group" style={{ marginTop: "var(--spacing-md)" }}>
<label className="pop-form-label"></label>
<textarea
className="pop-input"
rows={2}
placeholder="점검 결과를 입력하세요"
disabled={isCompleted}
/>
</div>
</div>
);
}
return (
<div className="pop-step-form-section">
<h4 className="pop-step-form-title"> </h4>
<div className="pop-form-group">
<textarea
className="pop-input"
rows={3}
placeholder="메모를 입력하세요"
disabled={isCompleted}
/>
</div>
</div>
);
};
return (
<div className="pop-slide-panel active">
<div className="pop-slide-panel-overlay" onClick={onClose} />
<div className="pop-slide-panel-content">
{/* 헤더 */}
<div className="pop-slide-panel-header">
<div style={{ display: "flex", alignItems: "center", gap: "var(--spacing-md)" }}>
<h2 className="pop-slide-panel-title"></h2>
<span className="pop-badge pop-badge-primary">{workOrder.processName}</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "var(--spacing-md)" }}>
<div style={{ fontSize: "var(--text-xs)", color: "rgb(var(--text-muted))" }}>
<span>{formatDate(currentDateTime)}</span>
<span style={{ marginLeft: "var(--spacing-sm)", color: "rgb(var(--neon-cyan))", fontWeight: 700 }}>
{formatTime(currentDateTime)}
</span>
</div>
<button className="pop-icon-btn" onClick={onClose}>
<X size={16} />
</button>
</div>
</div>
{/* 작업지시 정보 */}
<div className="pop-work-order-info-section">
<div className="pop-work-order-info-card">
<div className="pop-work-order-info-item">
<span className="label"></span>
<span className="value primary">{workOrder.id}</span>
</div>
<div className="pop-work-order-info-item">
<span className="label"></span>
<span className="value">{workOrder.itemName}</span>
</div>
<div className="pop-work-order-info-item">
<span className="label"></span>
<span className="value">{workOrder.spec}</span>
</div>
<div className="pop-work-order-info-item">
<span className="label"></span>
<span className="value">{workOrder.orderQuantity} EA</span>
</div>
<div className="pop-work-order-info-item">
<span className="label"></span>
<span className="value">{workOrder.producedQuantity} EA</span>
</div>
<div className="pop-work-order-info-item">
<span className="label"></span>
<span className="value">{workOrder.dueDate}</span>
</div>
</div>
</div>
{/* 바디 */}
<div className="pop-slide-panel-body">
<div className="pop-panel-body-content">
{/* 작업순서 사이드바 */}
<div className="pop-work-steps-sidebar">
<div className="pop-work-steps-header"></div>
<div className="pop-work-steps-list">
{workSteps.map((step, index) => (
<div
key={step.id}
className={`pop-work-step-item ${index === currentStepIndex ? "active" : ""} ${step.status}`}
onClick={() => onStepChange(index)}
>
<div className="pop-work-step-number">{index + 1}</div>
<div className="pop-work-step-info">
<div className="pop-work-step-name">{step.name}</div>
<div className="pop-work-step-time">
{formatTime(step.startTime)} ~ {formatTime(step.endTime)}
</div>
</div>
<span className={`pop-work-step-status ${step.status}`}>
{step.status === "completed" ? "완료" : step.status === "in-progress" ? "진행중" : "대기"}
</span>
</div>
))}
</div>
</div>
{/* 작업 콘텐츠 영역 */}
<div className="pop-work-content-area">
{currentStep && (
<>
{/* 스텝 헤더 */}
<div className="pop-step-header">
<h3 className="pop-step-title">{currentStep.name}</h3>
<p className="pop-step-description">{currentStep.description}</p>
</div>
{/* 시간 컨트롤 */}
{currentStep.status !== "completed" && (
<div className="pop-step-time-controls">
<button
className="pop-time-control-btn start"
onClick={handleStartStep}
disabled={!!currentStep.startTime}
>
<Play size={16} />
{currentStep.startTime ? formatTime(currentStep.startTime) : ""}
</button>
<button
className="pop-time-control-btn end"
onClick={handleEndStep}
disabled={!currentStep.startTime || !!currentStep.endTime}
>
<Square size={16} />
{currentStep.endTime ? formatTime(currentStep.endTime) : ""}
</button>
</div>
)}
{/* 폼 */}
{renderStepForm()}
{/* 액션 버튼 */}
{currentStep.status !== "completed" && (
<div style={{ marginTop: "auto", display: "flex", gap: "var(--spacing-md)" }}>
<button
className="pop-btn pop-btn-outline"
style={{ flex: 1 }}
onClick={() => onStepChange(Math.max(0, currentStepIndex - 1))}
disabled={currentStepIndex === 0}
>
</button>
<button
className="pop-btn pop-btn-primary"
style={{ flex: 1 }}
onClick={handleSaveAndNext}
>
{currentStepIndex === workSteps.length - 1 ? "완료" : "저장 후 다음"}
<ChevronRight size={16} />
</button>
</div>
)}
{/* 완료 메시지 */}
{currentStep.status === "completed" && (
<div
style={{
padding: "var(--spacing-md)",
background: "rgba(0, 255, 136, 0.1)",
border: "1px solid rgba(0, 255, 136, 0.3)",
borderRadius: "var(--radius-md)",
display: "flex",
alignItems: "center",
gap: "var(--spacing-sm)",
color: "rgb(var(--success))",
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12" />
</svg>
<span style={{ fontWeight: 600 }}> </span>
</div>
)}
</>
)}
</div>
</div>
</div>
{/* 푸터 */}
<div className="pop-slide-panel-footer">
<button className="pop-btn pop-btn-outline" style={{ flex: 1 }} onClick={onClose}>
</button>
<button className="pop-btn pop-btn-primary" style={{ flex: 1 }}>
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,135 @@
"use client";
import React, { useState, useEffect } from "react";
import { X } from "lucide-react";
interface PopSettingsModalProps {
isOpen: boolean;
selectionMode: "single" | "multi";
completionAction: "close" | "stay";
onSave: (selectionMode: "single" | "multi", completionAction: "close" | "stay") => void;
onClose: () => void;
}
export function PopSettingsModal({
isOpen,
selectionMode,
completionAction,
onSave,
onClose,
}: PopSettingsModalProps) {
const [tempSelectionMode, setTempSelectionMode] = useState(selectionMode);
const [tempCompletionAction, setTempCompletionAction] = useState(completionAction);
useEffect(() => {
setTempSelectionMode(selectionMode);
setTempCompletionAction(completionAction);
}, [selectionMode, completionAction, isOpen]);
const handleSave = () => {
onSave(tempSelectionMode, tempCompletionAction);
};
if (!isOpen) return null;
return (
<div className="pop-modal-overlay active" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="pop-modal">
<div className="pop-modal-header">
<h2 className="pop-modal-title"></h2>
<button className="pop-modal-close" onClick={onClose}>
<X size={16} />
</button>
</div>
<div className="pop-modal-body">
{/* 선택 모드 */}
<div className="pop-settings-section">
<h3 className="pop-settings-title">/ </h3>
<div className="pop-mode-options">
<label className="pop-mode-option">
<input
type="radio"
name="selectionMode"
value="single"
checked={tempSelectionMode === "single"}
onChange={() => setTempSelectionMode("single")}
/>
<div className="pop-mode-info">
<div className="pop-mode-name"> </div>
<div className="pop-mode-desc">
.
</div>
</div>
</label>
<label className="pop-mode-option">
<input
type="radio"
name="selectionMode"
value="multi"
checked={tempSelectionMode === "multi"}
onChange={() => setTempSelectionMode("multi")}
/>
<div className="pop-mode-info">
<div className="pop-mode-name"> </div>
<div className="pop-mode-desc">
/ .
</div>
</div>
</label>
</div>
</div>
<div className="pop-settings-divider" />
{/* 완료 후 동작 */}
<div className="pop-settings-section">
<h3 className="pop-settings-title"> </h3>
<div className="pop-mode-options">
<label className="pop-mode-option">
<input
type="radio"
name="completionAction"
value="close"
checked={tempCompletionAction === "close"}
onChange={() => setTempCompletionAction("close")}
/>
<div className="pop-mode-info">
<div className="pop-mode-name"> </div>
<div className="pop-mode-desc">
.
</div>
</div>
</label>
<label className="pop-mode-option">
<input
type="radio"
name="completionAction"
value="stay"
checked={tempCompletionAction === "stay"}
onChange={() => setTempCompletionAction("stay")}
/>
<div className="pop-mode-info">
<div className="pop-mode-name"> </div>
<div className="pop-mode-desc">
.
</div>
</div>
</label>
</div>
</div>
</div>
<div className="pop-modal-footer">
<button className="pop-btn pop-btn-outline" style={{ flex: 1 }} onClick={onClose}>
</button>
<button className="pop-btn pop-btn-primary" style={{ flex: 1 }} onClick={handleSave}>
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,48 @@
"use client";
import React from "react";
import { StatusType } from "./types";
interface StatusCounts {
waitingCount: number;
pendingAcceptCount: number;
inProgressCount: number;
completedCount: number;
}
interface PopStatusTabsProps {
currentStatus: StatusType;
counts: StatusCounts;
onStatusChange: (status: StatusType) => void;
}
const STATUS_CONFIG: {
id: StatusType;
label: string;
detail: string;
countKey: keyof StatusCounts;
}[] = [
{ id: "waiting", label: "대기", detail: "내 공정 이전", countKey: "waitingCount" },
{ id: "pending-accept", label: "접수대기", detail: "내 차례", countKey: "pendingAcceptCount" },
{ id: "in-progress", label: "진행", detail: "작업중", countKey: "inProgressCount" },
{ id: "completed", label: "완료", detail: "처리완료", countKey: "completedCount" },
];
export function PopStatusTabs({ currentStatus, counts, onStatusChange }: PopStatusTabsProps) {
return (
<div className="pop-status-tabs">
{STATUS_CONFIG.map((status) => (
<div
key={status.id}
className={`pop-status-tab ${currentStatus === status.id ? "active" : ""}`}
onClick={() => onStatusChange(status.id)}
>
<span className="pop-status-tab-label">{status.label}</span>
<span className="pop-status-tab-count">{counts[status.countKey]}</span>
<span className="pop-status-tab-detail">{status.detail}</span>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,274 @@
"use client";
import React, { useRef, useEffect, useState } from "react";
import { WorkOrder, Process, StatusType } from "./types";
import { STATUS_TEXT } from "./data";
interface PopWorkCardProps {
workOrder: WorkOrder;
currentStatus: StatusType;
selectedProcess: Process | null;
onAccept: () => void;
onCancelAccept: () => void;
onStartProduction: () => void;
onClick: () => void;
}
export function PopWorkCard({
workOrder,
currentStatus,
selectedProcess,
onAccept,
onCancelAccept,
onStartProduction,
onClick,
}: PopWorkCardProps) {
const chipsRef = useRef<HTMLDivElement>(null);
const [showLeftBtn, setShowLeftBtn] = useState(false);
const [showRightBtn, setShowRightBtn] = useState(false);
const progress = ((workOrder.producedQuantity / workOrder.orderQuantity) * 100).toFixed(1);
const isReturnWork = workOrder.isReturn === true;
// 공정 스크롤 버튼 표시 여부 확인
const checkScrollButtons = () => {
const container = chipsRef.current;
if (!container) return;
const isScrollable = container.scrollWidth > container.clientWidth;
if (isScrollable) {
const scrollLeft = container.scrollLeft;
const maxScroll = container.scrollWidth - container.clientWidth;
setShowLeftBtn(scrollLeft > 5);
setShowRightBtn(scrollLeft < maxScroll - 5);
} else {
setShowLeftBtn(false);
setShowRightBtn(false);
}
};
// 현재 공정으로 스크롤
const scrollToCurrentProcess = () => {
const container = chipsRef.current;
if (!container || !workOrder.processFlow) return;
let targetIndex = -1;
// 내 공정 우선
if (selectedProcess) {
targetIndex = workOrder.processFlow.findIndex(
(step) =>
step.id === selectedProcess.id &&
(step.status === "current" || step.status === "pending")
);
}
// 없으면 현재 진행 중인 공정
if (targetIndex === -1) {
targetIndex = workOrder.processFlow.findIndex((step) => step.status === "current");
}
if (targetIndex === -1) return;
const chips = container.querySelectorAll(".pop-process-chip");
if (chips.length > targetIndex) {
const targetChip = chips[targetIndex] as HTMLElement;
const scrollPos =
targetChip.offsetLeft - container.clientWidth / 2 + targetChip.offsetWidth / 2;
container.scrollLeft = Math.max(0, scrollPos);
}
};
useEffect(() => {
scrollToCurrentProcess();
checkScrollButtons();
const container = chipsRef.current;
if (container) {
container.addEventListener("scroll", checkScrollButtons);
return () => container.removeEventListener("scroll", checkScrollButtons);
}
}, [workOrder, selectedProcess]);
const handleScroll = (direction: "left" | "right", e: React.MouseEvent) => {
e.stopPropagation();
const container = chipsRef.current;
if (!container) return;
const scrollAmount = 150;
container.scrollLeft += direction === "left" ? -scrollAmount : scrollAmount;
setTimeout(checkScrollButtons, 100);
};
// 상태 텍스트 결정
const statusText =
isReturnWork && currentStatus === "pending-accept" ? "리턴" : STATUS_TEXT[workOrder.status];
const statusClass = isReturnWork ? "return" : workOrder.status;
// 완료된 공정 수
const completedCount = workOrder.processFlow.filter((s) => s.status === "completed").length;
const totalCount = workOrder.processFlow.length;
return (
<div
className={`pop-work-card ${isReturnWork ? "return-card" : ""}`}
onClick={onClick}
>
{/* 헤더 */}
<div className="pop-work-card-header">
<div style={{ display: "flex", alignItems: "center", gap: "var(--spacing-sm)", flex: 1, flexWrap: "wrap" }}>
<span className="pop-work-number">{workOrder.id}</span>
{isReturnWork && <span className="pop-return-badge"></span>}
{workOrder.acceptedQuantity && workOrder.acceptedQuantity > 0 && workOrder.acceptedQuantity < workOrder.orderQuantity && (
<span className="pop-partial-badge">
{workOrder.acceptedQuantity}/{workOrder.orderQuantity}
</span>
)}
</div>
<span className={`pop-work-status ${statusClass}`}>{statusText}</span>
{/* 액션 버튼 */}
{currentStatus === "pending-accept" && (
<div className="pop-work-card-actions">
<button
className="pop-btn pop-btn-sm pop-btn-primary"
onClick={(e) => {
e.stopPropagation();
onAccept();
}}
>
</button>
</div>
)}
{currentStatus === "in-progress" && (
<div className="pop-work-card-actions">
<button
className="pop-btn pop-btn-sm pop-btn-ghost"
onClick={(e) => {
e.stopPropagation();
onCancelAccept();
}}
>
</button>
<button
className="pop-btn pop-btn-sm pop-btn-success"
onClick={(e) => {
e.stopPropagation();
onStartProduction();
}}
>
</button>
</div>
)}
</div>
{/* 리턴 정보 배너 */}
{isReturnWork && currentStatus === "pending-accept" && (
<div className="pop-return-banner">
<span className="pop-return-banner-icon">🔄</span>
<div>
<div className="pop-return-banner-title">
{workOrder.returnFromProcessName}
</div>
<div className="pop-return-banner-reason">{workOrder.returnReason || "사유 없음"}</div>
</div>
</div>
)}
{/* 바디 */}
<div className="pop-work-card-body">
<div className="pop-work-info-line">
<div className="pop-work-info-item">
<span className="pop-work-info-label"></span>
<span className="pop-work-info-value">{workOrder.itemName}</span>
</div>
<div className="pop-work-info-item">
<span className="pop-work-info-label"></span>
<span className="pop-work-info-value">{workOrder.spec}</span>
</div>
<div className="pop-work-info-item">
<span className="pop-work-info-label"></span>
<span className="pop-work-info-value">{workOrder.orderQuantity}</span>
</div>
<div className="pop-work-info-item">
<span className="pop-work-info-label"></span>
<span className="pop-work-info-value">{workOrder.dueDate}</span>
</div>
</div>
</div>
{/* 공정 타임라인 */}
<div className="pop-process-timeline">
<div className="pop-process-bar">
<div className="pop-process-bar-header">
<span className="pop-process-bar-label"> </span>
<span className="pop-process-bar-count">
<span>{completedCount}</span>/{totalCount}
</span>
</div>
<div className="pop-process-segments">
{workOrder.processFlow.map((step, index) => {
let segmentClass = "";
if (step.status === "completed") segmentClass = "done";
else if (step.status === "current") segmentClass = "current";
if (selectedProcess && step.id === selectedProcess.id) {
segmentClass += " my-work";
}
return <div key={index} className={`pop-process-segment ${segmentClass}`} />;
})}
</div>
</div>
<div className="pop-process-chips-container">
<button
className={`pop-process-scroll-btn left ${!showLeftBtn ? "hidden" : ""}`}
onClick={(e) => handleScroll("left", e)}
>
</button>
<div className="pop-process-chips" ref={chipsRef}>
{workOrder.processFlow.map((step, index) => {
let chipClass = "";
if (step.status === "completed") chipClass = "done";
else if (step.status === "current") chipClass = "current";
if (selectedProcess && step.id === selectedProcess.id) {
chipClass += " my-work";
}
return (
<div key={index} className={`pop-process-chip ${chipClass}`}>
<span className="pop-chip-num">{index + 1}</span>
{step.name}
</div>
);
})}
</div>
<button
className={`pop-process-scroll-btn right ${!showRightBtn ? "hidden" : ""}`}
onClick={(e) => handleScroll("right", e)}
>
</button>
</div>
</div>
{/* 진행률 바 */}
{workOrder.status !== "completed" && (
<div className="pop-work-progress">
<div className="pop-progress-info">
<span className="pop-progress-text">
{workOrder.producedQuantity} / {workOrder.orderQuantity} EA
</span>
<span className="pop-progress-percent">{progress}%</span>
</div>
<div className="pop-progress-bar">
<div className="pop-progress-fill" style={{ width: `${progress}%` }} />
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,35 @@
"use client";
import React from "react";
import { ActivityItem } from "./types";
interface ActivityListProps {
items: ActivityItem[];
onMoreClick: () => void;
}
export function ActivityList({ items, onMoreClick }: ActivityListProps) {
return (
<div className="pop-dashboard-card">
<div className="pop-dashboard-card-header">
<h3 className="pop-dashboard-card-title"> </h3>
<button className="pop-dashboard-btn-more" onClick={onMoreClick}>
</button>
</div>
<div className="pop-dashboard-activity-list">
{items.map((item) => (
<div key={item.id} className="pop-dashboard-activity-item">
<span className="pop-dashboard-activity-time">{item.time}</span>
<span className={`pop-dashboard-activity-dot ${item.category}`} />
<div className="pop-dashboard-activity-content">
<div className="pop-dashboard-activity-title">{item.title}</div>
<div className="pop-dashboard-activity-desc">{item.description}</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,24 @@
"use client";
import React from "react";
interface DashboardFooterProps {
companyName: string;
version: string;
emergencyContact: string;
}
export function DashboardFooter({
companyName,
version,
emergencyContact,
}: DashboardFooterProps) {
return (
<footer className="pop-dashboard-footer">
<span>&copy; 2024 {companyName}</span>
<span>Version {version}</span>
<span>: {emergencyContact}</span>
</footer>
);
}

View File

@ -0,0 +1,96 @@
"use client";
import React, { useState, useEffect } from "react";
import { Moon, Sun } from "lucide-react";
import { WeatherInfo, UserInfo, CompanyInfo } from "./types";
interface DashboardHeaderProps {
theme: "dark" | "light";
weather: WeatherInfo;
user: UserInfo;
company: CompanyInfo;
onThemeToggle: () => void;
onUserClick: () => void;
}
export function DashboardHeader({
theme,
weather,
user,
company,
onThemeToggle,
onUserClick,
}: DashboardHeaderProps) {
const [mounted, setMounted] = useState(false);
const [currentTime, setCurrentTime] = useState(new Date());
useEffect(() => {
setMounted(true);
const timer = setInterval(() => {
setCurrentTime(new Date());
}, 1000);
return () => clearInterval(timer);
}, []);
const formatTime = (date: Date) => {
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${hours}:${minutes}:${seconds}`;
};
const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
return (
<header className="pop-dashboard-header">
<div className="pop-dashboard-header-left">
<div className="pop-dashboard-time-display">
<div className="pop-dashboard-time-main">
{mounted ? formatTime(currentTime) : "--:--:--"}
</div>
<div className="pop-dashboard-time-date">
{mounted ? formatDate(currentTime) : "----.--.--"}
</div>
</div>
</div>
<div className="pop-dashboard-header-right">
{/* 테마 토글 */}
<button
className="pop-dashboard-theme-toggle"
onClick={onThemeToggle}
title="테마 변경"
>
{theme === "dark" ? <Moon size={16} /> : <Sun size={16} />}
</button>
{/* 날씨 정보 */}
<div className="pop-dashboard-weather">
<span className="pop-dashboard-weather-temp">{weather.temp}</span>
<span className="pop-dashboard-weather-desc">{weather.description}</span>
</div>
{/* 회사 정보 */}
<div className="pop-dashboard-company">
<div className="pop-dashboard-company-name">{company.name}</div>
<div className="pop-dashboard-company-sub">{company.subTitle}</div>
</div>
{/* 사용자 배지 */}
<button className="pop-dashboard-user-badge" onClick={onUserClick}>
<div className="pop-dashboard-user-avatar">{user.avatar}</div>
<div className="pop-dashboard-user-text">
<div className="pop-dashboard-user-name">{user.name}</div>
<div className="pop-dashboard-user-role">{user.role}</div>
</div>
</button>
</div>
</header>
);
}

View File

@ -0,0 +1,60 @@
"use client";
import React from "react";
import { KpiItem } from "./types";
interface KpiBarProps {
items: KpiItem[];
}
export function KpiBar({ items }: KpiBarProps) {
const getStrokeDashoffset = (percentage: number) => {
const circumference = 264; // 2 * PI * 42
return circumference - (circumference * percentage) / 100;
};
const formatValue = (value: number) => {
if (value >= 1000) {
return value.toLocaleString();
}
return value.toString();
};
return (
<div className="pop-dashboard-kpi-bar">
{items.map((item) => (
<div key={item.id} className="pop-dashboard-kpi-item">
<div className="pop-dashboard-kpi-gauge">
<svg viewBox="0 0 100 100" width="52" height="52">
<circle
className="pop-dashboard-kpi-gauge-bg"
cx="50"
cy="50"
r="42"
/>
<circle
className={`pop-dashboard-kpi-gauge-fill kpi-color-${item.color}`}
cx="50"
cy="50"
r="42"
strokeDasharray="264"
strokeDashoffset={getStrokeDashoffset(item.percentage)}
/>
</svg>
<span className={`pop-dashboard-kpi-gauge-text kpi-color-${item.color}`}>
{item.percentage}%
</span>
</div>
<div className="pop-dashboard-kpi-info">
<div className="pop-dashboard-kpi-label">{item.label}</div>
<div className={`pop-dashboard-kpi-value kpi-color-${item.color}`}>
{formatValue(item.value)}
<span className="pop-dashboard-kpi-unit">{item.unit}</span>
</div>
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,43 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { MenuItem } from "./types";
interface MenuGridProps {
items: MenuItem[];
}
export function MenuGrid({ items }: MenuGridProps) {
const router = useRouter();
const handleClick = (item: MenuItem) => {
if (item.href === "#") {
alert(`${item.title} 화면은 준비 중입니다.`);
} else {
router.push(item.href);
}
};
return (
<div className="pop-dashboard-menu-grid">
{items.map((item) => (
<div
key={item.id}
className={`pop-dashboard-menu-card ${item.category}`}
onClick={() => handleClick(item)}
>
<div className="pop-dashboard-menu-header">
<div className="pop-dashboard-menu-title">{item.title}</div>
<div className={`pop-dashboard-menu-count ${item.category}`}>
{item.count}
</div>
</div>
<div className="pop-dashboard-menu-desc">{item.description}</div>
<div className="pop-dashboard-menu-status">{item.status}</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,22 @@
"use client";
import React from "react";
interface NoticeBannerProps {
text: string;
}
export function NoticeBanner({ text }: NoticeBannerProps) {
return (
<div className="pop-dashboard-notice-banner">
<div className="pop-dashboard-notice-label"></div>
<div className="pop-dashboard-notice-content">
<div className="pop-dashboard-notice-marquee">
<span className="pop-dashboard-notice-text">{text}</span>
<span className="pop-dashboard-notice-text">{text}</span>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,31 @@
"use client";
import React from "react";
import { NoticeItem } from "./types";
interface NoticeListProps {
items: NoticeItem[];
onMoreClick: () => void;
}
export function NoticeList({ items, onMoreClick }: NoticeListProps) {
return (
<div className="pop-dashboard-card">
<div className="pop-dashboard-card-header">
<h3 className="pop-dashboard-card-title"></h3>
<button className="pop-dashboard-btn-more" onClick={onMoreClick}>
</button>
</div>
<div className="pop-dashboard-notice-list">
{items.map((item) => (
<div key={item.id} className="pop-dashboard-notice-item">
<div className="pop-dashboard-notice-title">{item.title}</div>
<div className="pop-dashboard-notice-date">{item.date}</div>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,83 @@
"use client";
import React, { useState, useEffect } from "react";
import { DashboardHeader } from "./DashboardHeader";
import { NoticeBanner } from "./NoticeBanner";
import { KpiBar } from "./KpiBar";
import { MenuGrid } from "./MenuGrid";
import { ActivityList } from "./ActivityList";
import { NoticeList } from "./NoticeList";
import { DashboardFooter } from "./DashboardFooter";
import {
KPI_ITEMS,
MENU_ITEMS,
ACTIVITY_ITEMS,
NOTICE_ITEMS,
NOTICE_MARQUEE_TEXT,
} from "./data";
import "./dashboard.css";
export function PopDashboard() {
const [theme, setTheme] = useState<"dark" | "light">("dark");
// 로컬 스토리지에서 테마 로드
useEffect(() => {
const savedTheme = localStorage.getItem("popTheme") as "dark" | "light" | null;
if (savedTheme) {
setTheme(savedTheme);
}
}, []);
const handleThemeToggle = () => {
const newTheme = theme === "dark" ? "light" : "dark";
setTheme(newTheme);
localStorage.setItem("popTheme", newTheme);
};
const handleUserClick = () => {
if (confirm("로그아웃 하시겠습니까?")) {
alert("로그아웃되었습니다.");
}
};
const handleActivityMore = () => {
alert("전체 활동 내역 화면으로 이동합니다.");
};
const handleNoticeMore = () => {
alert("전체 공지사항 화면으로 이동합니다.");
};
return (
<div className={`pop-dashboard-container ${theme === "light" ? "light" : ""}`}>
<div className="pop-dashboard">
<DashboardHeader
theme={theme}
weather={{ temp: "18°C", description: "맑음" }}
user={{ name: "김철수", role: "생산1팀", avatar: "김" }}
company={{ name: "탑씰", subTitle: "현장 관리 시스템" }}
onThemeToggle={handleThemeToggle}
onUserClick={handleUserClick}
/>
<NoticeBanner text={NOTICE_MARQUEE_TEXT} />
<KpiBar items={KPI_ITEMS} />
<MenuGrid items={MENU_ITEMS} />
<div className="pop-dashboard-bottom-section">
<ActivityList items={ACTIVITY_ITEMS} onMoreClick={handleActivityMore} />
<NoticeList items={NOTICE_ITEMS} onMoreClick={handleNoticeMore} />
</div>
<DashboardFooter
companyName="탑씰"
version="1.0.0"
emergencyContact="042-XXX-XXXX"
/>
</div>
</div>
);
}

View File

@ -0,0 +1,906 @@
/* ============================================
POP 대시보드 스타일시트
다크 모드 (사이버펑크) + 라이트 모드 (소프트 그레이 민트)
============================================ */
/* ========== 다크 모드 (기본) ========== */
.pop-dashboard-container {
--db-bg-page: #080c15;
--db-bg-card: linear-gradient(145deg, rgba(25, 35, 60, 0.9) 0%, rgba(18, 26, 47, 0.95) 100%);
--db-bg-card-solid: #121a2f;
--db-bg-card-alt: rgba(0, 0, 0, 0.2);
--db-bg-elevated: #202d4b;
--db-accent-primary: #00d4ff;
--db-accent-primary-light: #00f0ff;
--db-indigo: #4169e1;
--db-violet: #8a2be2;
--db-mint: #00d4ff;
--db-emerald: #00ff88;
--db-amber: #ffaa00;
--db-rose: #ff3333;
--db-text-primary: #ffffff;
--db-text-secondary: #b4c3dc;
--db-text-muted: #64788c;
--db-border: rgba(40, 55, 85, 1);
--db-border-light: rgba(55, 75, 110, 1);
--db-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
--db-shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4);
--db-shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5);
--db-glow-accent: 0 0 20px rgba(0, 212, 255, 0.5), 0 0 40px rgba(0, 212, 255, 0.3);
--db-radius-sm: 6px;
--db-radius-md: 10px;
--db-radius-lg: 14px;
--db-card-border-production: rgba(65, 105, 225, 0.5);
--db-card-border-material: rgba(138, 43, 226, 0.5);
--db-card-border-quality: rgba(0, 212, 255, 0.5);
--db-card-border-equipment: rgba(0, 255, 136, 0.5);
--db-card-border-safety: rgba(255, 170, 0, 0.5);
--db-notice-bg: rgba(255, 170, 0, 0.1);
--db-notice-border: rgba(255, 170, 0, 0.3);
--db-notice-text: #ffaa00;
--db-weather-bg: rgba(0, 0, 0, 0.2);
--db-weather-border: rgba(40, 55, 85, 1);
--db-user-badge-bg: rgba(0, 0, 0, 0.3);
--db-user-badge-hover: rgba(0, 212, 255, 0.1);
--db-btn-more-bg: rgba(0, 212, 255, 0.08);
--db-btn-more-border: rgba(0, 212, 255, 0.2);
--db-btn-more-color: #00d4ff;
--db-status-bg: rgba(0, 212, 255, 0.1);
--db-status-border: rgba(0, 212, 255, 0.2);
--db-status-color: #00d4ff;
}
/* ========== 라이트 모드 ========== */
.pop-dashboard-container.light {
--db-bg-page: #f8f9fb;
--db-bg-card: #ffffff;
--db-bg-card-solid: #ffffff;
--db-bg-card-alt: #f3f5f7;
--db-bg-elevated: #fafbfc;
--db-accent-primary: #14b8a6;
--db-accent-primary-light: #2dd4bf;
--db-indigo: #6366f1;
--db-violet: #8b5cf6;
--db-mint: #14b8a6;
--db-emerald: #10b981;
--db-amber: #f59e0b;
--db-rose: #f43f5e;
--db-text-primary: #1e293b;
--db-text-secondary: #475569;
--db-text-muted: #94a3b8;
--db-border: #e2e8f0;
--db-border-light: #f1f5f9;
--db-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.06);
--db-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
--db-shadow-lg: 0 10px 25px -5px rgba(0, 0, 0, 0.08), 0 8px 10px -6px rgba(0, 0, 0, 0.04);
--db-glow-accent: none;
--db-card-border-production: rgba(99, 102, 241, 0.3);
--db-card-border-material: rgba(139, 92, 246, 0.3);
--db-card-border-quality: rgba(20, 184, 166, 0.3);
--db-card-border-equipment: rgba(16, 185, 129, 0.3);
--db-card-border-safety: rgba(245, 158, 11, 0.3);
--db-notice-bg: linear-gradient(90deg, rgba(245, 158, 11, 0.08), rgba(251, 191, 36, 0.05));
--db-notice-border: rgba(245, 158, 11, 0.2);
--db-notice-text: #475569;
--db-weather-bg: rgba(20, 184, 166, 0.08);
--db-weather-border: rgba(20, 184, 166, 0.25);
--db-user-badge-bg: #f3f5f7;
--db-user-badge-hover: #e2e8f0;
--db-btn-more-bg: rgba(20, 184, 166, 0.08);
--db-btn-more-border: rgba(20, 184, 166, 0.25);
--db-btn-more-color: #0d9488;
--db-status-bg: #f3f5f7;
--db-status-border: transparent;
--db-status-color: #475569;
}
/* ========== 기본 컨테이너 ========== */
.pop-dashboard-container {
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
background: var(--db-bg-page);
color: var(--db-text-primary);
min-height: 100vh;
min-height: 100dvh;
transition: background 0.3s, color 0.3s;
position: relative;
}
/* 다크 모드 배경 그리드 */
.pop-dashboard-container::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
repeating-linear-gradient(90deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
repeating-linear-gradient(0deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 60%),
linear-gradient(180deg, #080c15 0%, #0a0f1c 50%, #0d1323 100%);
pointer-events: none;
z-index: 0;
}
.pop-dashboard-container.light::before {
background: linear-gradient(180deg, #f1f5f9 0%, #f8fafc 50%, #ffffff 100%);
}
.pop-dashboard {
position: relative;
z-index: 1;
max-width: 1600px;
margin: 0 auto;
padding: 20px;
min-height: 100vh;
min-height: 100dvh;
}
/* ========== 헤더 ========== */
.pop-dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 14px 24px;
background: var(--db-bg-card);
border: 1px solid var(--db-border);
border-radius: var(--db-radius-lg);
box-shadow: var(--db-shadow-sm);
position: relative;
overflow: hidden;
}
.pop-dashboard-container:not(.light) .pop-dashboard-header::before {
content: '';
position: absolute;
top: 0;
left: 20%;
right: 20%;
height: 2px;
background: linear-gradient(90deg, transparent, var(--db-accent-primary), transparent);
}
.pop-dashboard-header-left {
display: flex;
align-items: center;
gap: 8px;
}
.pop-dashboard-time-display {
display: flex;
align-items: baseline;
gap: 12px;
}
.pop-dashboard-time-main {
font-size: 26px;
font-weight: 600;
letter-spacing: 1px;
color: var(--db-accent-primary);
line-height: 1;
font-variant-numeric: tabular-nums;
}
.pop-dashboard-container:not(.light) .pop-dashboard-time-main {
text-shadow: var(--db-glow-accent);
animation: neonFlicker 3s infinite;
}
.pop-dashboard-time-date {
font-size: 13px;
color: var(--db-text-muted);
font-variant-numeric: tabular-nums;
}
.pop-dashboard-header-right {
display: flex;
align-items: center;
gap: 14px;
}
/* 테마 토글 */
.pop-dashboard-theme-toggle {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--db-bg-card-alt);
border: 1px solid var(--db-border);
border-radius: var(--db-radius-md);
color: var(--db-text-secondary);
cursor: pointer;
transition: all 0.2s;
}
.pop-dashboard-theme-toggle:hover {
border-color: var(--db-accent-primary);
color: var(--db-accent-primary);
}
/* 날씨 정보 */
.pop-dashboard-weather {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--db-weather-bg);
border: 1px solid var(--db-weather-border);
border-radius: var(--db-radius-md);
}
.pop-dashboard-weather-temp {
font-size: 13px;
font-weight: 700;
color: var(--db-amber);
}
.pop-dashboard-container.light .pop-dashboard-weather-temp {
color: var(--db-accent-primary);
}
.pop-dashboard-weather-desc {
font-size: 11px;
color: var(--db-text-muted);
}
/* 회사 정보 */
.pop-dashboard-company {
padding-right: 14px;
border-right: 1px solid var(--db-border);
text-align: right;
}
.pop-dashboard-company-name {
font-size: 15px;
font-weight: 700;
color: var(--db-text-primary);
}
.pop-dashboard-company-sub {
font-size: 11px;
color: var(--db-text-muted);
margin-top: 2px;
}
/* 사용자 배지 */
.pop-dashboard-user-badge {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
background: var(--db-user-badge-bg);
border: 1px solid var(--db-border);
border-radius: var(--db-radius-md);
cursor: pointer;
transition: all 0.2s;
}
.pop-dashboard-user-badge:hover {
background: var(--db-user-badge-hover);
}
.pop-dashboard-user-badge:active {
transform: scale(0.98);
}
.pop-dashboard-user-avatar {
width: 32px;
height: 32px;
background: linear-gradient(135deg, var(--db-accent-primary), var(--db-emerald));
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
color: white;
}
.pop-dashboard-container:not(.light) .pop-dashboard-user-avatar {
box-shadow: 0 0 10px rgba(0, 212, 255, 0.4);
}
.pop-dashboard-container.light .pop-dashboard-user-avatar {
box-shadow: 0 2px 8px rgba(20, 184, 166, 0.3);
}
.pop-dashboard-user-name {
font-size: 13px;
font-weight: 600;
color: var(--db-text-primary);
}
.pop-dashboard-user-role {
font-size: 11px;
color: var(--db-text-muted);
}
/* ========== 공지사항 배너 ========== */
.pop-dashboard-notice-banner {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding: 10px 16px;
background: var(--db-notice-bg);
border: 1px solid var(--db-notice-border);
border-radius: var(--db-radius-md);
}
.pop-dashboard-notice-label {
font-size: 10px;
font-weight: 700;
color: var(--db-bg-page);
background: var(--db-amber);
padding: 3px 8px;
border-radius: 4px;
flex-shrink: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.pop-dashboard-container.light .pop-dashboard-notice-label {
color: white;
}
.pop-dashboard-notice-content {
flex: 1;
overflow: hidden;
}
.pop-dashboard-notice-marquee {
display: flex;
animation: dashboardMarquee 30s linear infinite;
white-space: nowrap;
}
.pop-dashboard-notice-text {
font-size: 12px;
color: var(--db-notice-text);
padding-right: 100px;
}
@keyframes dashboardMarquee {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
.pop-dashboard-notice-banner:hover .pop-dashboard-notice-marquee {
animation-play-state: paused;
}
/* ========== KPI 바 ========== */
.pop-dashboard-kpi-bar {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.pop-dashboard-kpi-item {
background: var(--db-bg-card);
border: 1px solid var(--db-border);
border-radius: var(--db-radius-lg);
padding: 16px 18px;
display: flex;
align-items: center;
gap: 14px;
box-shadow: var(--db-shadow-sm);
transition: all 0.2s;
position: relative;
overflow: hidden;
}
.pop-dashboard-container:not(.light) .pop-dashboard-kpi-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.5), transparent);
}
.pop-dashboard-kpi-item:hover {
transform: translateY(-2px);
box-shadow: var(--db-shadow-md);
}
.pop-dashboard-container:not(.light) .pop-dashboard-kpi-item:hover {
border-color: rgba(0, 212, 255, 0.3);
box-shadow: 0 0 30px rgba(0, 212, 255, 0.1), inset 0 0 30px rgba(0, 212, 255, 0.02);
}
.pop-dashboard-kpi-gauge {
width: 52px;
height: 52px;
position: relative;
flex-shrink: 0;
}
.pop-dashboard-kpi-gauge svg {
transform: rotate(-90deg);
}
.pop-dashboard-kpi-gauge-bg {
fill: none;
stroke: var(--db-border);
stroke-width: 5;
}
.pop-dashboard-kpi-gauge-fill {
fill: none;
stroke-width: 5;
stroke-linecap: round;
transition: stroke-dashoffset 0.5s;
}
.pop-dashboard-container:not(.light) .pop-dashboard-kpi-gauge-fill {
filter: drop-shadow(0 0 6px currentColor);
}
.pop-dashboard-container.light .pop-dashboard-kpi-gauge-fill {
filter: drop-shadow(0 1px 2px rgba(0,0,0,0.1));
}
.pop-dashboard-kpi-gauge-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.pop-dashboard-kpi-info { flex: 1; }
.pop-dashboard-kpi-label {
font-size: 11px;
color: var(--db-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.pop-dashboard-kpi-value {
font-size: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.pop-dashboard-kpi-unit {
font-size: 12px;
color: var(--db-text-muted);
margin-left: 3px;
font-weight: 500;
}
/* KPI 색상 */
.kpi-color-cyan { color: var(--db-mint); stroke: var(--db-mint); }
.kpi-color-emerald { color: var(--db-emerald); stroke: var(--db-emerald); }
.kpi-color-rose { color: var(--db-rose); stroke: var(--db-rose); }
.kpi-color-amber { color: var(--db-amber); stroke: var(--db-amber); }
/* ========== 메뉴 그리드 ========== */
.pop-dashboard-menu-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.pop-dashboard-menu-card {
background: var(--db-bg-card);
border-radius: var(--db-radius-lg);
padding: 18px;
cursor: pointer;
transition: all 0.2s;
box-shadow: var(--db-shadow-sm);
position: relative;
overflow: hidden;
}
.pop-dashboard-menu-card:hover {
transform: translateY(-4px);
box-shadow: var(--db-shadow-lg);
}
.pop-dashboard-menu-card:active {
transform: scale(0.98);
}
.pop-dashboard-menu-card.production { border: 2px solid var(--db-card-border-production); }
.pop-dashboard-menu-card.material { border: 2px solid var(--db-card-border-material); }
.pop-dashboard-menu-card.quality { border: 2px solid var(--db-card-border-quality); }
.pop-dashboard-menu-card.equipment { border: 2px solid var(--db-card-border-equipment); }
.pop-dashboard-menu-card.safety { border: 2px solid var(--db-card-border-safety); }
.pop-dashboard-container:not(.light) .pop-dashboard-menu-card.production:hover { box-shadow: 0 0 20px rgba(65, 105, 225, 0.3); }
.pop-dashboard-container:not(.light) .pop-dashboard-menu-card.material:hover { box-shadow: 0 0 20px rgba(138, 43, 226, 0.3); }
.pop-dashboard-container:not(.light) .pop-dashboard-menu-card.quality:hover { box-shadow: 0 0 20px rgba(0, 212, 255, 0.3); }
.pop-dashboard-container:not(.light) .pop-dashboard-menu-card.equipment:hover { box-shadow: 0 0 20px rgba(0, 255, 136, 0.3); }
.pop-dashboard-container:not(.light) .pop-dashboard-menu-card.safety:hover { box-shadow: 0 0 20px rgba(255, 170, 0, 0.3); }
.pop-dashboard-menu-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10px;
}
.pop-dashboard-menu-title {
font-size: 14px;
font-weight: 700;
color: var(--db-text-primary);
}
.pop-dashboard-menu-count {
font-size: 24px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.pop-dashboard-container:not(.light) .pop-dashboard-menu-count {
text-shadow: 0 0 20px currentColor;
}
.pop-dashboard-menu-count.production { color: var(--db-indigo); }
.pop-dashboard-menu-count.material { color: var(--db-violet); }
.pop-dashboard-menu-count.quality { color: var(--db-mint); }
.pop-dashboard-menu-count.equipment { color: var(--db-emerald); }
.pop-dashboard-menu-count.safety { color: var(--db-amber); }
.pop-dashboard-menu-desc {
font-size: 11px;
color: var(--db-text-muted);
line-height: 1.5;
}
.pop-dashboard-menu-status {
display: inline-block;
margin-top: 10px;
padding: 4px 10px;
background: var(--db-status-bg);
border: 1px solid var(--db-status-border);
border-radius: 16px;
font-size: 10px;
font-weight: 600;
color: var(--db-status-color);
}
/* ========== 하단 섹션 ========== */
.pop-dashboard-bottom-section {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 12px;
}
.pop-dashboard-card {
background: var(--db-bg-card);
border: 1px solid var(--db-border);
border-radius: var(--db-radius-lg);
padding: 18px;
box-shadow: var(--db-shadow-sm);
position: relative;
overflow: hidden;
}
.pop-dashboard-container:not(.light) .pop-dashboard-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.5), transparent);
}
.pop-dashboard-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
padding-bottom: 12px;
border-bottom: 1px solid var(--db-border);
}
.pop-dashboard-card-title {
font-size: 14px;
font-weight: 700;
color: var(--db-text-primary);
}
.pop-dashboard-btn-more {
padding: 6px 12px;
background: var(--db-btn-more-bg);
border: 1px solid var(--db-btn-more-border);
color: var(--db-btn-more-color);
border-radius: var(--db-radius-sm);
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.pop-dashboard-btn-more:hover {
background: var(--db-accent-primary);
color: white;
border-color: var(--db-accent-primary);
}
/* 활동 리스트 */
.pop-dashboard-activity-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.pop-dashboard-activity-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--db-bg-card-alt);
border-radius: var(--db-radius-md);
transition: all 0.2s;
}
.pop-dashboard-container:not(.light) .pop-dashboard-activity-item {
border: 1px solid transparent;
}
.pop-dashboard-container:not(.light) .pop-dashboard-activity-item:hover {
background: rgba(0, 212, 255, 0.05);
border-color: rgba(0, 212, 255, 0.2);
}
.pop-dashboard-container.light .pop-dashboard-activity-item:hover {
background: var(--db-border-light);
}
.pop-dashboard-activity-time {
font-size: 12px;
font-weight: 700;
color: var(--db-accent-primary);
font-variant-numeric: tabular-nums;
min-width: 48px;
}
.pop-dashboard-container:not(.light) .pop-dashboard-activity-time {
text-shadow: 0 0 10px rgba(0, 212, 255, 0.5);
}
.pop-dashboard-activity-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.pop-dashboard-container:not(.light) .pop-dashboard-activity-dot {
box-shadow: 0 0 8px currentColor;
}
.pop-dashboard-activity-dot.production { background: var(--db-indigo); color: var(--db-indigo); }
.pop-dashboard-activity-dot.material { background: var(--db-violet); color: var(--db-violet); }
.pop-dashboard-activity-dot.quality { background: var(--db-mint); color: var(--db-mint); }
.pop-dashboard-activity-dot.equipment { background: var(--db-emerald); color: var(--db-emerald); }
.pop-dashboard-activity-content { flex: 1; }
.pop-dashboard-activity-title {
font-size: 13px;
font-weight: 600;
color: var(--db-text-primary);
margin-bottom: 2px;
}
.pop-dashboard-activity-desc {
font-size: 11px;
color: var(--db-text-muted);
}
/* 공지사항 리스트 */
.pop-dashboard-notice-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.pop-dashboard-notice-item {
padding: 12px;
background: var(--db-bg-card-alt);
border-radius: var(--db-radius-md);
transition: all 0.2s;
cursor: pointer;
}
.pop-dashboard-container:not(.light) .pop-dashboard-notice-item:hover {
background: rgba(255, 170, 0, 0.05);
}
.pop-dashboard-container.light .pop-dashboard-notice-item:hover {
background: var(--db-border-light);
}
.pop-dashboard-notice-title {
font-size: 13px;
font-weight: 600;
color: var(--db-text-primary);
margin-bottom: 4px;
}
.pop-dashboard-notice-date {
font-size: 11px;
color: var(--db-text-muted);
font-variant-numeric: tabular-nums;
}
/* ========== 푸터 ========== */
.pop-dashboard-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 16px;
padding: 14px 18px;
background: var(--db-bg-card);
border: 1px solid var(--db-border);
border-radius: var(--db-radius-md);
font-size: 11px;
color: var(--db-text-muted);
}
/* ========== 반응형 ========== */
/* 가로 모드 */
@media (orientation: landscape) {
.pop-dashboard { padding: 16px 24px; }
.pop-dashboard-kpi-bar { grid-template-columns: repeat(4, 1fr) !important; gap: 10px; }
.pop-dashboard-kpi-item { padding: 12px 14px; }
.pop-dashboard-kpi-gauge { width: 44px; height: 44px; }
.pop-dashboard-kpi-gauge svg { width: 44px; height: 44px; }
.pop-dashboard-kpi-value { font-size: 20px; }
.pop-dashboard-menu-grid { grid-template-columns: repeat(5, 1fr) !important; gap: 10px; }
.pop-dashboard-menu-card { padding: 14px; display: block; }
.pop-dashboard-menu-header { margin-bottom: 8px; display: block; }
.pop-dashboard-menu-title { font-size: 13px; }
.pop-dashboard-menu-count { font-size: 20px; }
.pop-dashboard-menu-desc { display: block; font-size: 10px; }
.pop-dashboard-menu-status { margin-top: 8px; }
.pop-dashboard-bottom-section { grid-template-columns: 2fr 1fr; }
}
/* 세로 모드 */
@media (orientation: portrait) {
.pop-dashboard { padding: 16px; }
.pop-dashboard-kpi-bar { grid-template-columns: repeat(2, 1fr) !important; gap: 10px; }
.pop-dashboard-menu-grid { grid-template-columns: 1fr !important; gap: 8px; }
.pop-dashboard-menu-card {
padding: 14px 18px;
display: flex;
align-items: center;
justify-content: space-between;
}
.pop-dashboard-menu-header { margin-bottom: 0; display: flex; align-items: center; gap: 12px; }
.pop-dashboard-menu-title { font-size: 15px; }
.pop-dashboard-menu-count { font-size: 20px; }
.pop-dashboard-menu-desc { display: none; }
.pop-dashboard-menu-status { margin-top: 0; padding: 5px 12px; font-size: 11px; }
.pop-dashboard-bottom-section { grid-template-columns: 1fr; }
}
/* 작은 화면 세로 */
@media (max-width: 600px) and (orientation: portrait) {
.pop-dashboard { padding: 12px; }
.pop-dashboard-header { padding: 10px 14px; }
.pop-dashboard-time-main { font-size: 20px; }
.pop-dashboard-time-date { display: none; }
.pop-dashboard-weather { padding: 4px 8px; }
.pop-dashboard-weather-temp { font-size: 11px; }
.pop-dashboard-weather-desc { display: none; }
.pop-dashboard-company { display: none; }
.pop-dashboard-user-text { display: none; }
.pop-dashboard-user-avatar { width: 30px; height: 30px; }
.pop-dashboard-notice-banner { padding: 8px 12px; }
.pop-dashboard-notice-label { font-size: 9px; }
.pop-dashboard-notice-text { font-size: 11px; }
.pop-dashboard-kpi-item { padding: 12px 14px; gap: 10px; }
.pop-dashboard-kpi-gauge { width: 44px; height: 44px; }
.pop-dashboard-kpi-gauge svg { width: 44px; height: 44px; }
.pop-dashboard-kpi-gauge-text { font-size: 10px; }
.pop-dashboard-kpi-label { font-size: 10px; }
.pop-dashboard-kpi-value { font-size: 18px; }
.pop-dashboard-menu-card { padding: 12px 16px; }
.pop-dashboard-menu-title { font-size: 14px; }
.pop-dashboard-menu-count { font-size: 18px; }
.pop-dashboard-menu-status { padding: 4px 10px; font-size: 10px; }
}
/* 작은 화면 가로 */
@media (max-width: 600px) and (orientation: landscape) {
.pop-dashboard { padding: 10px 16px; }
.pop-dashboard-header { padding: 8px 12px; }
.pop-dashboard-time-main { font-size: 18px; }
.pop-dashboard-time-date { font-size: 10px; }
.pop-dashboard-weather { display: none; }
.pop-dashboard-company { display: none; }
.pop-dashboard-user-text { display: none; }
.pop-dashboard-notice-banner { padding: 6px 10px; margin-bottom: 10px; }
.pop-dashboard-kpi-item { padding: 8px 10px; gap: 8px; }
.pop-dashboard-kpi-gauge { width: 36px; height: 36px; }
.pop-dashboard-kpi-gauge svg { width: 36px; height: 36px; }
.pop-dashboard-kpi-gauge-text { font-size: 9px; }
.pop-dashboard-kpi-label { font-size: 9px; }
.pop-dashboard-kpi-value { font-size: 16px; }
.pop-dashboard-menu-card { padding: 10px; }
.pop-dashboard-menu-title { font-size: 11px; }
.pop-dashboard-menu-count { font-size: 16px; }
.pop-dashboard-menu-status { margin-top: 4px; padding: 2px 6px; font-size: 8px; }
}
/* ========== 애니메이션 ========== */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes neonFlicker {
0%, 19%, 21%, 23%, 25%, 54%, 56%, 100% { opacity: 1; }
20%, 24%, 55% { opacity: 0.85; }
}
.pop-dashboard-kpi-item, .pop-dashboard-menu-card, .pop-dashboard-card {
animation: fadeIn 0.35s ease-out backwards;
}
.pop-dashboard-kpi-item:nth-child(1) { animation-delay: 0.05s; }
.pop-dashboard-kpi-item:nth-child(2) { animation-delay: 0.1s; }
.pop-dashboard-kpi-item:nth-child(3) { animation-delay: 0.15s; }
.pop-dashboard-kpi-item:nth-child(4) { animation-delay: 0.2s; }
.pop-dashboard-menu-card:nth-child(1) { animation-delay: 0.1s; }
.pop-dashboard-menu-card:nth-child(2) { animation-delay: 0.15s; }
.pop-dashboard-menu-card:nth-child(3) { animation-delay: 0.2s; }
.pop-dashboard-menu-card:nth-child(4) { animation-delay: 0.25s; }
.pop-dashboard-menu-card:nth-child(5) { animation-delay: 0.3s; }
/* 스크롤바 */
.pop-dashboard-container ::-webkit-scrollbar { width: 6px; height: 6px; }
.pop-dashboard-container ::-webkit-scrollbar-track { background: transparent; }
.pop-dashboard-container ::-webkit-scrollbar-thumb { background: var(--db-border); border-radius: 3px; }
.pop-dashboard-container ::-webkit-scrollbar-thumb:hover { background: var(--db-accent-primary); }

View File

@ -0,0 +1,139 @@
// POP 대시보드 샘플 데이터
import { KpiItem, MenuItem, ActivityItem, NoticeItem } from "./types";
export const KPI_ITEMS: KpiItem[] = [
{
id: "achievement",
label: "목표 달성률",
value: 83.3,
unit: "%",
percentage: 83,
color: "cyan",
},
{
id: "production",
label: "금일 생산실적",
value: 1250,
unit: "EA",
percentage: 100,
color: "emerald",
},
{
id: "defect",
label: "불량률",
value: 0.8,
unit: "%",
percentage: 1,
color: "rose",
},
{
id: "equipment",
label: "가동 설비",
value: 8,
unit: "/ 10",
percentage: 80,
color: "amber",
},
];
export const MENU_ITEMS: MenuItem[] = [
{
id: "production",
title: "생산관리",
count: 5,
description: "작업지시 / 생산실적 / 공정관리",
status: "진행중",
category: "production",
href: "/pop/work",
},
{
id: "material",
title: "자재관리",
count: 12,
description: "자재출고 / 재고확인 / 입고처리",
status: "대기",
category: "material",
href: "#",
},
{
id: "quality",
title: "품질관리",
count: 3,
description: "품질검사 / 불량처리 / 검사기록",
status: "검사대기",
category: "quality",
href: "#",
},
{
id: "equipment",
title: "설비관리",
count: 2,
description: "설비현황 / 점검관리 / 고장신고",
status: "점검필요",
category: "equipment",
href: "#",
},
{
id: "safety",
title: "안전관리",
count: 0,
description: "안전점검 / 사고신고 / 안전교육",
status: "이상무",
category: "safety",
href: "#",
},
];
export const ACTIVITY_ITEMS: ActivityItem[] = [
{
id: "1",
time: "14:25",
title: "생산실적 등록",
description: "WO-2024-156 - 500EA 생산완료",
category: "production",
},
{
id: "2",
time: "13:50",
title: "자재출고",
description: "알루미늄 프로파일 A100 - 200EA",
category: "material",
},
{
id: "3",
time: "11:30",
title: "품질검사 완료",
description: "LOT-2024-156 합격 (불량 0건)",
category: "quality",
},
{
id: "4",
time: "09:15",
title: "설비점검",
description: "5호기 정기점검 완료",
category: "equipment",
},
];
export const NOTICE_ITEMS: NoticeItem[] = [
{
id: "1",
title: "금일 15:00 전체 안전교육",
date: "2024-01-05",
},
{
id: "2",
title: "3호기 정기점검 안내",
date: "2024-01-04",
},
{
id: "3",
title: "11월 우수팀 - 생산1팀",
date: "2024-01-03",
},
];
export const NOTICE_MARQUEE_TEXT =
"[공지] 금일 오후 3시 전체 안전교육 실시 예정입니다. 전 직원 필참 바랍니다. | [알림] 내일 설비 정기점검으로 인한 3호기 가동 중지 예정 | [안내] 11월 생산실적 우수팀 발표 - 생산1팀 축하드립니다!";

View File

@ -0,0 +1,11 @@
export { PopDashboard } from "./PopDashboard";
export { DashboardHeader } from "./DashboardHeader";
export { NoticeBanner } from "./NoticeBanner";
export { KpiBar } from "./KpiBar";
export { MenuGrid } from "./MenuGrid";
export { ActivityList } from "./ActivityList";
export { NoticeList } from "./NoticeList";
export { DashboardFooter } from "./DashboardFooter";
export * from "./types";
export * from "./data";

View File

@ -0,0 +1,51 @@
// POP 대시보드 타입 정의
export interface KpiItem {
id: string;
label: string;
value: number;
unit: string;
percentage: number;
color: string;
}
export interface MenuItem {
id: string;
title: string;
count: number;
description: string;
status: string;
category: "production" | "material" | "quality" | "equipment" | "safety";
href: string;
}
export interface ActivityItem {
id: string;
time: string;
title: string;
description: string;
category: "production" | "material" | "quality" | "equipment";
}
export interface NoticeItem {
id: string;
title: string;
date: string;
}
export interface WeatherInfo {
temp: string;
description: string;
}
export interface UserInfo {
name: string;
role: string;
avatar: string;
}
export interface CompanyInfo {
name: string;
subTitle: string;
}

View File

@ -0,0 +1,373 @@
// POP 샘플 데이터
import { Process, Equipment, WorkOrder, WorkStepTemplate } from "./types";
// 공정 목록
export const PROCESSES: Process[] = [
{ id: "P001", name: "절단", code: "CUT" },
{ id: "P002", name: "용접", code: "WELD" },
{ id: "P003", name: "도장", code: "PAINT" },
{ id: "P004", name: "조립", code: "ASSY" },
{ id: "P005", name: "검사", code: "QC" },
{ id: "P006", name: "포장", code: "PACK" },
{ id: "P007", name: "프레스", code: "PRESS" },
{ id: "P008", name: "연마", code: "POLISH" },
{ id: "P009", name: "열처리", code: "HEAT" },
{ id: "P010", name: "표면처리", code: "SURFACE" },
{ id: "P011", name: "드릴링", code: "DRILL" },
{ id: "P012", name: "밀링", code: "MILL" },
{ id: "P013", name: "선반", code: "LATHE" },
{ id: "P014", name: "연삭", code: "GRIND" },
{ id: "P015", name: "측정", code: "MEASURE" },
{ id: "P016", name: "세척", code: "CLEAN" },
{ id: "P017", name: "건조", code: "DRY" },
{ id: "P018", name: "코팅", code: "COAT" },
{ id: "P019", name: "라벨링", code: "LABEL" },
{ id: "P020", name: "출하검사", code: "FINAL_QC" },
];
// 설비 목록
export const EQUIPMENTS: Equipment[] = [
{
id: "E001",
name: "CNC-01",
processIds: ["P001"],
processNames: ["절단"],
status: "running",
},
{
id: "E002",
name: "CNC-02",
processIds: ["P001"],
processNames: ["절단"],
status: "idle",
},
{
id: "E003",
name: "용접기-01",
processIds: ["P002"],
processNames: ["용접"],
status: "running",
},
{
id: "E004",
name: "도장라인-A",
processIds: ["P003"],
processNames: ["도장"],
status: "running",
},
{
id: "E005",
name: "조립라인-01",
processIds: ["P004", "P006"],
processNames: ["조립", "포장"],
status: "running",
},
{
id: "E006",
name: "검사대-01",
processIds: ["P005"],
processNames: ["검사"],
status: "idle",
},
{
id: "E007",
name: "작업대-A",
processIds: ["P001", "P002", "P004"],
processNames: ["절단", "용접", "조립"],
status: "idle",
},
{
id: "E008",
name: "작업대-B",
processIds: ["P003", "P005", "P006"],
processNames: ["도장", "검사", "포장"],
status: "idle",
},
];
// 작업순서 템플릿
export const WORK_STEP_TEMPLATES: Record<string, WorkStepTemplate[]> = {
P001: [
// 절단 공정
{
id: 1,
name: "설비 점검",
type: "equipment-check",
description: "설비 상태 및 안전 점검",
},
{
id: 2,
name: "원자재 확인",
type: "material-check",
description: "원자재 수량 및 품질 확인",
},
{ id: 3, name: "설비 셋팅", type: "setup", description: "절단 조건 설정" },
{ id: 4, name: "가공 작업", type: "work", description: "절단 가공 진행" },
{
id: 5,
name: "품질 검사",
type: "inspection",
description: "가공 결과 품질 검사",
},
{ id: 6, name: "작업 기록", type: "record", description: "작업 실적 기록" },
],
P002: [
// 용접 공정
{
id: 1,
name: "설비 점검",
type: "equipment-check",
description: "용접기 및 안전장비 점검",
},
{
id: 2,
name: "자재 준비",
type: "material-check",
description: "용접 자재 및 부품 확인",
},
{
id: 3,
name: "용접 조건 설정",
type: "setup",
description: "전류, 전압 등 설정",
},
{ id: 4, name: "용접 작업", type: "work", description: "용접 진행" },
{
id: 5,
name: "용접부 검사",
type: "inspection",
description: "용접 품질 검사",
},
{ id: 6, name: "작업 기록", type: "record", description: "용접 실적 기록" },
],
default: [
{
id: 1,
name: "작업 준비",
type: "preparation",
description: "작업 전 준비사항 확인",
},
{ id: 2, name: "작업 실행", type: "work", description: "작업 진행" },
{
id: 3,
name: "품질 확인",
type: "inspection",
description: "작업 결과 확인",
},
{ id: 4, name: "작업 기록", type: "record", description: "작업 내용 기록" },
],
};
// 작업지시 목록
export const WORK_ORDERS: WorkOrder[] = [
{
id: "WO-2025-001",
itemCode: "PROD-001",
itemName: "LCD 패널 A101",
spec: "1920x1080",
orderQuantity: 500,
producedQuantity: 0,
status: "waiting",
process: "P001",
processName: "절단",
equipment: "E001",
equipmentName: "CNC-01",
startDate: "2025-01-06",
dueDate: "2025-01-10",
priority: "high",
accepted: false,
processFlow: [
{ id: "P001", name: "절단", status: "pending" },
{ id: "P007", name: "프레스", status: "pending" },
{ id: "P011", name: "드릴링", status: "pending" },
{ id: "P002", name: "용접", status: "pending" },
{ id: "P008", name: "연마", status: "pending" },
{ id: "P003", name: "도장", status: "pending" },
{ id: "P004", name: "조립", status: "pending" },
{ id: "P005", name: "검사", status: "pending" },
{ id: "P006", name: "포장", status: "pending" },
],
currentProcessIndex: 0,
},
{
id: "WO-2025-002",
itemCode: "PROD-002",
itemName: "LED 모듈 B202",
spec: "500x500",
orderQuantity: 300,
producedQuantity: 150,
status: "in-progress",
process: "P002",
processName: "용접",
equipment: "E003",
equipmentName: "용접기-01",
startDate: "2025-01-05",
dueDate: "2025-01-08",
priority: "medium",
accepted: true,
processFlow: [
{ id: "P001", name: "절단", status: "completed" },
{ id: "P007", name: "프레스", status: "completed" },
{ id: "P011", name: "드릴링", status: "completed" },
{ id: "P002", name: "용접", status: "current" },
{ id: "P008", name: "연마", status: "pending" },
{ id: "P003", name: "도장", status: "pending" },
{ id: "P004", name: "조립", status: "pending" },
{ id: "P005", name: "검사", status: "pending" },
{ id: "P006", name: "포장", status: "pending" },
],
currentProcessIndex: 3,
},
{
id: "WO-2025-003",
itemCode: "PROD-003",
itemName: "OLED 디스플레이",
spec: "2560x1440",
orderQuantity: 200,
producedQuantity: 50,
status: "in-progress",
process: "P004",
processName: "조립",
equipment: "E005",
equipmentName: "조립라인-01",
startDate: "2025-01-04",
dueDate: "2025-01-09",
priority: "high",
accepted: true,
processFlow: [
{ id: "P001", name: "절단", status: "completed" },
{ id: "P007", name: "프레스", status: "completed" },
{ id: "P002", name: "용접", status: "completed" },
{ id: "P003", name: "도장", status: "completed" },
{ id: "P004", name: "조립", status: "current" },
{ id: "P005", name: "검사", status: "pending" },
{ id: "P006", name: "포장", status: "pending" },
],
currentProcessIndex: 4,
},
{
id: "WO-2025-004",
itemCode: "PROD-004",
itemName: "스틸 프레임 C300",
spec: "800x600",
orderQuantity: 150,
producedQuantity: 30,
status: "in-progress",
process: "P005",
processName: "검사",
equipment: "E006",
equipmentName: "검사대-01",
startDate: "2025-01-03",
dueDate: "2025-01-10",
priority: "medium",
accepted: false,
processFlow: [
{ id: "P001", name: "절단", status: "completed" },
{ id: "P002", name: "용접", status: "completed" },
{ id: "P008", name: "연마", status: "completed" },
{ id: "P003", name: "도장", status: "completed" },
{ id: "P004", name: "조립", status: "completed" },
{ id: "P005", name: "검사", status: "current" },
{ id: "P006", name: "포장", status: "pending" },
],
currentProcessIndex: 5,
},
{
id: "WO-2025-005",
itemCode: "PROD-005",
itemName: "알루미늄 케이스",
spec: "300x400",
orderQuantity: 400,
producedQuantity: 400,
status: "completed",
process: "P006",
processName: "포장",
equipment: "E005",
equipmentName: "조립라인-01",
startDate: "2025-01-01",
dueDate: "2025-01-05",
completedDate: "2025-01-05",
priority: "high",
accepted: true,
processFlow: [
{ id: "P001", name: "절단", status: "completed" },
{ id: "P007", name: "프레스", status: "completed" },
{ id: "P008", name: "연마", status: "completed" },
{ id: "P003", name: "도장", status: "completed" },
{ id: "P004", name: "조립", status: "completed" },
{ id: "P005", name: "검사", status: "completed" },
{ id: "P006", name: "포장", status: "completed" },
],
currentProcessIndex: 6,
},
// 공정 리턴 작업지시
{
id: "WO-2025-006",
itemCode: "PROD-006",
itemName: "리턴품 샤프트 F100",
spec: "50x300",
orderQuantity: 80,
producedQuantity: 30,
status: "in-progress",
process: "P008",
processName: "연마",
equipment: null,
equipmentName: null,
startDate: "2025-01-03",
dueDate: "2025-01-08",
priority: "high",
accepted: false,
isReturn: true,
returnReason: "검사 불합격 - 표면 조도 미달",
returnFromProcess: "P005",
returnFromProcessName: "검사",
processFlow: [
{ id: "P001", name: "절단", status: "completed" },
{ id: "P002", name: "용접", status: "completed" },
{ id: "P008", name: "연마", status: "pending", isReturnTarget: true },
{ id: "P014", name: "연삭", status: "pending" },
{ id: "P016", name: "세척", status: "pending" },
{ id: "P005", name: "검사", status: "pending" },
],
currentProcessIndex: 2,
},
// 분할접수 작업지시
{
id: "WO-2025-007",
itemCode: "PROD-007",
itemName: "분할접수 테스트 품목",
spec: "100x200",
orderQuantity: 200,
producedQuantity: 50,
acceptedQuantity: 50,
remainingQuantity: 150,
status: "in-progress",
process: "P002",
processName: "용접",
equipment: "E003",
equipmentName: "용접기-01",
startDate: "2025-01-04",
dueDate: "2025-01-10",
priority: "normal",
accepted: true,
isPartialAccept: true,
processFlow: [
{ id: "P001", name: "절단", status: "completed" },
{ id: "P002", name: "용접", status: "current" },
{ id: "P003", name: "도장", status: "pending" },
{ id: "P004", name: "조립", status: "pending" },
{ id: "P005", name: "검사", status: "pending" },
{ id: "P006", name: "포장", status: "pending" },
],
currentProcessIndex: 1,
},
];
// 상태 텍스트 매핑
export const STATUS_TEXT: Record<string, string> = {
waiting: "대기",
"in-progress": "진행중",
completed: "완료",
};

View File

@ -0,0 +1,13 @@
export { PopApp } from "./PopApp";
export { PopHeader } from "./PopHeader";
export { PopStatusTabs } from "./PopStatusTabs";
export { PopWorkCard } from "./PopWorkCard";
export { PopBottomNav } from "./PopBottomNav";
export { PopEquipmentModal } from "./PopEquipmentModal";
export { PopProcessModal } from "./PopProcessModal";
export { PopAcceptModal } from "./PopAcceptModal";
export { PopSettingsModal } from "./PopSettingsModal";
export { PopProductionPanel } from "./PopProductionPanel";
export * from "./types";
export * from "./data";

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,104 @@
// POP 생산실적관리 타입 정의
export interface Process {
id: string;
name: string;
code: string;
}
export interface Equipment {
id: string;
name: string;
processIds: string[];
processNames: string[];
status: "running" | "idle" | "maintenance";
}
export interface ProcessFlowStep {
id: string;
name: string;
status: "pending" | "current" | "completed";
isReturnTarget?: boolean;
}
export interface WorkOrder {
id: string;
itemCode: string;
itemName: string;
spec: string;
orderQuantity: number;
producedQuantity: number;
status: "waiting" | "in-progress" | "completed";
process: string;
processName: string;
equipment: string | null;
equipmentName: string | null;
startDate: string;
dueDate: string;
completedDate?: string;
priority: "high" | "medium" | "normal" | "low";
accepted: boolean;
processFlow: ProcessFlowStep[];
currentProcessIndex: number;
// 리턴 관련
isReturn?: boolean;
returnReason?: string;
returnFromProcess?: string;
returnFromProcessName?: string;
// 분할접수 관련
acceptedQuantity?: number;
remainingQuantity?: number;
isPartialAccept?: boolean;
}
export interface WorkStepTemplate {
id: number;
name: string;
type:
| "equipment-check"
| "material-check"
| "setup"
| "work"
| "inspection"
| "record"
| "preparation";
description: string;
}
export interface WorkStep extends WorkStepTemplate {
status: "pending" | "in-progress" | "completed";
startTime: Date | null;
endTime: Date | null;
data: Record<string, any>;
}
export type StatusType = "waiting" | "pending-accept" | "in-progress" | "completed";
export type ProductionType = "work-order" | "material";
export interface AppState {
currentStatus: StatusType;
selectedEquipment: Equipment | null;
selectedProcess: Process | null;
selectedWorkOrder: WorkOrder | null;
showMyWorkOnly: boolean;
currentWorkSteps: WorkStep[];
currentStepIndex: number;
currentProductionType: ProductionType;
selectionMode: "single" | "multi";
completionAction: "close" | "stay";
acceptTargetWorkOrder: WorkOrder | null;
acceptQuantity: number;
theme: "dark" | "light";
}
export interface ModalState {
equipment: boolean;
process: boolean;
accept: boolean;
settings: boolean;
}
export interface PanelState {
production: boolean;
}