feat: BLOCK DETAIL Phase 4 + 안정화 - 그룹별 타이머, 터치 최적화 UI, DB 저장 버그 수정

pop-work-detail 컴포넌트에 그룹별 타이머 시스템과 터치 최적화 UI를 추가하고,
체크리스트 결과가 DB에 저장되지 않던 버그를 수정하여 안정화를 완료한다.
[그룹별 타이머]
- group-timer API 신규: start/pause/resume/complete 액션 (popProductionController)
- process_work_result에 group_started_at/paused_at/total_paused_time/completed_at 활용
- GroupTimerHeader UI: 순수 작업시간 + 경과시간 이중 표시
- 첫 그룹 "시작" 시 work_order_process.started_at 자동 기록 (공정 시작 자동 감지)
- 공정 완료 시 actual_work_time을 그룹 타이머 합산으로 백엔드 자동 계산
[터치 최적화 UI]
- 12개 영역 전면 스케일업: 버튼 h-11~h-12, 입력 h-11, 체크박스 h-6 w-6
- 사이드바 w-[180px], InfoBar text-sm, 최소 터치 영역 40~44px 확보
- 산업 현장 태블릿 터치 사용 최적화
[DB 저장 버그 수정]
- saveResultValue/handleQuantityRegister: execute-action task 형식 수정
  (fixedValue + lookupMode:"manual" + manualItemField/manualPkColumn:"id")
- 원인: 백엔드가 __cart_row_key를 찾는데 프론트에서 id만 전송하여 lookup 실패
[디자이너 설정 확장]
- displayMode: list/step 전환 설정 추가
- PopWorkDetailConfig: 표시 모드 Select 드롭다운
- types.ts: PopWorkDetailConfig 인터페이스 displayMode 추가
- PopCardListV2Component: parentRow.__processFlow__ 전달 보강
This commit is contained in:
SeongHyun Kim 2026-03-17 09:32:59 +09:00
parent 230d35b03a
commit 06c52b422f
7 changed files with 1441 additions and 367 deletions

View File

@ -206,10 +206,11 @@ export const controlTimer = async (
});
}
if (!["start", "pause", "resume"].includes(action)) {
if (!["start", "pause", "resume", "complete"].includes(action)) {
return res.status(400).json({
success: false,
message: "action은 start, pause, resume 중 하나여야 합니다.",
message:
"action은 start, pause, resume, complete 중 하나여야 합니다.",
});
}
@ -262,6 +263,47 @@ export const controlTimer = async (
[work_order_process_id, companyCode]
);
break;
case "complete": {
const { good_qty, defect_qty } = req.body;
const groupSumResult = await pool.query(
`SELECT COALESCE(SUM(
CASE WHEN group_started_at IS NOT NULL AND group_completed_at IS NOT NULL THEN
EXTRACT(EPOCH FROM group_completed_at::timestamp - group_started_at::timestamp)::int
- COALESCE(group_total_paused_time::int, 0)
ELSE 0 END
), 0)::text AS total_work_seconds
FROM process_work_result
WHERE work_order_process_id = $1 AND company_code = $2`,
[work_order_process_id, companyCode]
);
const calculatedWorkTime = groupSumResult.rows[0]?.total_work_seconds || "0";
result = await pool.query(
`UPDATE work_order_process
SET status = 'completed',
completed_at = NOW()::text,
completed_by = $3,
actual_work_time = $4,
good_qty = COALESCE($5, good_qty),
defect_qty = COALESCE($6, defect_qty),
paused_at = NULL,
updated_date = NOW()
WHERE id = $1 AND company_code = $2
AND status != 'completed'
RETURNING id, status, completed_at, completed_by, actual_work_time, good_qty, defect_qty`,
[
work_order_process_id,
companyCode,
userId,
calculatedWorkTime,
good_qty || null,
defect_qty || null,
]
);
break;
}
}
if (!result || result.rowCount === 0) {
@ -289,3 +331,137 @@ export const controlTimer = async (
});
}
};
/**
* ()
* ///
*/
export const controlGroupTimer = async (
req: AuthenticatedRequest,
res: Response
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const { work_order_process_id, source_work_item_id, action } = req.body;
if (!work_order_process_id || !source_work_item_id || !action) {
return res.status(400).json({
success: false,
message:
"work_order_process_id, source_work_item_id, action은 필수입니다.",
});
}
if (!["start", "pause", "resume", "complete"].includes(action)) {
return res.status(400).json({
success: false,
message:
"action은 start, pause, resume, complete 중 하나여야 합니다.",
});
}
logger.info("[pop/production] group-timer 요청", {
companyCode,
work_order_process_id,
source_work_item_id,
action,
});
const whereClause = `work_order_process_id = $1 AND source_work_item_id = $2 AND company_code = $3`;
const baseParams = [work_order_process_id, source_work_item_id, companyCode];
let result;
switch (action) {
case "start":
result = await pool.query(
`UPDATE process_work_result
SET group_started_at = CASE WHEN group_started_at IS NULL THEN NOW()::text ELSE group_started_at END,
updated_date = NOW()
WHERE ${whereClause}
RETURNING id, group_started_at`,
baseParams
);
await pool.query(
`UPDATE work_order_process
SET started_at = NOW()::text, updated_date = NOW()
WHERE id = $1 AND company_code = $2 AND started_at IS NULL`,
[work_order_process_id, companyCode]
);
break;
case "pause":
result = await pool.query(
`UPDATE process_work_result
SET group_paused_at = NOW()::text,
updated_date = NOW()
WHERE ${whereClause} AND group_paused_at IS NULL
RETURNING id, group_paused_at`,
baseParams
);
break;
case "resume":
result = await pool.query(
`UPDATE process_work_result
SET group_total_paused_time = (
COALESCE(group_total_paused_time::int, 0)
+ EXTRACT(EPOCH FROM NOW() - group_paused_at::timestamp)::int
)::text,
group_paused_at = NULL,
updated_date = NOW()
WHERE ${whereClause} AND group_paused_at IS NOT NULL
RETURNING id, group_total_paused_time`,
baseParams
);
break;
case "complete": {
result = await pool.query(
`UPDATE process_work_result
SET group_completed_at = NOW()::text,
group_total_paused_time = CASE
WHEN group_paused_at IS NOT NULL THEN (
COALESCE(group_total_paused_time::int, 0)
+ EXTRACT(EPOCH FROM NOW() - group_paused_at::timestamp)::int
)::text
ELSE group_total_paused_time
END,
group_paused_at = NULL,
updated_date = NOW()
WHERE ${whereClause}
RETURNING id, group_started_at, group_completed_at, group_total_paused_time`,
baseParams
);
break;
}
}
if (!result || result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "대상 그룹을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.",
});
}
logger.info("[pop/production] group-timer 완료", {
action,
source_work_item_id,
affectedRows: result.rowCount,
});
return res.json({
success: true,
data: result.rows[0],
affectedRows: result.rowCount,
});
} catch (error: any) {
logger.error("[pop/production] group-timer 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "그룹 타이머 처리 중 오류가 발생했습니다.",
});
}
};

View File

@ -3,6 +3,7 @@ import { authenticateToken } from "../middleware/authMiddleware";
import {
createWorkProcesses,
controlTimer,
controlGroupTimer,
} from "../controllers/popProductionController";
const router = Router();
@ -11,5 +12,6 @@ router.use(authenticateToken);
router.post("/create-work-processes", createWorkProcesses);
router.post("/timer", controlTimer);
router.post("/group-timer", controlGroupTimer);
export default router;

View File

@ -974,6 +974,14 @@ export function PopCardListV2Component({
publish(`__comp_output__${componentId}__selected_items`, selectedItems);
}, [selectedKeys, filteredRows, componentId, isCartListMode, publish]);
// 공정 완료 이벤트 수신 시 목록 갱신
useEffect(() => {
const unsub = subscribe("process_completed", () => {
fetchDataRef.current();
});
return unsub;
}, [subscribe]);
// 카드 영역 스타일
const cardGap = effectiveConfig?.cardGap ?? spec.gap;
const cardMinHeight = spec.height;

View File

@ -1,9 +1,13 @@
"use client";
import React, { useState } from "react";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import type { PopWorkDetailConfig } from "../types";
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";
interface PopWorkDetailConfigPanelProps {
config?: PopWorkDetailConfig;
@ -16,6 +20,21 @@ const DEFAULT_PHASE_LABELS: Record<string, string> = {
POST: "작업 후",
};
const DEFAULT_INFO_BAR = {
enabled: true,
fields: [] as WorkDetailInfoBarField[],
};
const DEFAULT_STEP_CONTROL = {
requireStartBeforeInput: false,
autoAdvance: true,
};
const DEFAULT_NAVIGATION = {
showPrevNext: true,
showCompleteButton: true,
};
export function PopWorkDetailConfigPanel({
config,
onChange,
@ -23,50 +42,141 @@ export function PopWorkDetailConfigPanel({
const cfg: PopWorkDetailConfig = {
showTimer: config?.showTimer ?? true,
showQuantityInput: config?.showQuantityInput ?? true,
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 },
};
const update = (partial: Partial<PopWorkDetailConfig>) => {
onChange?.({ ...cfg, ...partial });
};
const [newFieldLabel, setNewFieldLabel] = useState("");
const [newFieldColumn, setNewFieldColumn] = useState("");
const addInfoBarField = () => {
if (!newFieldLabel || !newFieldColumn) return;
const fields = [...(cfg.infoBar.fields ?? []), { label: newFieldLabel, column: newFieldColumn }];
update({ infoBar: { ...cfg.infoBar, fields } });
setNewFieldLabel("");
setNewFieldColumn("");
};
const removeInfoBarField = (idx: number) => {
const fields = (cfg.infoBar.fields ?? []).filter((_, i) => i !== idx);
update({ infoBar: { ...cfg.infoBar, fields } });
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={cfg.showTimer}
onCheckedChange={(v) => update({ showTimer: v })}
/>
</div>
<div className="space-y-5">
{/* 기본 설정 */}
<Section title="기본 설정">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Select value={cfg.displayMode} onValueChange={(v) => update({ displayMode: v as "list" | "step" })}>
<SelectTrigger className="h-7 w-28 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="list"></SelectItem>
<SelectItem value="step"></SelectItem>
</SelectContent>
</Select>
</div>
<ToggleRow label="타이머 표시" checked={cfg.showTimer} onChange={(v) => update({ showTimer: v })} />
<ToggleRow label="수량 입력 표시" checked={cfg.showQuantityInput} onChange={(v) => update({ showQuantityInput: v })} />
</Section>
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={cfg.showQuantityInput}
onCheckedChange={(v) => update({ showQuantityInput: v })}
{/* 정보 바 */}
<Section title="작업지시 정보 바">
<ToggleRow
label="정보 바 표시"
checked={cfg.infoBar.enabled}
onChange={(v) => update({ infoBar: { ...cfg.infoBar, enabled: v } })}
/>
</div>
{cfg.infoBar.enabled && (
<div className="space-y-2 pt-1">
{(cfg.infoBar.fields ?? []).map((f, i) => (
<div key={i} className="flex items-center gap-1">
<span className="w-16 truncate text-xs text-muted-foreground">{f.label}</span>
<span className="flex-1 truncate text-xs font-mono">{f.column}</span>
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => removeInfoBarField(i)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
<div className="flex items-center gap-1">
<Input className="h-7 text-xs" placeholder="라벨" value={newFieldLabel} onChange={(e) => setNewFieldLabel(e.target.value)} />
<Input className="h-7 text-xs" placeholder="컬럼명" value={newFieldColumn} onChange={(e) => setNewFieldColumn(e.target.value)} />
<Button size="icon" variant="outline" className="h-7 w-7 shrink-0" onClick={addInfoBarField}>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
</Section>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
{/* 단계 제어 */}
<Section title="단계 제어">
<ToggleRow
label="시작 전 입력 잠금"
checked={cfg.stepControl.requireStartBeforeInput}
onChange={(v) => update({ stepControl: { ...cfg.stepControl, requireStartBeforeInput: v } })}
/>
<ToggleRow
label="완료 시 자동 다음 이동"
checked={cfg.stepControl.autoAdvance}
onChange={(v) => update({ stepControl: { ...cfg.stepControl, autoAdvance: v } })}
/>
</Section>
{/* 네비게이션 */}
<Section title="네비게이션">
<ToggleRow
label="이전/다음 버튼"
checked={cfg.navigation.showPrevNext}
onChange={(v) => update({ navigation: { ...cfg.navigation, showPrevNext: v } })}
/>
<ToggleRow
label="공정 완료 버튼"
checked={cfg.navigation.showCompleteButton}
onChange={(v) => update({ navigation: { ...cfg.navigation, showCompleteButton: v } })}
/>
</Section>
{/* 단계 라벨 */}
<Section title="단계 라벨">
{(["PRE", "IN", "POST"] as const).map((phase) => (
<div key={phase} className="flex items-center gap-2">
<span className="w-12 text-xs font-medium text-muted-foreground">
{phase}
</span>
<span className="w-12 text-xs font-medium text-muted-foreground">{phase}</span>
<Input
className="h-8 text-xs"
className="h-7 text-xs"
value={cfg.phaseLabels[phase] ?? DEFAULT_PHASE_LABELS[phase]}
onChange={(e) =>
update({
phaseLabels: { ...cfg.phaseLabels, [phase]: e.target.value },
})
}
onChange={(e) => update({ phaseLabels: { ...cfg.phaseLabels, [phase]: e.target.value } })}
/>
</div>
))}
</div>
</Section>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="space-y-2">
<div className="text-xs font-semibold text-muted-foreground">{title}</div>
{children}
</div>
);
}
function ToggleRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
<div className="flex items-center justify-between">
<Label className="text-xs">{label}</Label>
<Switch checked={checked} onCheckedChange={onChange} />
</div>
);
}

View File

@ -9,7 +9,25 @@ import type { PopWorkDetailConfig } from "../types";
const defaultConfig: PopWorkDetailConfig = {
showTimer: true,
showQuantityInput: true,
displayMode: "list",
phaseLabels: { PRE: "작업 전", IN: "작업 중", POST: "작업 후" },
infoBar: {
enabled: true,
fields: [
{ label: "작업지시", column: "wo_no" },
{ label: "품목", column: "item_name" },
{ label: "공정", column: "__process_name" },
{ label: "지시수량", column: "qty" },
],
},
stepControl: {
requireStartBeforeInput: false,
autoAdvance: true,
},
navigation: {
showPrevNext: true,
showCompleteButton: true,
},
};
PopComponentRegistry.registerComponent({

View File

@ -1006,8 +1006,34 @@ export const VIRTUAL_SUB_SEQ = "__subSeqNo__" as const;
// pop-work-detail 전용 타입
// =============================================
export interface WorkDetailInfoBarField {
label: string;
column: string;
}
export interface WorkDetailInfoBarConfig {
enabled: boolean;
fields: WorkDetailInfoBarField[];
}
export interface WorkDetailStepControl {
requireStartBeforeInput: boolean;
autoAdvance: boolean;
}
export interface WorkDetailNavigationConfig {
showPrevNext: boolean;
showCompleteButton: boolean;
}
export interface PopWorkDetailConfig {
showTimer: boolean;
/** @deprecated result-input 타입으로 대체 */
showQuantityInput: boolean;
/** 표시 모드: list(기존 리스트), step(한 항목씩 진행) */
displayMode: "list" | "step";
phaseLabels: Record<string, string>;
infoBar: WorkDetailInfoBarConfig;
stepControl: WorkDetailStepControl;
navigation: WorkDetailNavigationConfig;
}