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:
parent
230d35b03a
commit
06c52b422f
|
|
@ -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 || "그룹 타이머 처리 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue