feat: pop-card-list-v2 설정 패널 MES 간소화 + Core Binding + 내 작업 표시 모드

MES 고정 구조에 맞게 설정 패널을 간소화하고, 작업상세 내장 모달과
작업자 기반 카드 필터링 기능을 추가한다.
[설정 패널 간소화]
- 3탭(데이터/디자인/동작) -> 2탭(정보/동작)으로 축소
- "정보" 탭: 데이터 소스, 카드 구성, 클릭 동작을 읽기 전용 요약으로 표시
- "동작" 탭: cardClickAction 선택(none/modal-open/built-in-work-detail)
  + 내 작업 표시 + 고급 설정(필터 전 숨김, 기본 표시 수) 유지
- PopWorkDetailConfigPanel을 카드 설정에서 분리
  (작업상세 컴포넌트 자체 설정 패널에서 관리)
[Core Binding 내장 모달]
- cardClickAction="built-in-work-detail" 시 내부 Dialog로
  PopWorkDetail 직접 렌더링, parentRow를 prop으로 전달
- LazyPopWorkDetail dynamic import로 성능 최적화
- in_progress 상태 카드만 상세 모달 열림
[카드 열 수 선택]
- 정보 탭 상단에 1열/2열/3열/4열 버튼 UI 추가
- gridColumns 설정 즉시 반영
[내 작업 표시 3모드]
- 전체 보기: 모든 카드 동등 표시
- 우선 표시: 내 카드 상단 + 다른 카드 비활성화(기존 동작)
- 내 작업만: worker 컬럼 기준 내 카드만 표시, 나머지 숨김
- ownerFilterMode("priority"|"only") 타입 추가, 컬럼 선택 드롭다운
  제거하고 worker 고정 토글로 단순화
This commit is contained in:
SeongHyun Kim 2026-03-19 16:09:11 +09:00
parent 5d12bef5e5
commit d001f82565
9 changed files with 516 additions and 250 deletions

View File

