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:
parent
5d12bef5e5
commit
d001f82565
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue