Merge branch 'feature/dashboard-swipe-fixes' into ksh-v2-work
This commit is contained in:
commit
cd106c7499
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
{/* 콘텐츠 (슬라이드 애니메이션) */}
|
||||
|
|
|
|||
Loading…
Reference in New Issue