@ -872,7 +872,7 @@ export const saveResult = async (
[wo_id, prevSeq, companyCode]
);
if (prevProcess.rowCount > 0) {
prevGoodQty = prevProcess.rows[0].total_good;
prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0;
}
}
@ -887,8 +887,8 @@ export const saveResult = async (
[wo_id, seq_no, companyCode]
);
const totalInput = siblingCheck.rows[0].total_input;
const incompleteCount = parseInt(siblingCheck.rows[0].incomplete_count, 10);
const totalInput = parseInt(siblingCheck.rows[0].total_input, 10) || 0;
const incompleteCount = parseInt(siblingCheck.rows[0].incomplete_count, 10) || 0;
const remainingAcceptable = prevGoodQty - totalInput;
// 모든 분할 행 완료 + 잔여 접수가능 0 -> 원본(마스터)도 completed
@ -1111,7 +1111,7 @@ export const confirmResult = async (
[wo_id, prevSeq, companyCode]
);
if (prevProcess.rowCount > 0) {
prevGoodQty = prevProcess.rows[0].total_good;
prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0;
}
}
@ -1125,8 +1125,8 @@ export const confirmResult = async (
[wo_id, seq_no, companyCode]
);
const totalInput = siblingCheck.rows[0].total_input;
const incompleteCount = parseInt(siblingCheck.rows[0].incomplete_count, 10);
const totalInput = parseInt(siblingCheck.rows[0].total_input, 10) || 0;
const incompleteCount = parseInt(siblingCheck.rows[0].incomplete_count, 10) || 0;
const remainingAcceptable = prevGoodQty - totalInput;
if (incompleteCount === 0 && remainingAcceptable <= 0) {
@ -1183,7 +1183,8 @@ export const getResultHistory = async (
try {
const companyCode = req.user!.companyCode;
const { work_order_process_id } = req.query;
const rawWopId = req.query.work_order_process_id;
const work_order_process_id = Array.isArray(rawWopId) ? rawWopId[0] : rawWopId;
if (!work_order_process_id) {
return res.status(400).json({
@ -1270,7 +1271,8 @@ export const getAvailableQty = async (req: AuthenticatedRequest, res: Response)
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const { work_order_process_id } = req.query;
const rawWopId = req.query.work_order_process_id;
const work_order_process_id = Array.isArray(rawWopId) ? rawWopId[0] : rawWopId;
if (!work_order_process_id) {
return res.status(400).json({
@ -1304,7 +1306,7 @@ export const getAvailableQty = async (req: AuthenticatedRequest, res: Response)
AND parent_process_id IS NOT NULL`,
[wo_id, seq_no, companyCode]
);
const myInputQty = totalAccepted.rows[0].total_input;
const myInputQty = parseInt(totalAccepted.rows[0].total_input, 10) || 0;
// 앞공정 양품+특채 합산
let prevGoodQty = instrQty;
@ -1317,7 +1319,7 @@ export const getAvailableQty = async (req: AuthenticatedRequest, res: Response)
[wo_id, prevSeq, companyCode]
);
if (prevProcess.rowCount > 0) {
prevGoodQty = prevProcess.rows[0].total_good;
prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0;
}
}
@ -1427,7 +1429,7 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) =>
[row.wo_id, prevSeq, companyCode]
);
if (prevProcess.rowCount > 0) {
prevGoodQty = prevProcess.rows[0].total_good;
prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0;
}
}
@ -1517,7 +1519,7 @@ export const cancelAccept = async (
const current = await pool.query(
`SELECT id, status, input_qty, total_production_qty, result_status,
parent_process_id, wo_id, seq_no
parent_process_id, wo_id, seq_no, process_name
FROM work_order_process
WHERE id = $1 AND company_code = $2`,
[work_order_process_id, companyCode]

View File

@ -7,7 +7,6 @@ import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "luc
import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition } from "@/types/screen";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
import { useAuth } from "@/hooks/useAuth";
@ -62,7 +61,8 @@ function PopScreenViewPage() {
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
const screenId = parseInt(params.screenId as string);
const screenId = parseInt(params.screenId as string, 10);
const isValidScreenId = !isNaN(screenId) && screenId > 0;
const isPreviewMode = searchParams.get("preview") === "true";
@ -126,22 +126,15 @@ function PopScreenViewPage() {
if (popLayout && isPopLayout(popLayout)) {
const v6Layout = loadLegacyLayout(popLayout);
setLayout(v6Layout);
const componentCount = Object.keys(popLayout.components).length;
console.log(`[POP] v5 레이아웃 로드됨: ${componentCount}개 컴포넌트`);
} else if (popLayout) {
// 다른 버전 레이아웃은 빈 v5로 처리
console.log("[POP] 레거시 레이아웃 감지, 빈 레이아웃으로 시작합니다:", popLayout.version);
setLayout(createEmptyLayout());
} else {
console.log("[POP] 레이아웃 없음");
setLayout(createEmptyLayout());
}
} catch (layoutError) {
console.warn("[POP] 레이아웃 로드 실패:", layoutError);
} catch {
setLayout(createEmptyLayout());
}
} catch (error) {
console.error("[POP] 화면 로드 실패:", error);
setError("화면을 불러오는데 실패했습니다.");
showErrorToast("POP 화면을 불러오는 데 실패했습니다", error, { guidance: "화면 설정을 확인하거나 잠시 후 다시 시도해 주세요." });
} finally {
@ -149,10 +142,13 @@ function PopScreenViewPage() {
}
};
if (screenId) {
if (isValidScreenId) {
loadScreen();
} else if (params.screenId) {
setError("유효하지 않은 화면 ID입니다.");
setLoading(false);
}
}, [screenId]);
}, [screenId, isValidScreenId]);
// 뷰어 모드에서도 컴포넌트 크기 변경 지원 (더보기 등)
const handleRequestResize = React.useCallback((componentId: string, newRowSpan: number, newColSpan?: number) => {

View File

@ -8,7 +8,6 @@
*/
import React, { useEffect, useState, useRef, useMemo, useCallback } from "react";
import { useRouter } from "next/navigation";
import {
Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Trash2, Check, X,
} from "lucide-react";
@ -55,6 +54,10 @@ import type { PopLayoutData } from "@/components/pop/designer/types/pop-layout";
import { isPopLayout, detectGridMode } from "@/components/pop/designer/types/pop-layout";
import dynamic from "next/dynamic";
const PopViewerWithModals = dynamic(() => import("@/components/pop/viewer/PopViewerWithModals"), { ssr: false });
const LazyPopWorkDetail = dynamic(
() => import("../pop-work-detail/PopWorkDetailComponent").then((m) => ({ default: m.PopWorkDetailComponent })),
{ ssr: false },
);
type RowData = Record<string, unknown>;
@ -140,7 +143,6 @@ export function PopCardListV2Component({
onRequestResize,
}: PopCardListV2ComponentProps) {
const { subscribe, publish, setSharedData } = usePopEvent(screenId || "default");
const router = useRouter();
const { userId: currentUserId } = useAuth();
const isCartListMode = config?.cartListMode?.enabled === true;
@ -243,6 +245,10 @@ export function PopCardListV2Component({
const [selectedRowIds, setSelectedRowIds] = useState<Set<string>>(new Set());
const [selectProcessing, setSelectProcessing] = useState(false);
// ===== 내장 작업 상세 모달 =====
const [workDetailOpen, setWorkDetailOpen] = useState(false);
const [workDetailRow, setWorkDetailRow] = useState<RowData | null>(null);
// ===== 모달 열기 (POP 화면) =====
const [popModalOpen, setPopModalOpen] = useState(false);
const [popModalLayout, setPopModalLayout] = useState<PopLayoutData | null>(null);
@ -280,6 +286,14 @@ export function PopCardListV2Component({
const handleCardSelect = useCallback((row: RowData) => {
if (row.__isAcceptClone) return;
if (effectiveConfig?.cardClickAction === "built-in-work-detail") {
const subStatus = row[VIRTUAL_SUB_STATUS] as string | undefined;
if (subStatus && subStatus !== "in_progress") return;
setWorkDetailRow(row);
setWorkDetailOpen(true);
return;
}
if (effectiveConfig?.cardClickAction === "modal-open" && effectiveConfig?.cardClickModalConfig?.screenId) {
const mc = effectiveConfig.cardClickModalConfig;
@ -693,6 +707,7 @@ export function PopCardListV2Component({
const scrollAreaRef = useRef<HTMLDivElement>(null);
const ownerSortColumn = config?.ownerSortColumn;
const ownerFilterMode = config?.ownerFilterMode || "priority";
const displayCards = useMemo(() => {
let source = filteredRows;
@ -707,13 +722,13 @@ export function PopCardListV2Component({
others.push(row);
}
}
source = [...mine, ...others];
source = ownerFilterMode === "only" ? mine : [...mine, ...others];
}
if (!isExpanded) return source.slice(0, visibleCardCount);
const start = (currentPage - 1) * expandedCardsPerPage;
return source.slice(start, start + expandedCardsPerPage);
}, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage, ownerSortColumn, currentUserId]);
}, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage, ownerSortColumn, ownerFilterMode, currentUserId]);
const totalPages = isExpanded ? Math.ceil(filteredRows.length / expandedCardsPerPage) : 1;
const needsPagination = isExpanded && totalPages > 1;
@ -1091,15 +1106,15 @@ export function PopCardListV2Component({
useEffect(() => {
if (isCartListMode) {
const cartListMode = config!.cartListMode!;
if (!cartListMode.sourceScreenId) { setLoading(false); setRows([]); return; }
const cartListMode = config?.cartListMode;
if (!cartListMode?.sourceScreenId) { setLoading(false); setRows([]); return; }
const fetchCartData = async () => {
setLoading(true);
setError(null);
try {
try {
const layoutJson = await screenApi.getLayoutPop(cartListMode.sourceScreenId!);
const layoutJson = await screenApi.getLayoutPop(cartListMode.sourceScreenId);
const componentsMap = layoutJson?.components || {};
const componentList = Object.values(componentsMap) as any[];
const matched = cartListMode.sourceComponentId
@ -1375,6 +1390,27 @@ export function PopCardListV2Component({
</>
)}
{/* 내장 작업 상세 모달 (풀스크린) */}
<Dialog open={workDetailOpen} onOpenChange={(open) => {
setWorkDetailOpen(open);
if (!open) setWorkDetailRow(null);
}}>
<DialogContent className="flex h-dvh w-screen max-w-none flex-col gap-0 rounded-none border-none p-0 [&>button]:z-50">
<DialogHeader className="flex shrink-0 flex-row items-center justify-between border-b px-4 py-2">
<DialogTitle className="text-base"> </DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto">
{workDetailRow && (
<LazyPopWorkDetail
parentRow={workDetailRow}
config={effectiveConfig?.workDetailConfig}
screenId={screenId}
/>
)}
</div>
</DialogContent>
</Dialog>
{/* POP 화면 모달 (풀스크린) */}
<Dialog open={popModalOpen} onOpenChange={(open) => {
setPopModalOpen(open);

View File

@ -48,7 +48,6 @@ import type {
CardSortConfig,
V2OverflowConfig,
V2CardClickAction,
V2CardClickModalConfig,
ActionButtonUpdate,
TimelineDataSource,
StatusValueMapping,
@ -117,37 +116,35 @@ const V2_DEFAULT_CONFIG: PopCardListV2Config = {
cardGap: 8,
scrollDirection: "vertical",
overflow: { mode: "loadMore", visibleCount: 6, loadMoreCount: 6 },
cardClickAction: "none",
cardClickAction: "modal-open",
};
// ===== 탭 정의 =====
type V2ConfigTab = "data" | "design" | "actions";
type V2ConfigTab = "info" | "actions";
const TAB_LABELS: { id: V2ConfigTab; label: string }[] = [
{ id: "data", label: "데이터" },
{ id: "design", label: "카드 디자인" },
{ id: "info", label: "정보" },
{ id: "actions", label: "동작" },
];
// ===== 셀 타입 라벨 =====
const V2_CELL_TYPE_LABELS: Record<CardCellType, { label: string; group: string }> = {
const V2_CELL_TYPE_LABELS: Record<string, { label: string; group: string }> = {
text: { label: "텍스트", group: "기본" },
field: { label: "필드 (라벨+값)", group: "기본" },
image: { label: "이미지", group: "기본" },
badge: { label: "배지", group: "기본" },
button: { label: "버튼", group: "동작" },
"number-input": { label: "숫자 입력", group: "입력" },
"cart-button": { label: "담기 버튼", group: "입력" },
"package-summary": { label: "포장 요약", group: "요약" },
"status-badge": { label: "상태 배지", group: "표시" },
timeline: { label: "타임라인", group: "표시" },
"footer-status": { label: "하단 상태", group: "표시" },
"action-buttons": { label: "액션 버튼", group: "동작" },
"process-qty-summary": { label: "공정 수량 요약", group: "표시" },
"mes-process-card": { label: "MES 공정 카드", group: "표시" },
};
const CELL_TYPE_GROUPS = ["기본", "표시", "입력", "동작", "요약"] as const;
const CELL_TYPE_GROUPS = ["기본", "표시", "입력", "동작"] as const;
// ===== 그리드 유틸 =====
@ -197,10 +194,8 @@ const shortType = (t: string): string => {
// ===== 메인 컴포넌트 =====
export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps) {
const [tab, setTab] = useState<V2ConfigTab>("data");
const [tables, setTables] = useState<TableInfo[]>([]);
const [tab, setTab] = useState<V2ConfigTab>("info");
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [selectedColumns, setSelectedColumns] = useState<string[]>([]);
const cfg: PopCardListV2Config = {
...V2_DEFAULT_CONFIG,
@ -215,28 +210,12 @@ export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps)
};
useEffect(() => {
fetchTableList()
.then(setTables)
.catch(() => setTables([]));
}, []);
useEffect(() => {
if (!cfg.dataSource.tableName) {
setColumns([]);
return;
}
if (!cfg.dataSource.tableName) { setColumns([]); return; }
fetchTableColumns(cfg.dataSource.tableName)
.then(setColumns)
.catch(() => setColumns([]));
}, [cfg.dataSource.tableName]);
useEffect(() => {
if (cfg.selectedColumns && cfg.selectedColumns.length > 0) {
setSelectedColumns(cfg.selectedColumns);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cfg.dataSource.tableName]);
return (
<div className="flex flex-col gap-3">
{/* 탭 바 */}
@ -257,56 +236,142 @@ export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps)
))}
</div>
{/* 탭 컨텐츠 */}
{tab === "data" && (
<TabData
cfg={cfg}
tables={tables}
columns={columns}
selectedColumns={selectedColumns}
onTableChange={(tableName) => {
setSelectedColumns([]);
update({
dataSource: { ...cfg.dataSource, tableName },
selectedColumns: [],
cardGrid: { ...cfg.cardGrid, cells: [] },
});
}}
onColumnsChange={(cols) => {
setSelectedColumns(cols);
update({ selectedColumns: cols });
}}
onDataSourceChange={(dataSource) => update({ dataSource })}
onSortChange={(sort) =>
update({ dataSource: { ...cfg.dataSource, sort } })
}
/>
)}
{tab === "design" && (
<TabCardDesign
cfg={cfg}
columns={columns}
selectedColumns={selectedColumns}
tables={tables}
onGridChange={(cardGrid) => update({ cardGrid })}
onGridColumnsChange={(gridColumns) => update({ gridColumns })}
onCardGapChange={(cardGap) => update({ cardGap })}
/>
)}
{tab === "info" && <TabInfo cfg={cfg} onUpdate={update} />}
{tab === "actions" && (
<TabActions
cfg={cfg}
onUpdate={update}
columns={columns}
/>
<TabActions cfg={cfg} onUpdate={update} columns={columns} />
)}
</div>
);
}
// ===== 탭 1: 데이터 =====
// ===== 탭 1: 정보 (연결 흐름 요약) =====
function TabInfo({
cfg,
onUpdate,
}: {
cfg: PopCardListV2Config;
onUpdate: (partial: Partial<PopCardListV2Config>) => void;
}) {
const ds = cfg.dataSource;
const joins = ds.joins || [];
const clickAction = cfg.cardClickAction || "none";
const cellTypes = cfg.cardGrid.cells.map((c) => c.type);
const hasTimeline = cellTypes.includes("timeline");
const hasActionButtons = cellTypes.includes("action-buttons");
const currentCols = cfg.gridColumns || 3;
return (
<div className="space-y-3">
{/* 카드 열 수 (편집 가능) */}
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 flex gap-1">
{[1, 2, 3, 4].map((n) => (
<button
key={n}
type="button"
onClick={() => onUpdate({ gridColumns: n })}
className={cn(
"flex-1 rounded border py-1.5 text-xs font-medium transition-colors",
currentCols === n
? "border-primary bg-primary/10 text-primary"
: "border-border hover:bg-muted"
)}
>
{n}
</button>
))}
</div>
</div>
{/* 데이터 소스 */}
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<div className="mt-1 rounded border bg-muted/10 p-2 space-y-1">
{ds.tableName ? (
<>
<div className="text-xs font-medium">{ds.tableName}</div>
{joins.map((j, i) => (
<div key={i} className="flex items-center gap-1 text-[10px] text-muted-foreground">
<span className="text-[8px]">+</span>
<span>{j.targetTable}</span>
<span className="text-[8px]">({j.joinType})</span>
</div>
))}
{ds.sort?.[0] && (
<div className="text-[10px] text-muted-foreground">
: {ds.sort[0].column} ({ds.sort[0].direction === "asc" ? "오름차순" : "내림차순"})
</div>
)}
</>
) : (
<span className="text-[10px] text-muted-foreground"> </span>
)}
</div>
</div>
{/* 카드 구성 */}
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<div className="mt-1 rounded border bg-muted/10 p-2 space-y-1 text-[10px]">
<div>{cfg.cardGrid.rows} x {cfg.cardGrid.cols} , {cfg.cardGrid.cells.length}</div>
<div className="flex flex-wrap gap-1 mt-1">
{hasTimeline && (
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-[9px] text-blue-700"></span>
)}
{hasActionButtons && (
<span className="rounded bg-green-100 px-1.5 py-0.5 text-[9px] text-green-700"> </span>
)}
{cellTypes.includes("status-badge") && (
<span className="rounded bg-purple-100 px-1.5 py-0.5 text-[9px] text-purple-700"> </span>
)}
{cellTypes.includes("number-input") && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] text-amber-700"> </span>
)}
{cellTypes.filter((t) => t === "field" || t === "text").length > 0 && (
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[9px] text-gray-700">
/ {cellTypes.filter((t) => t === "field" || t === "text").length}
</span>
)}
</div>
</div>
</div>
{/* 동작 흐름 */}
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<div className="mt-1 rounded border bg-muted/10 p-2 text-[10px]">
{clickAction === "none" && (
<span className="text-muted-foreground"> </span>
)}
{clickAction === "modal-open" && (
<div className="space-y-0.5">
<div className="font-medium"> </div>
{cfg.cardClickModalConfig?.screenId ? (
<div className="text-muted-foreground">
: {cfg.cardClickModalConfig.screenId}
{cfg.cardClickModalConfig.modalTitle && ` (${cfg.cardClickModalConfig.modalTitle})`}
</div>
) : (
<div className="text-muted-foreground"> - </div>
)}
</div>
)}
{clickAction === "built-in-work-detail" && (
<div className="space-y-0.5">
<div className="font-medium"> ()</div>
<div className="text-muted-foreground">(in_progress) </div>
</div>
)}
</div>
</div>
</div>
);
}
// ===== (레거시) 탭: 데이터 =====
function TabData({
cfg,
@ -1414,7 +1479,7 @@ function CellDetailEditor({
<SelectTrigger className={cn("h-7 text-[10px]", cell.type === "action-buttons" ? "flex-1" : "w-24")}><SelectValue /></SelectTrigger>
<SelectContent>
{CELL_TYPE_GROUPS.map((group) => {
const types = (Object.entries(V2_CELL_TYPE_LABELS) as [CardCellType, { label: string; group: string }][]).filter(([, v]) => v.group === group);
const types = Object.entries(V2_CELL_TYPE_LABELS).filter(([, v]) => v.group === group);
if (types.length === 0) return null;
return (
<Fragment key={group}>
@ -2942,9 +3007,9 @@ function TabActions({
columns: ColumnInfo[];
}) {
const designerCtx = usePopDesignerContext();
const overflow = cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 };
const clickAction = cfg.cardClickAction || "none";
const modalConfig = cfg.cardClickModalConfig || { screenId: "" };
const [advancedOpen, setAdvancedOpen] = useState(false);
const [processColumns, setProcessColumns] = useState<ColumnInfo[]>([]);
const timelineCell = cfg.cardGrid?.cells?.find((c) => c.type === "timeline" && c.timelineSource?.processTable);
@ -2971,31 +3036,11 @@ function TabActions({
return (
<div className="space-y-3">
{/* 소유자 우선 정렬 */}
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 flex items-center gap-1">
<Select
value={cfg.ownerSortColumn || "__none__"}
onValueChange={(v) => onUpdate({ ownerSortColumn: v === "__none__" ? undefined : v })}
>
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="사용 안 함" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-[10px]"> </SelectItem>
{renderColumnOptionGroups(ownerColumnGroups)}
</SelectContent>
</Select>
</div>
<p className="mt-0.5 text-[9px] text-muted-foreground">
</p>
</div>
{/* 카드 선택 시 */}
{/* 카드 선택 시 동작 */}
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 space-y-1">
{(["none", "publish", "navigate", "modal-open"] as V2CardClickAction[]).map((action) => (
{(["none", "modal-open", "built-in-work-detail"] as V2CardClickAction[]).map((action) => (
<label key={action} className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted/50">
<input
type="radio"
@ -3006,16 +3051,16 @@ function TabActions({
/>
<span className="text-xs">
{action === "none" && "없음"}
{action === "publish" && "상세 데이터 전달 (다른 컴포넌트 연결)"}
{action === "navigate" && "화면 이동"}
{action === "modal-open" && "모달 열기"}
{action === "built-in-work-detail" && "작업 상세 (내장)"}
</span>
</label>
))}
</div>
{/* 모달 열기 설정 */}
{clickAction === "modal-open" && (
<div className="mt-2 space-y-1.5 rounded border bg-muted/20 p-2">
{/* 모달 캔버스 (디자이너 모드) */}
{designerCtx && (
<div>
{modalConfig.screenId?.startsWith("modal-") ? (
@ -3049,7 +3094,6 @@ function TabActions({
)}
</div>
)}
{/* 뷰어 모드 또는 직접 입력 폴백 */}
{!designerCtx && (
<div className="flex items-center gap-1">
<span className="w-16 shrink-0 text-[9px] text-muted-foreground"> ID</span>
@ -3122,118 +3166,111 @@ function TabActions({
)}
</div>
)}
{/* 작업 상세 내장 모드 안내 */}
{clickAction === "built-in-work-detail" && (
<p className="mt-2 text-[9px] text-muted-foreground rounded border bg-muted/20 p-2">
.
(in_progress) .
.
</p>
)}
</div>
{/* 필터 전 비표시 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={!!cfg.hideUntilFiltered}
onCheckedChange={(checked) => onUpdate({ hideUntilFiltered: checked })}
/>
</div>
{cfg.hideUntilFiltered && (
<p className="text-[9px] text-muted-foreground -mt-2 pl-1">
.
{/* 내 작업 표시 모드 */}
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 flex gap-1">
{([
{ value: "off", label: "전체 보기" },
{ value: "priority", label: "우선 표시" },
{ value: "only", label: "내 작업만" },
] as const).map((opt) => {
const current = !cfg.ownerSortColumn
? "off"
: cfg.ownerFilterMode === "only"
? "only"
: "priority";
return (
<button
key={opt.value}
type="button"
onClick={() => {
if (opt.value === "off") {
onUpdate({ ownerSortColumn: undefined, ownerFilterMode: undefined });
} else {
onUpdate({ ownerSortColumn: "worker", ownerFilterMode: opt.value });
}
}}
className={cn(
"flex-1 rounded border py-1.5 text-[10px] font-medium transition-colors",
current === opt.value
? "border-primary bg-primary/10 text-primary"
: "border-border hover:bg-muted"
)}
>
{opt.label}
</button>
);
})}
</div>
<p className="mt-1 text-[9px] text-muted-foreground">
{!cfg.ownerSortColumn
? "모든 작업자의 카드가 동일하게 표시됩니다"
: cfg.ownerFilterMode === "only"
? "내가 담당인 작업만 표시되고, 다른 작업은 숨겨집니다"
: "내가 담당인 작업이 상단에 표시되고, 다른 작업은 비활성화로 표시됩니다"}
</p>
)}
{/* 스크롤 방향 */}
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 flex gap-1">
{(["vertical", "horizontal"] as const).map((dir) => (
<button
key={dir}
type="button"
onClick={() => onUpdate({ scrollDirection: dir })}
className={cn(
"flex-1 rounded border py-1 text-xs transition-colors",
(cfg.scrollDirection || "vertical") === dir
? "border-primary bg-primary/10 text-primary"
: "border-border hover:bg-muted"
)}
>
{dir === "vertical" ? "세로" : "가로"}
</button>
))}
</div>
</div>
{/* 오버플로우 */}
{/* 고급 설정 (접이식) */}
<div>
<Label className="text-xs"></Label>
<div className="mt-1 flex gap-1">
{(["loadMore", "pagination"] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => onUpdate({ overflow: { ...overflow, mode } })}
className={cn(
"flex-1 rounded border py-1 text-xs transition-colors",
overflow.mode === mode
? "border-primary bg-primary/10 text-primary"
: "border-border hover:bg-muted"
)}
>
{mode === "loadMore" ? "더보기" : "페이지네이션"}
</button>
))}
</div>
<div className="mt-2 space-y-2">
<div>
<Label className="text-[10px]"> </Label>
<Input
type="number"
min={1}
max={50}
value={overflow.visibleCount}
onChange={(e) => onUpdate({ overflow: { ...overflow, visibleCount: Number(e.target.value) || 6 } })}
className="mt-0.5 h-7 text-[10px]"
/>
</div>
{overflow.mode === "loadMore" && (
<button
type="button"
onClick={() => setAdvancedOpen(!advancedOpen)}
className="flex w-full items-center gap-1 text-xs font-medium text-muted-foreground"
>
{advancedOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
</button>
{advancedOpen && (
<div className="mt-2 space-y-3 rounded border bg-muted/10 p-2">
{/* 필터 전 비표시 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={!!cfg.hideUntilFiltered}
onCheckedChange={(checked) => onUpdate({ hideUntilFiltered: checked })}
/>
</div>
{cfg.hideUntilFiltered && (
<p className="text-[9px] text-muted-foreground -mt-2 pl-1">
.
</p>
)}
{/* 기본 표시 수 */}
<div>
<Label className="text-[10px]"> </Label>
<Label className="text-xs"> </Label>
<Input
type="number"
min={1}
max={50}
value={overflow.loadMoreCount ?? 6}
onChange={(e) => onUpdate({ overflow: { ...overflow, loadMoreCount: Number(e.target.value) || 6 } })}
value={(cfg.overflow || { visibleCount: 6 }).visibleCount}
onChange={(e) => onUpdate({
overflow: {
...(cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 }),
visibleCount: Number(e.target.value) || 6,
},
})}
className="mt-0.5 h-7 text-[10px]"
/>
<p className="mt-0.5 text-[9px] text-muted-foreground">
(기본: 6개)
</p>
</div>
)}
{overflow.mode === "pagination" && (
<div>
<Label className="text-[10px]"> </Label>
<Input
type="number"
min={1}
max={100}
value={overflow.pageSize ?? overflow.visibleCount}
onChange={(e) => onUpdate({ overflow: { ...overflow, pageSize: Number(e.target.value) || 6 } })}
className="mt-0.5 h-7 text-[10px]"
/>
</div>
)}
</div>
</div>
{/* 장바구니 */}
<div className="flex items-center justify-between">
<Label className="text-xs">() </Label>
<Switch
checked={!!cfg.cartAction}
onCheckedChange={(checked) => {
if (checked) {
onUpdate({ cartAction: { saveMode: "cart", label: "담기", cancelLabel: "취소" } });
} else {
onUpdate({ cartAction: undefined });
}
}}
/>
</div>
)}
</div>
</div>
);

View File

@ -679,6 +679,7 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode,
e.stopPropagation();
const actions = (btn.clickActions && btn.clickActions.length > 0) ? btn.clickActions : [btn.clickAction];
const firstAction = actions[0];
if (!firstAction) return;
const config: Record<string, unknown> = {
...firstAction,
@ -1263,14 +1264,15 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C
function ProcessFlowStrip({ steps, currentIdx, instrQty }: {
steps: TimelineProcessStep[]; currentIdx: number; instrQty: number;
}) {
const prevStep = currentIdx > 0 ? steps[currentIdx - 1] : null;
const currStep = steps[currentIdx];
const nextStep = currentIdx < steps.length - 1 ? steps[currentIdx + 1] : null;
const safeIdx = currentIdx >= 0 && currentIdx < steps.length ? currentIdx : -1;
const prevStep = safeIdx > 0 ? steps[safeIdx - 1] : null;
const currStep = safeIdx >= 0 ? steps[safeIdx] : null;
const nextStep = safeIdx >= 0 && safeIdx < steps.length - 1 ? steps[safeIdx + 1] : null;
const hiddenBefore = currentIdx > 1 ? currentIdx - 1 : 0;
const hiddenAfter = currentIdx < steps.length - 2 ? steps.length - currentIdx - 2 : 0;
const hiddenBefore = safeIdx > 1 ? safeIdx - 1 : 0;
const hiddenAfter = safeIdx >= 0 && safeIdx < steps.length - 2 ? steps.length - safeIdx - 2 : 0;
const allBeforeDone = hiddenBefore > 0 && steps.slice(0, currentIdx - 1).every(s => {
const allBeforeDone = hiddenBefore > 0 && safeIdx > 1 && steps.slice(0, safeIdx - 1).every(s => {
const sem = s.semantic || LEGACY_STATUS_TO_SEMANTIC[s.status] || "pending";
return sem === "done";
});

View File

@ -4,7 +4,7 @@ import React, { useEffect, useState, useMemo, useCallback, useRef } from "react"
import {
Loader2, Play, Pause, CheckCircle2, AlertCircle, Timer, Package,
ChevronLeft, ChevronRight, Check, X, CircleDot, ClipboardList,
Plus, Trash2, Save, FileCheck,
Plus, Trash2, Save, FileCheck, Construction,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@ -17,7 +17,7 @@ import { dataApi } from "@/lib/api/data";
import { apiClient } from "@/lib/api/client";
import { usePopEvent } from "@/hooks/pop/usePopEvent";
import { useAuth } from "@/hooks/useAuth";
import type { PopWorkDetailConfig, ResultSectionConfig } from "../types";
import type { PopWorkDetailConfig, ResultSectionConfig, ResultSectionType } from "../types";
import type { TimelineProcessStep } from "../types";
// ========================================
@ -119,12 +119,18 @@ const DEFAULT_INFO_FIELDS = [
const DEFAULT_CFG: PopWorkDetailConfig = {
showTimer: true,
showQuantityInput: true,
showQuantityInput: false,
displayMode: "list",
phaseLabels: { PRE: "작업 전", IN: "작업 중", POST: "작업 후" },
infoBar: { enabled: true, fields: [] },
stepControl: { requireStartBeforeInput: false, autoAdvance: true },
navigation: { showPrevNext: true, showCompleteButton: true },
resultSections: [
{ id: "total-qty", type: "total-qty", enabled: true, showCondition: { type: "always" } },
{ id: "good-defect", type: "good-defect", enabled: true, showCondition: { type: "always" } },
{ id: "defect-types", type: "defect-types", enabled: true, showCondition: { type: "always" } },
{ id: "note", type: "note", enabled: true, showCondition: { type: "always" } },
],
};
// ========================================
@ -137,6 +143,7 @@ interface PopWorkDetailComponentProps {
componentId?: string;
currentRowSpan?: number;
currentColSpan?: number;
parentRow?: RowData;
}
// ========================================
@ -146,6 +153,7 @@ interface PopWorkDetailComponentProps {
export function PopWorkDetailComponent({
config,
screenId,
parentRow: parentRowProp,
}: PopWorkDetailComponentProps) {
const { getSharedData, publish } = usePopEvent(screenId || "default");
const { user } = useAuth();
@ -160,7 +168,7 @@ export function PopWorkDetailComponent({
phaseLabels: { ...DEFAULT_CFG.phaseLabels, ...config?.phaseLabels },
};
const parentRow = getSharedData<RowData>("parentRow");
const parentRow = parentRowProp ?? getSharedData<RowData>("parentRow");
const processFlow = parentRow?.__processFlow__ as TimelineProcessStep[] | undefined;
const currentProcess = processFlow?.find((p) => p.isCurrent);
const workOrderProcessId = parentRow?.__splitProcessId
@ -869,7 +877,7 @@ export function PopWorkDetailComponent({
<div
className="h-full bg-primary transition-all duration-300"
style={{
width: `${currentItems.length > 0 ? ((selectedGroup.completed / selectedGroup.total) * 100) : 0}%`,
width: `${currentItems.length > 0 && selectedGroup.total > 0 ? ((selectedGroup.completed / selectedGroup.total) * 100) : 0}%`,
}}
/>
</div>
@ -1073,6 +1081,22 @@ interface BatchHistoryItem {
changed_by: string | null;
}
const IMPLEMENTED_SECTIONS = new Set<ResultSectionType>(["total-qty", "good-defect", "defect-types", "note"]);
const SECTION_LABELS: Record<ResultSectionType, string> = {
"total-qty": "생산수량",
"good-defect": "양품/불량",
"defect-types": "불량 유형 상세",
"note": "비고",
"box-packing": "박스 포장",
"label-print": "라벨 출력",
"photo": "사진",
"document": "문서",
"material-input": "자재 투입",
"barcode-scan": "바코드 스캔",
"plc-data": "PLC 데이터",
};
interface ResultPanelProps {
workOrderProcessId: string;
processData: ProcessTimerData | null;
@ -1467,6 +1491,19 @@ function ResultPanel({
</div>
)}
{/* 미구현 섹션 플레이스홀더 (순서 보존) */}
{enabledSections
.filter((s) => !IMPLEMENTED_SECTIONS.has(s.type))
.map((s) => (
<div key={s.id} className="flex items-center gap-3 rounded-lg border border-dashed p-4">
<Construction className="h-5 w-5 shrink-0 text-muted-foreground" />
<div>
<p className="text-sm font-medium">{SECTION_LABELS[s.type] ?? s.type}</p>
<p className="text-xs text-muted-foreground"> </p>
</div>
</div>
))}
{/* 등록 버튼 */}
<div className="flex items-center gap-3">
<Button
@ -1872,8 +1909,8 @@ function InspectSelect({ item, disabled, saving, onSave }: { item: WorkResultRow
try {
const parsed = JSON.parse(item.spec_value ?? "{}");
options = parsed.options ?? [];
passValues = parsed.passValues ?? [];
options = Array.isArray(parsed.options) ? parsed.options : [];
passValues = Array.isArray(parsed.passValues) ? parsed.passValues : [];
} catch {
options = (item.spec_value ?? "").split(",").filter(Boolean);
}

View File

@ -6,14 +6,30 @@ import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Trash2 } from "lucide-react";
import type { PopWorkDetailConfig, WorkDetailInfoBarField } from "../types";
import { Plus, Trash2, ChevronUp, ChevronDown } from "lucide-react";
import type { PopWorkDetailConfig, WorkDetailInfoBarField, ResultSectionConfig, ResultSectionType } from "../types";
interface PopWorkDetailConfigPanelProps {
config?: PopWorkDetailConfig;
onChange?: (config: PopWorkDetailConfig) => void;
}
const SECTION_TYPE_META: Record<ResultSectionType, { label: string }> = {
"total-qty": { label: "생산수량" },
"good-defect": { label: "양품/불량" },
"defect-types": { label: "불량 유형 상세" },
"note": { label: "비고" },
"box-packing": { label: "박스 포장" },
"label-print": { label: "라벨 출력" },
"photo": { label: "사진" },
"document": { label: "문서" },
"material-input": { label: "자재 투입" },
"barcode-scan": { label: "바코드 스캔" },
"plc-data": { label: "PLC 데이터" },
};
const ALL_SECTION_TYPES = Object.keys(SECTION_TYPE_META) as ResultSectionType[];
const DEFAULT_PHASE_LABELS: Record<string, string> = {
PRE: "작업 전",
IN: "작업 중",
@ -41,12 +57,13 @@ export function PopWorkDetailConfigPanel({
}: PopWorkDetailConfigPanelProps) {
const cfg: PopWorkDetailConfig = {
showTimer: config?.showTimer ?? true,
showQuantityInput: config?.showQuantityInput ?? true,
showQuantityInput: config?.showQuantityInput ?? false,
displayMode: config?.displayMode ?? "list",
phaseLabels: config?.phaseLabels ?? { ...DEFAULT_PHASE_LABELS },
infoBar: config?.infoBar ?? { ...DEFAULT_INFO_BAR },
stepControl: config?.stepControl ?? { ...DEFAULT_STEP_CONTROL },
navigation: config?.navigation ?? { ...DEFAULT_NAVIGATION },
resultSections: config?.resultSections ?? [],
};
const update = (partial: Partial<PopWorkDetailConfig>) => {
@ -69,6 +86,40 @@ export function PopWorkDetailConfigPanel({
update({ infoBar: { ...cfg.infoBar, fields } });
};
// --- 실적 입력 섹션 관리 ---
const sections = cfg.resultSections ?? [];
const usedTypes = new Set(sections.map((s) => s.type));
const availableTypes = ALL_SECTION_TYPES.filter((t) => !usedTypes.has(t));
const updateSections = (next: ResultSectionConfig[]) => {
update({ resultSections: next });
};
const addSection = (type: ResultSectionType) => {
updateSections([
...sections,
{ id: type, type, enabled: true, showCondition: { type: "always" } },
]);
};
const removeSection = (idx: number) => {
updateSections(sections.filter((_, i) => i !== idx));
};
const toggleSection = (idx: number, enabled: boolean) => {
const next = [...sections];
next[idx] = { ...next[idx], enabled };
updateSections(next);
};
const moveSection = (idx: number, dir: -1 | 1) => {
const target = idx + dir;
if (target < 0 || target >= sections.length) return;
const next = [...sections];
[next[idx], next[target]] = [next[target], next[idx]];
updateSections(next);
};
return (
<div className="space-y-5">
{/* 기본 설정 */}
@ -86,7 +137,58 @@ export function PopWorkDetailConfigPanel({
</Select>
</div>
<ToggleRow label="타이머 표시" checked={cfg.showTimer} onChange={(v) => update({ showTimer: v })} />
<ToggleRow label="수량 입력 표시" checked={cfg.showQuantityInput} onChange={(v) => update({ showQuantityInput: v })} />
</Section>
{/* 실적 입력 섹션 */}
<Section title="실적 입력 섹션">
{sections.length === 0 ? (
<p className="text-xs text-muted-foreground py-1"> </p>
) : (
<div className="space-y-1">
{sections.map((s, i) => (
<div
key={s.id}
className="flex items-center gap-1 rounded-md border px-2 py-1"
>
<div className="flex flex-col">
<button
type="button"
className="h-3.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
disabled={i === 0}
onClick={() => moveSection(i, -1)}
>
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
className="h-3.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
disabled={i === sections.length - 1}
onClick={() => moveSection(i, 1)}
>
<ChevronDown className="h-3 w-3" />
</button>
</div>
<span className="flex-1 truncate text-xs font-medium">
{SECTION_TYPE_META[s.type]?.label ?? s.type}
</span>
<Switch
checked={s.enabled}
onCheckedChange={(v) => toggleSection(i, v)}
className="scale-75"
/>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 shrink-0"
onClick={() => removeSection(i)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{availableTypes.length > 0 && <SectionAdder types={availableTypes} onAdd={addSection} />}
</Section>
{/* 정보 바 */}
@ -163,6 +265,49 @@ export function PopWorkDetailConfigPanel({
);
}
function SectionAdder({
types,
onAdd,
}: {
types: ResultSectionType[];
onAdd: (type: ResultSectionType) => void;
}) {
const [selected, setSelected] = useState<string>("");
const handleAdd = () => {
if (!selected) return;
onAdd(selected as ResultSectionType);
setSelected("");
};
return (
<div className="flex items-center gap-1 pt-1">
<Select value={selected} onValueChange={setSelected}>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="섹션 선택" />
</SelectTrigger>
<SelectContent>
{types.map((t) => (
<SelectItem key={t} value={t} className="text-xs">
{SECTION_TYPE_META[t]?.label ?? t}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
className="h-7 shrink-0 gap-1 px-2 text-xs"
disabled={!selected}
onClick={handleAdd}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="space-y-2">

View File

@ -8,7 +8,7 @@ import type { PopWorkDetailConfig } from "../types";
const defaultConfig: PopWorkDetailConfig = {
showTimer: true,
showQuantityInput: true,
showQuantityInput: false,
displayMode: "list",
phaseLabels: { PRE: "작업 전", IN: "작업 중", POST: "작업 후" },
infoBar: {
@ -28,6 +28,12 @@ const defaultConfig: PopWorkDetailConfig = {
showPrevNext: true,
showCompleteButton: true,
},
resultSections: [
{ id: "total-qty", type: "total-qty", enabled: true, showCondition: { type: "always" } },
{ id: "good-defect", type: "good-defect", enabled: true, showCondition: { type: "always" } },
{ id: "defect-types", type: "defect-types", enabled: true, showCondition: { type: "always" } },
{ id: "note", type: "note", enabled: true, showCondition: { type: "always" } },
],
};
PopComponentRegistry.registerComponent({

View File

@ -959,7 +959,7 @@ export interface CardGridConfigV2 {
// ----- V2 카드 선택 동작 -----
export type V2CardClickAction = "none" | "publish" | "navigate" | "modal-open";
export type V2CardClickAction = "none" | "publish" | "navigate" | "modal-open" | "built-in-work-detail";
export interface V2CardClickModalConfig {
screenId: string;
@ -1004,6 +1004,8 @@ export interface PopCardListV2Config {
cartListMode?: CartListModeConfig;
saveMapping?: CardListSaveMapping;
ownerSortColumn?: string;
ownerFilterMode?: "priority" | "only";
workDetailConfig?: PopWorkDetailConfig;
}
/** 카드 컴포넌트가 하위 필터 적용 시 주입하는 가상 컬럼 키 */
@ -1045,7 +1047,10 @@ export type ResultSectionType =
| "box-packing"
| "label-print"
| "photo"
| "document";
| "document"
| "material-input"
| "barcode-scan"
| "plc-data";
export interface ResultSectionConfig {
id: string;