Merge branch 'feature/dashboard-swipe-fixes' into ksh-v2-work

This commit is contained in:
SeongHyun Kim 2026-03-31 11:18:03 +09:00
commit cd106c7499
4 changed files with 217 additions and 3 deletions

View File

@ -2014,3 +2014,134 @@ export const inventoryInbound = async (
client.release();
}
};
/**
* ( )
* + + inventory_stock UPSERT + inbound_mng
*/
export const quickInventoryInbound = async (
req: AuthenticatedRequest,
res: Response
) => {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { item_id, qty, warehouse_code, location_code, remark } = req.body;
// 필수 파라미터 검증
if (!item_id || !qty || !warehouse_code) {
return res.status(400).json({
success: false,
message: "item_id, qty, warehouse_code는 필수입니다.",
});
}
const parsedQty = parseInt(String(qty), 10);
if (isNaN(parsedQty) || parsedQty <= 0) {
return res.status(400).json({
success: false,
message: "수량은 1 이상의 정수여야 합니다.",
});
}
await client.query("BEGIN");
// 1. item_info에서 item_number, item_name 조회
const itemResult = await client.query(
`SELECT item_number, item_name, size, material, unit
FROM item_info WHERE id = $1 AND company_code = $2`,
[item_id, companyCode]
);
if (itemResult.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({
success: false,
message: "품목 정보를 찾을 수 없습니다.",
});
}
const item = itemResult.rows[0];
const itemCode = item.item_number;
const effectiveLocationCode = location_code || warehouse_code;
// 2. inventory_stock UPSERT (기존 inventoryInbound와 동일한 패턴)
await client.query(
`INSERT INTO inventory_stock (id, company_code, item_code, warehouse_code, location_code, current_qty, last_in_date, created_date, updated_date, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, NOW(), NOW(), NOW(), $6)
ON CONFLICT (company_code, item_code, warehouse_code, location_code)
DO UPDATE SET current_qty = (COALESCE(inventory_stock.current_qty::numeric, 0) + $5::numeric)::text,
last_in_date = NOW(),
updated_date = NOW(),
writer = $6`,
[companyCode, itemCode, warehouse_code, effectiveLocationCode, String(parsedQty), userId]
);
// 3. inbound_mng에 간이입고 이력 기록
const seqResult = await client.query(
`SELECT COALESCE(MAX(
CASE WHEN inbound_number ~ '^QIB-[0-9]{4}-[0-9]+$'
THEN CAST(SUBSTRING(inbound_number FROM '[0-9]+$') AS INTEGER)
ELSE 0 END
), 0) + 1 AS next_seq
FROM inbound_mng WHERE company_code = $1`,
[companyCode]
);
const nextSeq = seqResult.rows[0].next_seq;
const year = new Date().getFullYear();
const inboundNumber = `QIB-${year}-${String(nextSeq).padStart(4, "0")}`;
await client.query(
`INSERT INTO inbound_mng (
id, company_code, inbound_number, inbound_type, inbound_date,
item_number, item_name, spec, material, unit,
inbound_qty, warehouse_code, location_code,
inbound_status, memo, remark,
created_date, updated_date, writer, created_by, updated_by
) VALUES (
gen_random_uuid()::text, $1, $2, '간이입고', CURRENT_DATE,
$3, $4, $5, $6, $7,
$8, $9, $10,
'완료', $11, $12,
NOW(), NOW(), $13, $13, $13
)`,
[
companyCode, inboundNumber,
item.item_number, item.item_name, item.size, item.material, item.unit,
parsedQty, warehouse_code, effectiveLocationCode,
remark || "POP 간이입고", remark || null,
userId,
]
);
await client.query("COMMIT");
logger.info("[pop/production] 간이 재고 입고 완료", {
companyCode, userId, item_id,
itemCode, warehouse_code, location_code: effectiveLocationCode,
qty: parsedQty, inboundNumber,
});
return res.json({
success: true,
message: "간이 재고 입고가 완료되었습니다.",
data: {
inbound_number: inboundNumber,
item_code: itemCode,
item_name: item.item_name,
warehouse_code,
location_code: effectiveLocationCode,
qty: parsedQty,
},
});
} catch (error: any) {
await client.query("ROLLBACK").catch(() => {});
logger.error("[pop/production] 간이 재고 입고 오류:", error);
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
};

View File

