();
- for (const item of order.items) {
- const key = item.floorCode !== '-' ? item.floorCode : (item.orderNodeId != null ? String(item.orderNodeId) : 'none');
- const label = item.floorCode !== '-' ? item.floorCode : item.orderNodeName;
- if (!nodeGroups.has(key)) {
- nodeGroups.set(key, { label, items: [] });
+
+ // 모든 아이템이 동일 그룹으로 들어가는지 확인
+ const uniqueKeys = new Set(order.items.map(item =>
+ item.floorCode !== '-' ? item.floorCode : String(item.orderNodeId ?? 'none')
+ ));
+ const allSameGroup = uniqueKeys.size <= 1;
+ const locationCount = order.shutterCount || 1;
+
+ if (allSameGroup && locationCount > 1) {
+ // 인덱스 기반 균등 분배
+ const itemsPerLoc = Math.ceil(order.items.length / locationCount);
+ for (let loc = 0; loc < locationCount; loc++) {
+ const start = loc * itemsPerLoc;
+ const end = Math.min(start + itemsPerLoc, order.items.length);
+ if (start >= order.items.length) break;
+ const key = `loc-${loc}`;
+ nodeGroups.set(key, {
+ label: `개소 ${loc + 1}`,
+ items: order.items.slice(start, end),
+ });
+ }
+ } else {
+ for (const item of order.items) {
+ const key = item.floorCode !== '-' ? item.floorCode : (item.orderNodeId != null ? String(item.orderNodeId) : 'none');
+ const label = item.floorCode !== '-' ? item.floorCode : item.orderNodeName;
+ if (!nodeGroups.has(key)) {
+ nodeGroups.set(key, { label, items: [] });
+ }
+ nodeGroups.get(key)!.items.push(item);
}
- nodeGroups.get(key)!.items.push(item);
}
const rows: React.ReactNode[] = [];
diff --git a/src/components/production/WorkOrders/documents/ScreenWorkLogContent.tsx b/src/components/production/WorkOrders/documents/ScreenWorkLogContent.tsx
index e7064a17..5f282fb3 100644
--- a/src/components/production/WorkOrders/documents/ScreenWorkLogContent.tsx
+++ b/src/components/production/WorkOrders/documents/ScreenWorkLogContent.tsx
@@ -271,8 +271,8 @@ export function ScreenWorkLogContent({ data: order, materialLots = [] }: ScreenW
return (
| {idx + 1} |
- {lotNoDisplay} |
- {item.productName} |
+ {lotNoDisplay} |
+ {item.productName} |
{getSymbolCode(item.floorCode)} |
{fmt(item.width)} |
{fmt(item.height)} |
diff --git a/src/components/production/WorkOrders/documents/bending/BottomBarSection.tsx b/src/components/production/WorkOrders/documents/bending/BottomBarSection.tsx
index 0324a415..2ce79584 100644
--- a/src/components/production/WorkOrders/documents/bending/BottomBarSection.tsx
+++ b/src/components/production/WorkOrders/documents/bending/BottomBarSection.tsx
@@ -55,9 +55,7 @@ export function BottomBarSection({ bendingInfo, mapping }: BottomBarSectionProps
{row.material} |
{fmt(row.length)} |
{fmt(row.quantity)} |
-
- {row.lotPrefix}-
- |
+ - |
{fmtWeight(row.weight)} |
))}
diff --git a/src/components/production/WorkOrders/documents/bending/GuideRailSection.tsx b/src/components/production/WorkOrders/documents/bending/GuideRailSection.tsx
index bad051fe..4fa924ad 100644
--- a/src/components/production/WorkOrders/documents/bending/GuideRailSection.tsx
+++ b/src/components/production/WorkOrders/documents/bending/GuideRailSection.tsx
@@ -60,9 +60,7 @@ function PartTable({ title, rows, imageUrl, lotNo, baseSize }: {
{row.partName === '하부BASE' ? (baseSize || '-') : fmt(row.length)}
{fmt(row.quantity)} |
-
- {row.lotPrefix}-
- |
+ - |
{fmtWeight(row.weight)} |
))}
@@ -79,11 +77,11 @@ export function GuideRailSection({ bendingInfo, mapping, lotNo }: GuideRailSecti
const productCode = bendingInfo.productCode;
const wallRows = wall
- ? buildWallGuideRailRows(wall.lengthData, wall.baseSize, mapping)
+ ? buildWallGuideRailRows(wall.lengthData, wall.baseDimension || '135*80', mapping)
: [];
const sideRows = side
- ? buildSideGuideRailRows(side.lengthData, mapping)
+ ? buildSideGuideRailRows(side.lengthData, side.baseDimension || '135*130', mapping)
: [];
if (wallRows.length === 0 && sideRows.length === 0) return null;
@@ -100,7 +98,7 @@ export function GuideRailSection({ bendingInfo, mapping, lotNo }: GuideRailSecti
rows={wallRows}
imageUrl={getBendingImageUrl('guiderail', productCode, 'wall')}
lotNo={lotNo}
- baseSize={wall?.baseSize}
+ baseSize={wall?.baseDimension || wall?.baseSize}
/>
)}
@@ -110,7 +108,7 @@ export function GuideRailSection({ bendingInfo, mapping, lotNo }: GuideRailSecti
rows={sideRows}
imageUrl={getBendingImageUrl('guiderail', productCode, 'side')}
lotNo={lotNo}
- baseSize="135*130"
+ baseSize={side?.baseDimension || '135*130'}
/>
)}
diff --git a/src/components/production/WorkOrders/documents/bending/ShutterBoxSection.tsx b/src/components/production/WorkOrders/documents/bending/ShutterBoxSection.tsx
index ee302b98..2db3eff4 100644
--- a/src/components/production/WorkOrders/documents/bending/ShutterBoxSection.tsx
+++ b/src/components/production/WorkOrders/documents/bending/ShutterBoxSection.tsx
@@ -70,9 +70,7 @@ function ShutterBoxSubSection({ box, index }: { box: ShutterBoxData; index: numb
{row.material} |
{row.dimension} |
{fmt(row.quantity)} |
-
- {row.lotPrefix}-
- |
+ - |
{fmtWeight(row.weight)} |
))}
diff --git a/src/components/production/WorkOrders/documents/bending/SmokeBarrierSection.tsx b/src/components/production/WorkOrders/documents/bending/SmokeBarrierSection.tsx
index 10b02b39..49518a54 100644
--- a/src/components/production/WorkOrders/documents/bending/SmokeBarrierSection.tsx
+++ b/src/components/production/WorkOrders/documents/bending/SmokeBarrierSection.tsx
@@ -55,7 +55,7 @@ export function SmokeBarrierSection({ bendingInfo }: SmokeBarrierSectionProps) {
{row.material} |
{fmt(row.length)} |
{fmt(row.quantity)} |
- {row.lotCode} |
+ - |
{fmtWeight(row.weight)} |
))}
diff --git a/src/components/production/WorkOrders/documents/bending/types.ts b/src/components/production/WorkOrders/documents/bending/types.ts
index 91b171ca..f0dd4ed8 100644
--- a/src/components/production/WorkOrders/documents/bending/types.ts
+++ b/src/components/production/WorkOrders/documents/bending/types.ts
@@ -14,7 +14,8 @@ export interface LengthQuantity {
// 가이드레일 타입별 데이터
export interface GuideRailTypeData {
lengthData: LengthQuantity[];
- baseSize: string; // "135*80" 또는 "135*130"
+ baseSize: string; // BOM 프로파일 사이즈 "130*75" (섹션 제목용)
+ baseDimension?: string; // 실제 하부BASE 물리 치수 "135*130" (작업일지 표시/무게계산용)
}
// 셔터박스 데이터
diff --git a/src/components/production/WorkOrders/documents/bending/utils.ts b/src/components/production/WorkOrders/documents/bending/utils.ts
index ee79d3ca..85152e8d 100644
--- a/src/components/production/WorkOrders/documents/bending/utils.ts
+++ b/src/components/production/WorkOrders/documents/bending/utils.ts
@@ -27,9 +27,8 @@ const EGI_DENSITY = 7.85; // g/cm3
// 가이드레일
const WALL_PART_WIDTH = 412; // mm - 벽면형 파트 폭
const SIDE_PART_WIDTH = 462; // mm - 측면형 파트 폭
-const WALL_BASE_HEIGHT_MIXED = 80; // 혼합형 벽면 하부BASE 높이
-const WALL_BASE_HEIGHT_ONLY = 130; // 벽면형 단독 하부BASE 높이
-const SIDE_BASE_HEIGHT = 130; // 측면형 하부BASE 높이
+const WALL_BASE_HEIGHT = 80; // 벽면형 하부BASE 높이 (legacy: wall_basesize 135*80)
+const SIDE_BASE_HEIGHT = 130; // 측면형 하부BASE 높이 (legacy: side_basesize 135*130)
const BASE_WIDTH = 135; // 하부BASE 폭
// 하단마감재
@@ -48,6 +47,21 @@ const GUIDE_RAIL_LENGTH_BUCKETS = [2438, 3000, 3500, 4000, 4300];
const SHUTTER_BOX_LENGTH_BUCKETS = [1219, 2438, 3000, 3500, 4000, 4150];
const SMOKE_W50_LENGTH_BUCKETS = [2438, 3000, 3500, 4000, 4300];
+// ============================================================
+// 하부BASE 치수 파싱 헬퍼
+// ============================================================
+
+/** baseDimension 문자열("135*130")에서 width/height 파싱. 없으면 기본값 사용 */
+function parseBaseDimension(baseDimension: string | undefined, fallbackHeight: number): { width: number; height: number } {
+ if (baseDimension) {
+ const parts = baseDimension.split('*').map(Number);
+ if (parts.length === 2 && parts[0] > 0 && parts[1] > 0) {
+ return { width: parts[0], height: parts[1] };
+ }
+ }
+ return { width: BASE_WIDTH, height: fallbackHeight };
+}
+
// ============================================================
// 핵심 함수: calWeight (PHP Lines 27-55)
// ============================================================
@@ -165,12 +179,10 @@ export function getSLengthCode(length: number, category: string): string | null
*/
export function buildWallGuideRailRows(
lengthData: LengthQuantity[],
- baseSize: string,
+ baseDimension: string,
mapping: MaterialMapping,
): GuideRailPartRow[] {
const rows: GuideRailPartRow[] = [];
- const baseHeight = baseSize === '135*80' ? WALL_BASE_HEIGHT_MIXED : WALL_BASE_HEIGHT_ONLY;
-
for (const ld of lengthData) {
if (ld.quantity <= 0) continue;
@@ -211,9 +223,11 @@ export function buildWallGuideRailRows(
}
// 하부BASE (길이 데이터와 무관하게 1행)
+ // baseDimension: "135*130" (KQTS01/KTE01) 또는 "135*80" (기타)
const totalQty = lengthData.reduce((sum, ld) => sum + ld.quantity, 0);
if (totalQty > 0) {
- const baseW = calcWeight('EGI 1.55T', BASE_WIDTH, baseHeight);
+ const { width: bw, height: bh } = parseBaseDimension(baseDimension, WALL_BASE_HEIGHT);
+ const baseW = calcWeight('EGI 1.55T', bw, bh);
rows.push({
partName: '하부BASE', lotPrefix: 'XX', material: 'EGI 1.55T',
length: 0, quantity: totalQty, weight: Math.round(baseW.weight * totalQty * 100) / 100,
@@ -228,6 +242,7 @@ export function buildWallGuideRailRows(
*/
export function buildSideGuideRailRows(
lengthData: LengthQuantity[],
+ baseDimension: string,
mapping: MaterialMapping,
): GuideRailPartRow[] {
const rows: GuideRailPartRow[] = [];
@@ -259,9 +274,11 @@ export function buildSideGuideRailRows(
}
// 하부BASE
+ // baseDimension: "135*130" (측면형은 항상 135*130)
const totalQty = lengthData.reduce((sum, ld) => sum + ld.quantity, 0);
if (totalQty > 0) {
- const baseW = calcWeight('EGI 1.55T', BASE_WIDTH, SIDE_BASE_HEIGHT);
+ const { width: bw, height: bh } = parseBaseDimension(baseDimension, SIDE_BASE_HEIGHT);
+ const baseW = calcWeight('EGI 1.55T', bw, bh);
rows.push({
partName: '하부BASE', lotPrefix: 'XX', material: 'EGI 1.55T',
length: 0, quantity: totalQty, weight: Math.round(baseW.weight * totalQty * 100) / 100,
@@ -341,93 +358,74 @@ export function buildShutterBoxRows(box: ShutterBoxData): ShutterBoxPartRow[] {
const { width: boxWidth, height: boxHeight } = parseBoxSize(box.size);
const isStandard = box.size === '500*380';
- for (const ld of box.lengthData) {
- if (ld.quantity <= 0) continue;
+ // 방향별 파트 정의
+ let parts: { name: string; prefix: string; dim: number }[];
- if (isStandard) {
- // 표준 500*380 구성
- const parts = [
- { name: '①전면부', prefix: 'CF', dim: boxHeight + 122 },
- { name: '②린텔부', prefix: 'CL', dim: boxWidth - 330 },
- { name: '③⑤점검구', prefix: 'CP', dim: boxWidth - 200 },
- { name: '④후면코너부', prefix: 'CB', dim: 170 },
- ];
- for (const p of parts) {
- const w = calcWeight(BOX_FINISH_MATERIAL, p.dim, ld.length);
- rows.push({
- partName: p.name, lotPrefix: p.prefix, material: BOX_FINISH_MATERIAL,
- dimension: `${ld.length}`, quantity: ld.quantity,
- weight: Math.round(w.weight * ld.quantity * 100) / 100,
- });
- }
- } else if (box.direction === '양면') {
- const parts = [
- { name: '①전면부', prefix: 'XX', dim: boxHeight + 122 },
- { name: '②린텔부', prefix: 'CL', dim: boxWidth - 330 },
- { name: '③점검구', prefix: 'XX', dim: boxWidth - 200 },
- { name: '④후면코너부', prefix: 'CB', dim: 170 },
- { name: '⑤점검구', prefix: 'XX', dim: boxHeight - 100 },
- ];
- for (const p of parts) {
- const w = calcWeight(BOX_FINISH_MATERIAL, p.dim, ld.length);
- rows.push({
- partName: p.name, lotPrefix: p.prefix, material: BOX_FINISH_MATERIAL,
- dimension: `${ld.length}`, quantity: ld.quantity,
- weight: Math.round(w.weight * ld.quantity * 100) / 100,
- });
- }
- } else if (box.direction === '밑면') {
- const parts = [
- { name: '①전면부', prefix: 'XX', dim: boxHeight + 122 },
- { name: '②린텔부', prefix: 'CL', dim: boxWidth - 330 },
- { name: '③점검구', prefix: 'XX', dim: boxWidth - 200 },
- { name: '④후면부', prefix: 'CB', dim: boxHeight + 85 * 2 },
- ];
- for (const p of parts) {
- const w = calcWeight(BOX_FINISH_MATERIAL, p.dim, ld.length);
- rows.push({
- partName: p.name, lotPrefix: p.prefix, material: BOX_FINISH_MATERIAL,
- dimension: `${ld.length}`, quantity: ld.quantity,
- weight: Math.round(w.weight * ld.quantity * 100) / 100,
- });
- }
- } else if (box.direction === '후면') {
- const parts = [
- { name: '①전면부', prefix: 'XX', dim: boxHeight + 122 },
- { name: '②린텔부', prefix: 'CL', dim: boxWidth + 85 * 2 },
- { name: '③점검구', prefix: 'XX', dim: boxHeight - 200 },
- { name: '④후면코너부', prefix: 'CB', dim: boxHeight + 85 * 2 },
- ];
- for (const p of parts) {
- const w = calcWeight(BOX_FINISH_MATERIAL, p.dim, ld.length);
- rows.push({
- partName: p.name, lotPrefix: p.prefix, material: BOX_FINISH_MATERIAL,
- dimension: `${ld.length}`, quantity: ld.quantity,
- weight: Math.round(w.weight * ld.quantity * 100) / 100,
- });
- }
+ if (isStandard) {
+ parts = [
+ { name: '①전면부', prefix: 'CF', dim: boxHeight + 122 },
+ { name: '②린텔부', prefix: 'CL', dim: boxWidth - 330 },
+ { name: '③⑤점검구', prefix: 'CP', dim: boxWidth - 200 },
+ { name: '④후면코너부', prefix: 'CB', dim: 170 },
+ ];
+ } else if (box.direction === '양면') {
+ parts = [
+ { name: '①전면부', prefix: 'XX', dim: boxHeight + 122 },
+ { name: '②린텔부', prefix: 'CL', dim: boxWidth - 330 },
+ { name: '③점검구', prefix: 'XX', dim: boxWidth - 200 },
+ { name: '④후면코너부', prefix: 'CB', dim: 170 },
+ { name: '⑤점검구', prefix: 'XX', dim: boxHeight - 100 },
+ ];
+ } else if (box.direction === '밑면') {
+ parts = [
+ { name: '①전면부', prefix: 'XX', dim: boxHeight + 122 },
+ { name: '②린텔부', prefix: 'CL', dim: boxWidth - 330 },
+ { name: '③점검구', prefix: 'XX', dim: boxWidth - 200 },
+ { name: '④후면부', prefix: 'CB', dim: boxHeight + 85 * 2 },
+ ];
+ } else if (box.direction === '후면') {
+ parts = [
+ { name: '①전면부', prefix: 'XX', dim: boxHeight + 122 },
+ { name: '②린텔부', prefix: 'CL', dim: boxWidth + 85 * 2 },
+ { name: '③점검구', prefix: 'XX', dim: boxHeight - 200 },
+ { name: '④후면코너부', prefix: 'CB', dim: boxHeight + 85 * 2 },
+ ];
+ } else {
+ parts = [];
+ }
+
+ // 구성요소 기준 정렬: 파트 → 길이 순서
+ for (const p of parts) {
+ for (const ld of box.lengthData) {
+ if (ld.quantity <= 0) continue;
+ const w = calcWeight(BOX_FINISH_MATERIAL, p.dim, ld.length);
+ rows.push({
+ partName: p.name, lotPrefix: p.prefix, material: BOX_FINISH_MATERIAL,
+ dimension: `${ld.length}`, quantity: ld.quantity,
+ weight: Math.round(w.weight * ld.quantity * 100) / 100,
+ });
}
}
- // 상부덮개 (비표준일 때)
- if (!isStandard && box.coverQty > 0) {
+ // 상부덮개
+ if (box.coverQty > 0) {
const coverWidth = boxWidth - 111;
const w = calcWeight(BOX_FINISH_MATERIAL, coverWidth, BOX_COVER_LENGTH);
rows.push({
- partName: '⑥상부덮개', lotPrefix: 'XX', material: BOX_FINISH_MATERIAL,
+ partName: isStandard ? '⑤상부덮개' : (box.direction === '양면' ? '⑥상부덮개' : '⑤상부덮개'),
+ lotPrefix: 'XX', material: BOX_FINISH_MATERIAL,
dimension: `1219 * ${coverWidth}`, quantity: box.coverQty,
weight: Math.round(w.weight * box.coverQty * 100) / 100,
});
}
- // 마구리 (비표준일 때)
- if (!isStandard && box.finCoverQty > 0) {
- const finWidth = boxWidth + 5;
- const finHeight = boxHeight + 5;
- const w = calcWeight(BOX_FINISH_MATERIAL, finWidth, finHeight);
+ // 마구리 (레거시: 무게는 원본 box 크기로 계산, 표시 치수만 +5)
+ if (box.finCoverQty > 0) {
+ const w = calcWeight(BOX_FINISH_MATERIAL, boxWidth, boxHeight);
rows.push({
- partName: '⑦측면부(마구리)', lotPrefix: 'XX', material: BOX_FINISH_MATERIAL,
- dimension: `${finWidth} * ${finHeight}`, quantity: box.finCoverQty,
+ partName: isStandard ? '⑥측면부(마구리)' : (box.direction === '양면' ? '⑦측면부(마구리)' : '⑥측면부(마구리)'),
+ lotPrefix: 'XX', material: BOX_FINISH_MATERIAL,
+ dimension: `${boxWidth + 5} * ${boxHeight + 5}`, quantity: box.finCoverQty,
weight: Math.round(w.weight * box.finCoverQty * 100) / 100,
});
}
@@ -493,8 +491,6 @@ export function calculateProductionSummary(
// 가이드레일 - 벽면형
if (bendingInfo.guideRail.wall) {
- const baseHeight = bendingInfo.guideRail.wall.baseSize === '135*80'
- ? WALL_BASE_HEIGHT_MIXED : WALL_BASE_HEIGHT_ONLY;
for (const ld of bendingInfo.guideRail.wall.lengthData) {
if (ld.quantity <= 0) continue;
addWeight(mapping.guideRailFinish, WALL_PART_WIDTH, ld.length, ld.quantity);
@@ -504,7 +500,8 @@ export function calculateProductionSummary(
}
}
const totalWallQty = bendingInfo.guideRail.wall.lengthData.reduce((s, l) => s + l.quantity, 0);
- addWeight('EGI 1.55T', BASE_WIDTH, baseHeight, totalWallQty);
+ const wallBase = parseBaseDimension(bendingInfo.guideRail.wall.baseDimension, WALL_BASE_HEIGHT);
+ addWeight('EGI 1.55T', wallBase.width, wallBase.height, totalWallQty);
}
// 가이드레일 - 측면형
@@ -515,7 +512,8 @@ export function calculateProductionSummary(
addWeight(mapping.bodyMaterial, SIDE_PART_WIDTH, ld.length, ld.quantity * 3);
}
const totalSideQty = bendingInfo.guideRail.side.lengthData.reduce((s, l) => s + l.quantity, 0);
- addWeight('EGI 1.55T', BASE_WIDTH, SIDE_BASE_HEIGHT, totalSideQty);
+ const sideBase = parseBaseDimension(bendingInfo.guideRail.side.baseDimension, SIDE_BASE_HEIGHT);
+ addWeight('EGI 1.55T', sideBase.width, sideBase.height, totalSideQty);
}
// 하단마감재
diff --git a/src/components/production/WorkerScreen/WorkOrderListPanel.tsx b/src/components/production/WorkerScreen/WorkOrderListPanel.tsx
index 8ce2b26b..e9f0a881 100644
--- a/src/components/production/WorkerScreen/WorkOrderListPanel.tsx
+++ b/src/components/production/WorkerScreen/WorkOrderListPanel.tsx
@@ -75,7 +75,7 @@ export function WorkOrderListPanel({
{/* 품목명 */}
- {order.productName}
+ {order.productName}
{/* 현장명 + 수량 */}
diff --git a/src/components/quotes/LocationListPanel.tsx b/src/components/quotes/LocationListPanel.tsx
index 264b868e..ebb5a7ca 100644
--- a/src/components/quotes/LocationListPanel.tsx
+++ b/src/components/quotes/LocationListPanel.tsx
@@ -134,10 +134,6 @@ export function LocationListPanel({
// 개소 추가 (BOM 계산 성공 시에만 폼 초기화)
const handleAdd = useCallback(async () => {
// 유효성 검사
- if (!formData.floor || !formData.code) {
- toast.error("층과 부호를 입력해주세요.");
- return;
- }
if (!formData.openWidth || !formData.openHeight) {
toast.error("가로와 세로를 입력해주세요.");
return;
@@ -150,8 +146,8 @@ export function LocationListPanel({
const product = finishedGoods.find((fg) => fg.item_code === formData.productCode);
const newLocation: Omit = {
- floor: formData.floor,
- code: formData.code,
+ floor: formData.floor || "-",
+ code: formData.code || "-",
openWidth: parseFloat(formData.openWidth) || 0,
openHeight: parseFloat(formData.openHeight) || 0,
productCode: formData.productCode,
diff --git a/src/components/quotes/QuoteRegistration.tsx b/src/components/quotes/QuoteRegistration.tsx
index 627fae81..fce69942 100644
--- a/src/components/quotes/QuoteRegistration.tsx
+++ b/src/components/quotes/QuoteRegistration.tsx
@@ -221,47 +221,49 @@ export function QuoteRegistration({
// DevFill (개발/테스트용 자동 채우기)
// ---------------------------------------------------------------------------
useDevFill("quoteV2", useCallback(() => {
- // BOM이 있는 제품만 필터링
- const productsWithBom = finishedGoods.filter((fg) => fg.has_bom === true || (fg.bom && Array.isArray(fg.bom) && fg.bom.length > 0));
+ // 제품 1개 고정 선택 (모델별 인증이라 섞을 수 없음)
+ const fixedProduct = finishedGoods.length > 0
+ ? finishedGoods[Math.floor(Math.random() * finishedGoods.length)]
+ : null;
- // 랜덤 개소 생성 함수
- const createRandomLocation = (index: number): LocationItem => {
- const floors = ["B2", "B1", "1F", "2F", "3F", "4F", "5F", "R"];
- const codePrefix = ["SD", "FSS", "FD", "SS", "DS"];
- const guideRailTypes = ["wall", "floor", "mixed"];
- const motorPowers = ["single", "three"];
- const controllers = ["basic", "smart", "premium"];
+ // 층 순서 (정렬된 상태로 순차 할당)
+ const sortedFloors = ["B2", "B1", "1F", "2F", "3F", "4F", "5F", "R"];
+ // 부호 접두사 1개 고정
+ const codePrefixes = ["SD", "FSS", "FD", "SS", "DS"];
+ const fixedPrefix = codePrefixes[Math.floor(Math.random() * codePrefixes.length)];
- const randomFloor = floors[Math.floor(Math.random() * floors.length)];
- const randomPrefix = codePrefix[Math.floor(Math.random() * codePrefix.length)];
- const randomWidth = (Math.floor(Math.random() * 40) + 20) * 100; // 2000~6000 (100단위)
- const randomHeight = (Math.floor(Math.random() * 30) + 20) * 100; // 2000~5000 (100단위)
- // BOM이 있는 제품 중에서 랜덤 선택 (없으면 전체에서 선택)
- const productPool = productsWithBom.length > 0 ? productsWithBom : finishedGoods;
- const randomProduct = productPool[Math.floor(Math.random() * productPool.length)];
+ const guideRailTypes = ["wall", "floor", "mixed"];
+ const motorPowers = ["single", "three"];
+ const controllers = ["basic", "smart", "premium"];
- return {
- id: `loc-${Date.now()}-${index}`,
- floor: randomFloor,
- code: `${randomPrefix}-${String(index + 1).padStart(2, "0")}`,
+ // 1~5개 랜덤 개소 생성
+ const locationCount = Math.floor(Math.random() * 5) + 1;
+
+ // 층을 순차 할당할 시작 인덱스 (랜덤 시작점, 순서대로 올라감)
+ const maxStartIdx = Math.max(0, sortedFloors.length - locationCount);
+ const floorStartIdx = Math.floor(Math.random() * (maxStartIdx + 1));
+
+ const testLocations: LocationItem[] = [];
+ for (let i = 0; i < locationCount; i++) {
+ const floorIdx = Math.min(floorStartIdx + i, sortedFloors.length - 1);
+ const randomWidth = (Math.floor(Math.random() * 40) + 20) * 100;
+ const randomHeight = (Math.floor(Math.random() * 30) + 20) * 100;
+
+ testLocations.push({
+ id: `loc-${Date.now()}-${i}`,
+ floor: sortedFloors[floorIdx],
+ code: `${fixedPrefix}-${String(i + 1).padStart(2, "0")}`,
openWidth: randomWidth,
openHeight: randomHeight,
- productCode: randomProduct?.item_code || "FG-SCR-001",
- productName: randomProduct?.item_name || "방화 스크린 셔터 (소형)",
- quantity: Math.floor(Math.random() * 3) + 1, // 1~3
+ productCode: fixedProduct?.item_code || "FG-SCR-001",
+ productName: fixedProduct?.item_name || "방화 스크린 셔터 (소형)",
+ quantity: 1,
guideRailType: guideRailTypes[Math.floor(Math.random() * guideRailTypes.length)],
motorPower: motorPowers[Math.floor(Math.random() * motorPowers.length)],
controller: controllers[Math.floor(Math.random() * controllers.length)],
wingSize: [50, 60, 70][Math.floor(Math.random() * 3)],
inspectionFee: [50000, 60000, 70000][Math.floor(Math.random() * 3)],
- };
- };
-
- // 1~5개 랜덤 개소 생성
- const locationCount = Math.floor(Math.random() * 5) + 1;
- const testLocations: LocationItem[] = [];
- for (let i = 0; i < locationCount; i++) {
- testLocations.push(createRandomLocation(i));
+ });
}
// 로그인 사용자 정보 가져오기
@@ -511,29 +513,36 @@ export function QuoteRegistration({
const source = formData.locations.find((loc) => loc.id === locationId);
if (!source) return;
- // 부호에서 접두어와 번호 분리 (예: "DS-01" → prefix="DS-", num=1)
- const codeMatch = source.code.match(/^(.*?)(\d+)$/);
- let newCode = source.code + "-copy";
+ // 층/부호가 없거나 "-"이면 그대로 유지
+ let newFloor = source.floor || "-";
+ let newCode = source.code || "-";
- if (codeMatch) {
- const prefix = codeMatch[1]; // "DS-"
- const numLength = codeMatch[2].length; // 2 (자릿수 보존)
+ if (newCode !== "-") {
+ // 부호에서 접두어와 번호 분리 (예: "DS-01" → prefix="DS-", num=1)
+ const codeMatch = source.code.match(/^(.*?)(\d+)$/);
+ if (codeMatch) {
+ const prefix = codeMatch[1]; // "DS-"
+ const numLength = codeMatch[2].length; // 2 (자릿수 보존)
- // 같은 접두어를 가진 부호 중 최대 번호 찾기
- let maxNum = 0;
- formData.locations.forEach((loc) => {
- const m = loc.code.match(new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)$`));
- if (m) {
- maxNum = Math.max(maxNum, parseInt(m[1], 10));
- }
- });
+ // 같은 접두어를 가진 부호 중 최대 번호 찾기
+ let maxNum = 0;
+ formData.locations.forEach((loc) => {
+ const m = loc.code.match(new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)$`));
+ if (m) {
+ maxNum = Math.max(maxNum, parseInt(m[1], 10));
+ }
+ });
- newCode = prefix + String(maxNum + 1).padStart(numLength, "0");
+ newCode = prefix + String(maxNum + 1).padStart(numLength, "0");
+ } else {
+ newCode = source.code + "-copy";
+ }
}
const clonedLocation: LocationItem = {
...source,
id: `loc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
+ floor: newFloor,
code: newCode,
};
diff --git a/src/components/quotes/actions.ts b/src/components/quotes/actions.ts
index 3ca7e558..d14f2d57 100644
--- a/src/components/quotes/actions.ts
+++ b/src/components/quotes/actions.ts
@@ -309,7 +309,6 @@ export async function getFinishedGoods(category?: string): Promise<{
const result = await executeServerAction[]>({
url: buildApiUrl('/api/v1/items', {
item_type: 'FG',
- has_bom: '1',
item_category: category,
size: '5000',
}),
diff --git a/src/components/quotes/types.ts b/src/components/quotes/types.ts
index 27b07f69..373de7b7 100644
--- a/src/components/quotes/types.ts
+++ b/src/components/quotes/types.ts
@@ -766,6 +766,7 @@ export function transformV2ToApi(
total_price: number;
sort_order: number;
note: string | null;
+ formula_source?: string;
item_index?: number;
finished_goods_code?: string;
formula_category?: string;
@@ -796,7 +797,8 @@ export function transformV2ToApi(
unit_price: bomItem.unit_price,
total_price: bomItem.unit_price * calcQty,
sort_order: sortOrder++,
- note: `${loc?.floor || ''} ${loc?.code || ''}`.trim() || null,
+ note: [loc?.floor, loc?.code].filter(v => v && v !== '-').join(' ') || null,
+ formula_source: `product_${locIndex}`,
item_index: locIndex,
finished_goods_code: bomResult.finished_goods.code,
formula_category: bomItem.process_group || undefined,
@@ -827,7 +829,8 @@ export function transformV2ToApi(
unit_price: bomItem.unit_price,
total_price: bomItem.unit_price * calcQty,
sort_order: sortOrder++,
- note: `${loc.floor || ''} ${loc.code || ''}`.trim() || null,
+ note: [loc.floor, loc.code].filter(v => v && v !== '-').join(' ') || null,
+ formula_source: `product_${locIndex}`,
item_index: locIndex,
finished_goods_code: loc.bomResult!.finished_goods.code,
formula_category: bomItem.process_group || undefined,
@@ -850,7 +853,8 @@ export function transformV2ToApi(
unit_price: loc.unitPrice || loc.inspectionFee || 0,
total_price: loc.totalPrice || (loc.unitPrice || loc.inspectionFee || 0) * loc.quantity,
sort_order: index + 1,
- note: `${loc.floor} ${loc.code}`.trim() || null,
+ note: [loc.floor, loc.code].filter(v => v && v !== '-').join(' ') || null,
+ formula_source: `product_${index}`,
}));
}
@@ -1027,6 +1031,7 @@ export function transformFormDataToApi(formData: QuoteFormData): Record v && v !== '-').join(' ') || null,
+ formula_source: `product_${calcItem.index}`,
item_index: calcItem.index,
finished_goods_code: calcItem.result.finished_goods.code,
formula_category: bomItem.process_group || undefined,
@@ -1084,7 +1090,8 @@ export function transformFormDataToApi(formData: QuoteFormData): Record v && v !== '-').join(' ') || null,
+ formula_source: `product_${index}`,
};
});
}
diff --git a/src/components/templates/IntegratedListTemplateV2.tsx b/src/components/templates/IntegratedListTemplateV2.tsx
index 58e4665c..2fdca5ca 100644
--- a/src/components/templates/IntegratedListTemplateV2.tsx
+++ b/src/components/templates/IntegratedListTemplateV2.tsx
@@ -240,6 +240,13 @@ export interface IntegratedListTemplateV2Props {
// 로딩 상태
isLoading?: boolean;
+
+ // ===== 컬럼 리사이즈 & 가시성 설정 (opt-in) =====
+ columnSettings?: {
+ columnWidths: Record;
+ onColumnResize: (columnKey: string, width: number) => void;
+ settingsPopover: ReactNode;
+ };
}
export function IntegratedListTemplateV2({
@@ -299,6 +306,7 @@ export function IntegratedListTemplateV2({
pagination,
devMetadata,
isLoading,
+ columnSettings,
}: IntegratedListTemplateV2Props) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
@@ -785,6 +793,7 @@ export function IntegratedListTemplateV2({
))}
{tableHeaderActions}
{renderAutoFilters()}
+ {columnSettings?.settingsPopover}
@@ -929,6 +938,17 @@ export function IntegratedListTemplateV2({
/>
) : (
+ {columnSettings && (
+
+ {showCheckbox && }
+ {tableColumns.map((col) => (
+
+ ))}
+
+ )}
{renderCustomTableHeader ? (
@@ -953,7 +973,7 @@ export function IntegratedListTemplateV2({
return (
onSort(column.key) : undefined}
>
{column.key === "actions" && selectedItems.size === 0 ? "" : (
@@ -974,6 +994,33 @@ export function IntegratedListTemplateV2({
)}
)}
+ {columnSettings && (
+ {
+ e.stopPropagation();
+ e.preventDefault();
+ const th = (e.target as HTMLElement).parentElement;
+ if (!th) return;
+ const startX = e.clientX;
+ const startWidth = th.offsetWidth;
+ const onMouseMove = (ev: MouseEvent) => {
+ const newWidth = Math.max(40, startWidth + ev.clientX - startX);
+ columnSettings.onColumnResize(column.key, newWidth);
+ };
+ const onMouseUp = () => {
+ document.removeEventListener('mousemove', onMouseMove);
+ document.removeEventListener('mouseup', onMouseUp);
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+ };
+ document.body.style.cursor = 'col-resize';
+ document.body.style.userSelect = 'none';
+ document.addEventListener('mousemove', onMouseMove);
+ document.addEventListener('mouseup', onMouseUp);
+ }}
+ />
+ )}
);
})}
diff --git a/src/components/templates/UniversalListPage/index.tsx b/src/components/templates/UniversalListPage/index.tsx
index d1d08265..b864684a 100644
--- a/src/components/templates/UniversalListPage/index.tsx
+++ b/src/components/templates/UniversalListPage/index.tsx
@@ -11,9 +11,11 @@
* - 클라이언트 사이드 필터링/페이지네이션 (clientSideFiltering: true)
*/
-import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
+import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { usePermission } from '@/hooks/usePermission';
+import { useColumnSettings } from '@/hooks/useColumnSettings';
+import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
import { toast } from 'sonner';
import { Download, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -818,6 +820,46 @@ export function UniversalListPage
({
}));
}, [config.columns, config.columnsPerTab, activeTab]);
+ // ===== 컬럼 리사이즈 & 가시성 설정 (자동 활성화) =====
+ const enableColumnSettings = !config.disableColumnSettings;
+ const alwaysVisibleKeys = useMemo(
+ () => effectiveColumns.filter(col => NON_SORTABLE_KEYS.includes(col.key)).map(col => col.key),
+ [effectiveColumns]
+ );
+ const {
+ visibleColumns: colSettingsVisible,
+ allColumnsWithVisibility,
+ columnWidths,
+ setColumnWidth,
+ toggleColumnVisibility,
+ resetSettings,
+ hasHiddenColumns,
+ } = useColumnSettings({
+ pageId: config.basePath,
+ columns: effectiveColumns,
+ alwaysVisibleKeys,
+ });
+ const hiddenColumnKeys = useMemo(
+ () => allColumnsWithVisibility.filter(c => !c.visible).map(c => c.key),
+ [allColumnsWithVisibility]
+ );
+ const templateColumns = enableColumnSettings ? colSettingsVisible : effectiveColumns;
+ const templateColumnSettings = useMemo(() => {
+ if (!enableColumnSettings) return undefined;
+ return {
+ columnWidths,
+ onColumnResize: setColumnWidth,
+ settingsPopover: (
+
+ ),
+ };
+ }, [enableColumnSettings, columnWidths, setColumnWidth, allColumnsWithVisibility, toggleColumnVisibility, resetSettings, hasHiddenColumns]);
+
// ===== ID로 아이템 찾기 헬퍼 =====
const getItemById = useCallback(
(id: string): T | undefined => {
@@ -852,19 +894,33 @@ export function UniversalListPage({
);
// ===== 렌더링 함수 래퍼 =====
+ const showCheckbox = config.showCheckbox !== false;
const renderTableRow = useCallback(
(item: T, index: number, globalIndex: number) => {
const id = effectiveGetItemId(item);
const isSelected = effectiveSelectedItems.has(id);
- return config.renderTableRow(item, index, globalIndex, {
+ const row = config.renderTableRow(item, index, globalIndex, {
isSelected,
onToggle: () => toggleSelection(id),
onRowClick: () => handleRowClick(item),
onEdit: () => handleEdit(item),
onDelete: permCanDelete ? () => handleDeleteClick(item) : undefined,
});
+
+ // 컬럼 설정 활성화 시 숨긴 컬럼의 셀을 React.Children으로 제거
+ if (!enableColumnSettings || hiddenColumnKeys.length === 0) return row;
+ if (!React.isValidElement(row)) return row;
+
+ const children = React.Children.toArray((row as React.ReactElement<{ children?: React.ReactNode }>).props.children);
+ const offset = showCheckbox ? 1 : 0;
+ const filtered = children.filter((_, cellIndex) => {
+ if (showCheckbox && cellIndex === 0) return true; // 체크박스 유지
+ const col = effectiveColumns[cellIndex - offset];
+ return !col || !hiddenColumnKeys.includes(col.key);
+ });
+ return React.cloneElement(row as React.ReactElement, {}, ...filtered);
},
- [config, effectiveGetItemId, handleDeleteClick, handleEdit, handleRowClick, effectiveSelectedItems, toggleSelection]
+ [config, effectiveGetItemId, handleDeleteClick, handleEdit, handleRowClick, effectiveSelectedItems, toggleSelection, enableColumnSettings, hiddenColumnKeys, showCheckbox, effectiveColumns]
);
const renderMobileCard = useCallback(
@@ -955,8 +1011,10 @@ export function UniversalListPage({
})
: config.tableHeaderActions
}
- // 테이블 컬럼 (탭별 다른 컬럼 지원)
- tableColumns={effectiveColumns}
+ // 테이블 컬럼 (가시성 필터링 적용)
+ tableColumns={templateColumns}
+ // 컬럼 리사이즈 & 가시성 설정
+ columnSettings={templateColumnSettings}
// 정렬 설정 (모든 페이지에서 활성화)
sortBy={sortBy}
sortOrder={sortOrder}
diff --git a/src/components/templates/UniversalListPage/types.ts b/src/components/templates/UniversalListPage/types.ts
index d4b1c709..8e42315f 100644
--- a/src/components/templates/UniversalListPage/types.ts
+++ b/src/components/templates/UniversalListPage/types.ts
@@ -413,6 +413,10 @@ export interface UniversalListConfig {
/** 검색어 변경 콜백 (config 내부에서 설정, 서버 사이드 검색용) */
onSearchChange?: (search: string) => void;
+ // ===== 컬럼 리사이즈 & 가시성 설정 =====
+ /** 컬럼 설정 비활성화 (기본: false = 자동 활성화) */
+ disableColumnSettings?: boolean;
+
// ===== 커스텀 다이얼로그 슬롯 =====
/**
* 커스텀 다이얼로그 렌더링 (DocumentDetailModal, SalaryDetailDialog 등)
diff --git a/src/hooks/useColumnSettings.ts b/src/hooks/useColumnSettings.ts
new file mode 100644
index 00000000..d7aa9ab3
--- /dev/null
+++ b/src/hooks/useColumnSettings.ts
@@ -0,0 +1,64 @@
+import { useMemo, useCallback } from 'react';
+import { useTableColumnStore } from '@/stores/useTableColumnStore';
+import type { TableColumn } from '@/components/templates/UniversalListPage/types';
+
+export interface ColumnWithVisibility extends TableColumn {
+ visible: boolean;
+ locked: boolean;
+}
+
+interface UseColumnSettingsParams {
+ pageId: string;
+ columns: TableColumn[];
+ alwaysVisibleKeys?: string[];
+}
+
+export function useColumnSettings({ pageId, columns, alwaysVisibleKeys = [] }: UseColumnSettingsParams) {
+ const store = useTableColumnStore();
+ const settings = store.getPageSettings(pageId);
+
+ const visibleColumns = useMemo(() => {
+ return columns.filter((col) => !settings.hiddenColumns.includes(col.key));
+ }, [columns, settings.hiddenColumns]);
+
+ const allColumnsWithVisibility = useMemo((): ColumnWithVisibility[] => {
+ return columns.map((col) => ({
+ ...col,
+ visible: !settings.hiddenColumns.includes(col.key),
+ locked: alwaysVisibleKeys.includes(col.key),
+ }));
+ }, [columns, settings.hiddenColumns, alwaysVisibleKeys]);
+
+ const columnWidths = settings.columnWidths;
+
+ const setColumnWidth = useCallback(
+ (key: string, width: number) => {
+ store.setColumnWidth(pageId, key, width);
+ },
+ [store, pageId]
+ );
+
+ const toggleColumnVisibility = useCallback(
+ (key: string) => {
+ if (alwaysVisibleKeys.includes(key)) return;
+ store.toggleColumnVisibility(pageId, key);
+ },
+ [store, pageId, alwaysVisibleKeys]
+ );
+
+ const resetSettings = useCallback(() => {
+ store.resetPageSettings(pageId);
+ }, [store, pageId]);
+
+ const hasHiddenColumns = settings.hiddenColumns.length > 0;
+
+ return {
+ visibleColumns,
+ allColumnsWithVisibility,
+ columnWidths,
+ setColumnWidth,
+ toggleColumnVisibility,
+ resetSettings,
+ hasHiddenColumns,
+ };
+}
diff --git a/src/stores/useTableColumnStore.ts b/src/stores/useTableColumnStore.ts
new file mode 100644
index 00000000..e7dc836a
--- /dev/null
+++ b/src/stores/useTableColumnStore.ts
@@ -0,0 +1,101 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+import { safeJsonParse } from '@/lib/utils';
+
+interface PageColumnSettings {
+ columnWidths: Record;
+ hiddenColumns: string[];
+}
+
+interface TableColumnState {
+ pageSettings: Record;
+ setColumnWidth: (pageId: string, columnKey: string, width: number) => void;
+ toggleColumnVisibility: (pageId: string, columnKey: string) => void;
+ resetPageSettings: (pageId: string) => void;
+ getPageSettings: (pageId: string) => PageColumnSettings;
+}
+
+function getUserId(): string {
+ if (typeof window === 'undefined') return 'default';
+ const userStr = localStorage.getItem('user');
+ if (!userStr) return 'default';
+ const user = safeJsonParse | null>(userStr, null);
+ return user?.id ? String(user.id) : 'default';
+}
+
+function getStorageKey(): string {
+ return `sam-table-columns-${getUserId()}`;
+}
+
+const DEFAULT_PAGE_SETTINGS: PageColumnSettings = {
+ columnWidths: {},
+ hiddenColumns: [],
+};
+
+export const useTableColumnStore = create()(
+ persist(
+ (set, get) => ({
+ pageSettings: {},
+
+ setColumnWidth: (pageId: string, columnKey: string, width: number) => {
+ const { pageSettings } = get();
+ const current = pageSettings[pageId] || { ...DEFAULT_PAGE_SETTINGS };
+ set({
+ pageSettings: {
+ ...pageSettings,
+ [pageId]: {
+ ...current,
+ columnWidths: { ...current.columnWidths, [columnKey]: width },
+ },
+ },
+ });
+ },
+
+ toggleColumnVisibility: (pageId: string, columnKey: string) => {
+ const { pageSettings } = get();
+ const current = pageSettings[pageId] || { ...DEFAULT_PAGE_SETTINGS };
+ const hidden = current.hiddenColumns.includes(columnKey)
+ ? current.hiddenColumns.filter((k) => k !== columnKey)
+ : [...current.hiddenColumns, columnKey];
+ set({
+ pageSettings: {
+ ...pageSettings,
+ [pageId]: { ...current, hiddenColumns: hidden },
+ },
+ });
+ },
+
+ resetPageSettings: (pageId: string) => {
+ const { pageSettings } = get();
+ const { [pageId]: _, ...rest } = pageSettings;
+ set({ pageSettings: rest });
+ },
+
+ getPageSettings: (pageId: string) => {
+ return get().pageSettings[pageId] || DEFAULT_PAGE_SETTINGS;
+ },
+ }),
+ {
+ name: 'sam-table-columns',
+ storage: {
+ getItem: (name) => {
+ const key = getStorageKey();
+ const str = localStorage.getItem(key);
+ if (!str) {
+ const fallback = localStorage.getItem(name);
+ return fallback ? JSON.parse(fallback) : null;
+ }
+ return JSON.parse(str);
+ },
+ setItem: (name, value) => {
+ const key = getStorageKey();
+ localStorage.setItem(key, JSON.stringify(value));
+ },
+ removeItem: (name) => {
+ const key = getStorageKey();
+ localStorage.removeItem(key);
+ },
+ },
+ }
+ )
+);