@ -16,6 +16,7 @@ import {
isLastProcess,
updateTargetWarehouse,
inventoryInbound,
quickInventoryInbound,
} from "../controllers/popProductionController";
const router = Router();
@ -37,5 +38,6 @@ router.get("/warehouse-locations/:warehouseId", getWarehouseLocations);
router.get("/is-last-process/:processId", isLastProcess);
router.post("/update-target-warehouse", updateTargetWarehouse);
router.post("/inventory-inbound", inventoryInbound);
router.post("/quick-inventory-inbound", quickInventoryInbound);
export default router;

View File

@ -7,7 +7,7 @@
* 최적화: 최소 44x44px
*/
import React, { useState, useCallback } from "react";
import React, { useState, useCallback, useRef } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
// ===== Props =====
@ -23,12 +23,17 @@ export interface ArrowsModeProps {
// ===== 메인 컴포넌트 =====
/** 스와이프 감지 최소 거리(px) */
const SWIPE_THRESHOLD = 50;
export function ArrowsModeComponent({
itemCount,
showIndicator = true,
renderItem,
}: ArrowsModeProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const touchStartX = useRef<number | null>(null);
const touchStartY = useRef<number | null>(null);
const goToPrev = useCallback(() => {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : itemCount - 1));
@ -38,6 +43,37 @@ export function ArrowsModeComponent({
setCurrentIndex((prev) => (prev < itemCount - 1 ? prev + 1 : 0));
}, [itemCount]);
// --- 터치 스와이프 핸들러 ---
const handleTouchStart = useCallback((e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY;
}, []);
const handleTouchEnd = useCallback(
(e: React.TouchEvent) => {
if (touchStartX.current === null || touchStartY.current === null) return;
const deltaX = e.changedTouches[0].clientX - touchStartX.current;
const deltaY = e.changedTouches[0].clientY - touchStartY.current;
// 수평 스와이프가 수직보다 클 때만 페이지 전환 (스크롤과 구분)
if (
Math.abs(deltaX) > SWIPE_THRESHOLD &&
Math.abs(deltaX) > Math.abs(deltaY)
) {
if (deltaX < 0) {
goToNext(); // 왼쪽 스와이프 -> 다음
} else {
goToPrev(); // 오른쪽 스와이프 -> 이전
}
}
touchStartX.current = null;
touchStartY.current = null;
},
[goToNext, goToPrev]
);
if (itemCount === 0) {
return (
<div className="flex h-full w-full items-center justify-center">
@ -47,7 +83,11 @@ export function ArrowsModeComponent({
}
return (
<div className="relative h-full w-full">
<div
className="relative h-full w-full"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* 아이템 (전체 영역 사용) */}
<div className="h-full w-full">
{renderItem(currentIndex)}

View File

@ -9,6 +9,9 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
/** 스와이프 감지 최소 거리(px) */
const SWIPE_THRESHOLD = 50;
// ===== Props =====
export interface AutoSlideModeProps {
@ -37,6 +40,8 @@ export function AutoSlideModeComponent({
const [isPaused, setIsPaused] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const resumeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const touchStartX = useRef<number | null>(null);
const touchStartY = useRef<number | null>(null);
// 타이머 정리 함수
const clearTimers = useCallback(() => {
@ -72,6 +77,41 @@ export function AutoSlideModeComponent({
}, resumeDelay * 1000);
}, [resumeDelay, clearTimers, startAutoSlide]);
// 스와이프 핸들러: 터치로 페이지 넘김 + 자동 슬라이드 일시정지
const handleTouchStart = useCallback((e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY;
}, []);
const handleTouchEnd = useCallback(
(e: React.TouchEvent) => {
if (touchStartX.current === null || touchStartY.current === null) return;
const deltaX = e.changedTouches[0].clientX - touchStartX.current;
const deltaY = e.changedTouches[0].clientY - touchStartY.current;
if (
Math.abs(deltaX) > SWIPE_THRESHOLD &&
Math.abs(deltaX) > Math.abs(deltaY)
) {
// 스와이프로 페이지 전환
if (deltaX < 0) {
setCurrentIndex((prev) => (prev < itemCount - 1 ? prev + 1 : 0));
} else {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : itemCount - 1));
}
handlePause(); // 스와이프 후 자동 슬라이드 일시정지
} else {
// 스와이프가 아닌 단순 터치 -> 일시정지만
handlePause();
}
touchStartX.current = null;
touchStartY.current = null;
},
[itemCount, handlePause]
);
// 마운트 시 자동 슬라이드 시작, unmount 시 정리
useEffect(() => {
if (!isPaused) {
@ -92,7 +132,8 @@ export function AutoSlideModeComponent({
<div
className="relative h-full w-full"
onClick={handlePause}
onTouchStart={handlePause}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
role="presentation"
>
{/* 콘텐츠 (슬라이드 애니메이션) */}