This commit is contained in:
김보곤
2026-02-21 14:45:04 +09:00
24 changed files with 618 additions and 182 deletions

3
.gitignore vendored
View File

@@ -120,3 +120,6 @@ src/app/**/dev/page-builder/
# ---> Dev Dashboard Prototypes (디자인 프로토타입 - 로컬 전용) # ---> Dev Dashboard Prototypes (디자인 프로토타입 - 로컬 전용)
src/app/**/dev/dashboard/ src/app/**/dev/dashboard/
# ---> Serena MCP memories
.serena/

View File

@@ -40,6 +40,9 @@
- ✅ 견적 화면에서 조인트바 BOM 표시 확인 완료 - ✅ 견적 화면에서 조인트바 BOM 표시 확인 완료
- ⬜ 절곡 실 데이터 테스트 (bending_info 채워진 작업지시로 확인) - ⬜ 절곡 실 데이터 테스트 (bending_info 채워진 작업지시로 확인)
### 관련 API 커밋
- `23029b1` (api) fix: 작업지시 단건조회(show)에 materialInputs eager loading 추가
### 관련 문서 ### 관련 문서
- `docs/plans/bending-worklog-reimplementation-plan.md` (✅ 완료) - `docs/plans/bending-worklog-reimplementation-plan.md` (✅ 완료)

View File

@@ -43,13 +43,16 @@ const WRITERS = ['드미트리', '김철수', '이영희', '박지민', '최서
export function generateQuoteFormItem( export function generateQuoteFormItem(
index: number, index: number,
products?: Array<{ code: string; name: string; category?: string }>, products?: Array<{ code: string; name: string; category?: string }>,
category?: string category?: string,
fixedProductCode?: string
): QuoteFormItem { ): QuoteFormItem {
const selectedCategory = category || randomPick(PRODUCT_CATEGORIES); const selectedCategory = category || randomPick(PRODUCT_CATEGORIES);
// 카테고리에 맞는 제품 필터링 // 카테고리에 맞는 제품 필터링
let productCode = ''; let productCode = '';
if (products && products.length > 0) { if (fixedProductCode) {
productCode = fixedProductCode;
} else if (products && products.length > 0) {
const categoryProducts = products.filter(p => const categoryProducts = products.filter(p =>
p.category?.toUpperCase() === selectedCategory || !p.category p.category?.toUpperCase() === selectedCategory || !p.category
); );
@@ -70,7 +73,7 @@ export function generateQuoteFormItem(
guideRailType: randomPick(GUIDE_RAIL_TYPES), guideRailType: randomPick(GUIDE_RAIL_TYPES),
motorPower: randomPick(MOTOR_POWERS), motorPower: randomPick(MOTOR_POWERS),
controller: randomPick(CONTROLLERS), controller: randomPick(CONTROLLERS),
quantity: randomInt(1, 10), quantity: 1,
wingSize: '50', wingSize: '50',
inspectionFee: 50000, inspectionFee: 50000,
}; };
@@ -104,11 +107,22 @@ export function generateQuoteData(options: GenerateQuoteDataOptions = {}): Quote
// 품목 수 결정 // 품목 수 결정
const count = itemCount ?? randomInt(1, 5); const count = itemCount ?? randomInt(1, 5);
// 품목 생성 (동일 카테고리 사용) // 제품 1개 고정 선택 (모델별 인증이라 섞을 수 없음)
const selectedCategory = category || randomPick(PRODUCT_CATEGORIES); const selectedCategory = category || randomPick(PRODUCT_CATEGORIES);
let fixedProductCode = '';
if (products && products.length > 0) {
const categoryProducts = products.filter(p =>
p.category?.toUpperCase() === selectedCategory || !p.category
);
if (categoryProducts.length > 0) {
fixedProductCode = randomPick(categoryProducts).code;
}
}
// 품목 생성 (동일 제품, 수량 1)
const items: QuoteFormItem[] = []; const items: QuoteFormItem[] = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
items.push(generateQuoteFormItem(i, products, selectedCategory)); items.push(generateQuoteFormItem(i, products, selectedCategory, fixedProductCode));
} }
return { return {

View File

@@ -0,0 +1,73 @@
'use client';
import { Settings2, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type { ColumnWithVisibility } from '@/hooks/useColumnSettings';
interface ColumnSettingsPopoverProps {
columns: ColumnWithVisibility[];
onToggle: (key: string) => void;
onReset: () => void;
hasHiddenColumns: boolean;
}
export function ColumnSettingsPopover({
columns,
onToggle,
onReset,
hasHiddenColumns,
}: ColumnSettingsPopoverProps) {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="relative h-8 px-2">
<Settings2 className="h-4 w-4" />
<span className="hidden sm:inline ml-1"></span>
{hasHiddenColumns && (
<span className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-blue-500" />
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-56 p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium"> </span>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={onReset}
>
<RotateCcw className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-1 max-h-[300px] overflow-y-auto">
{columns.map((col) => (
<label
key={col.key}
className={`flex items-center gap-2 px-2 py-1.5 rounded text-sm cursor-pointer hover:bg-muted/50 ${
col.locked ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
<Checkbox
checked={col.visible}
onCheckedChange={() => onToggle(col.key)}
disabled={col.locked}
/>
<span>{col.label}</span>
{col.locked && (
<span className="text-xs text-muted-foreground ml-auto"></span>
)}
</label>
))}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -234,13 +234,55 @@ export function OrderRegistration({
}, []) }, [])
); );
// 아이템을 개소별(floor+code)로 그룹핑 // 아이템을 개소별로 그룹핑
const itemGroups = useMemo(() => { const itemGroups = useMemo(() => {
const calcItems = form.selectedQuotation?.calculationInputs?.items; const calcItems = form.selectedQuotation?.calculationInputs?.items;
if (!calcItems || calcItems.length === 0) { if (!calcItems || calcItems.length === 0) {
return null; return null;
} }
// floor+code 고유 키 수 확인 (모두 같은 값인지 판별)
const uniqueLocKeys = new Set(
calcItems.map(ci => `${ci.floor || '-'}|${ci.code || '-'}`)
);
// 개소가 여러 개인데 floor+code가 모두 동일 → 인덱스 기반 균등 분배
const useIndexGrouping = calcItems.length > 1 && uniqueLocKeys.size === 1;
if (useIndexGrouping) {
const itemsPerLocation = Math.ceil(form.items.length / calcItems.length);
const result: Array<{
key: string;
label: string;
productCode: string;
locationCount: number;
quantity: number;
amount: number;
items: OrderItem[];
}> = [];
calcItems.forEach((ci, idx) => {
const start = idx * itemsPerLocation;
const end = Math.min(start + itemsPerLocation, form.items.length);
const groupItems = form.items.slice(start, end);
const amount = groupItems.reduce((sum, item) => sum + (item.amount ?? 0), 0);
const floor = ci.floor || '-';
const code = ci.code || '-';
result.push({
key: `loc_${idx}`,
label: `${idx + 1}. ${floor} / ${code}`,
productCode: ci.productName || ci.productCode || '',
locationCount: 1,
quantity: ci.quantity ?? 1,
amount,
items: groupItems,
});
});
return result;
}
// floor+code → calculationInput 매핑 (개소 메타정보) // floor+code → calculationInput 매핑 (개소 메타정보)
const locationMetaMap = new Map<string, { const locationMetaMap = new Map<string, {
productCode: string; productCode: string;

View File

@@ -538,8 +538,8 @@ function transformApiToFrontend(apiData: ApiOrder): Order {
note: apiData.note ?? undefined, note: apiData.note ?? undefined,
items: apiData.items?.map(transformItemApiToFrontend) || [], items: apiData.items?.map(transformItemApiToFrontend) || [],
nodes: apiData.root_nodes?.map(transformNodeApiToFrontend) || [], nodes: apiData.root_nodes?.map(transformNodeApiToFrontend) || [],
// 목록 페이지용 추가 필드 // 목록 페이지용 추가 필드: 첫 root_node의 options.product_name (FG 제품명)
productName: apiData.items?.[0]?.item_name ?? undefined, productName: (apiData.root_nodes?.[0]?.options?.product_name as string) || undefined,
receiverAddress: apiData.options?.shipping_address ?? undefined, receiverAddress: apiData.options?.shipping_address ?? undefined,
receiverPlace: apiData.options?.receiver_contact ?? undefined, receiverPlace: apiData.options?.receiver_contact ?? undefined,
frameCount: apiData.root_nodes_sum_quantity ?? apiData.quantity ?? undefined, frameCount: apiData.root_nodes_sum_quantity ?? apiData.quantity ?? undefined,

View File

@@ -513,13 +513,36 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
{(() => { {(() => {
// 개소(층/부호)별로 그룹화 // 개소(층/부호)별로 그룹화
const nodeGroups = new Map<string, { label: string; items: typeof order.items }>(); const nodeGroups = new Map<string, { label: string; items: typeof order.items }>();
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; const uniqueKeys = new Set(order.items.map(item =>
if (!nodeGroups.has(key)) { item.floorCode !== '-' ? item.floorCode : String(item.orderNodeId ?? 'none')
nodeGroups.set(key, { label, items: [] }); ));
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[] = []; const rows: React.ReactNode[] = [];

View File

@@ -271,8 +271,8 @@ export function ScreenWorkLogContent({ data: order, materialLots = [] }: ScreenW
return ( return (
<tr key={item.id}> <tr key={item.id}>
<td className="border border-gray-400 p-1 text-center">{idx + 1}</td> <td className="border border-gray-400 p-1 text-center">{idx + 1}</td>
<td className="border border-gray-400 p-1 text-center text-[10px]">{lotNoDisplay}</td> <td className="border border-gray-400 p-1 text-center">{lotNoDisplay}</td>
<td className="border border-gray-400 p-1 text-[10px]">{item.productName}</td> <td className="border border-gray-400 p-1">{item.productName}</td>
<td className="border border-gray-400 p-1 text-center whitespace-nowrap">{getSymbolCode(item.floorCode)}</td> <td className="border border-gray-400 p-1 text-center whitespace-nowrap">{getSymbolCode(item.floorCode)}</td>
<td className="border border-gray-400 p-1 text-center whitespace-nowrap font-bold text-red-600">{fmt(item.width)}</td> <td className="border border-gray-400 p-1 text-center whitespace-nowrap font-bold text-red-600">{fmt(item.width)}</td>
<td className="border border-gray-400 p-1 text-center whitespace-nowrap font-bold text-red-600">{fmt(item.height)}</td> <td className="border border-gray-400 p-1 text-center whitespace-nowrap font-bold text-red-600">{fmt(item.height)}</td>

View File

@@ -55,9 +55,7 @@ export function BottomBarSection({ bendingInfo, mapping }: BottomBarSectionProps
<td className="border border-gray-400 px-1 py-0.5 text-center">{row.material}</td> <td className="border border-gray-400 px-1 py-0.5 text-center">{row.material}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.length)}</td> <td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.length)}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.quantity)}</td> <td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.quantity)}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center"> <td className="border border-gray-400 px-1 py-0.5 text-center">-</td>
{row.lotPrefix}-
</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td> <td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
</tr> </tr>
))} ))}

View File

@@ -60,9 +60,7 @@ function PartTable({ title, rows, imageUrl, lotNo, baseSize }: {
{row.partName === '하부BASE' ? (baseSize || '-') : fmt(row.length)} {row.partName === '하부BASE' ? (baseSize || '-') : fmt(row.length)}
</td> </td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.quantity)}</td> <td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.quantity)}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center"> <td className="border border-gray-400 px-1 py-0.5 text-center">-</td>
{row.lotPrefix}-
</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td> <td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
</tr> </tr>
))} ))}
@@ -79,11 +77,11 @@ export function GuideRailSection({ bendingInfo, mapping, lotNo }: GuideRailSecti
const productCode = bendingInfo.productCode; const productCode = bendingInfo.productCode;
const wallRows = wall const wallRows = wall
? buildWallGuideRailRows(wall.lengthData, wall.baseSize, mapping) ? buildWallGuideRailRows(wall.lengthData, wall.baseDimension || '135*80', mapping)
: []; : [];
const sideRows = side const sideRows = side
? buildSideGuideRailRows(side.lengthData, mapping) ? buildSideGuideRailRows(side.lengthData, side.baseDimension || '135*130', mapping)
: []; : [];
if (wallRows.length === 0 && sideRows.length === 0) return null; if (wallRows.length === 0 && sideRows.length === 0) return null;
@@ -100,7 +98,7 @@ export function GuideRailSection({ bendingInfo, mapping, lotNo }: GuideRailSecti
rows={wallRows} rows={wallRows}
imageUrl={getBendingImageUrl('guiderail', productCode, 'wall')} imageUrl={getBendingImageUrl('guiderail', productCode, 'wall')}
lotNo={lotNo} lotNo={lotNo}
baseSize={wall?.baseSize} baseSize={wall?.baseDimension || wall?.baseSize}
/> />
)} )}
@@ -110,7 +108,7 @@ export function GuideRailSection({ bendingInfo, mapping, lotNo }: GuideRailSecti
rows={sideRows} rows={sideRows}
imageUrl={getBendingImageUrl('guiderail', productCode, 'side')} imageUrl={getBendingImageUrl('guiderail', productCode, 'side')}
lotNo={lotNo} lotNo={lotNo}
baseSize="135*130" baseSize={side?.baseDimension || '135*130'}
/> />
)} )}
</div> </div>

View File

@@ -70,9 +70,7 @@ function ShutterBoxSubSection({ box, index }: { box: ShutterBoxData; index: numb
<td className="border border-gray-400 px-1 py-0.5 text-center">{row.material}</td> <td className="border border-gray-400 px-1 py-0.5 text-center">{row.material}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{row.dimension}</td> <td className="border border-gray-400 px-1 py-0.5 text-center">{row.dimension}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.quantity)}</td> <td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.quantity)}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center"> <td className="border border-gray-400 px-1 py-0.5 text-center">-</td>
{row.lotPrefix}-
</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td> <td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
</tr> </tr>
))} ))}

View File

@@ -55,7 +55,7 @@ export function SmokeBarrierSection({ bendingInfo }: SmokeBarrierSectionProps) {
<td className="border border-gray-400 px-1 py-0.5 text-center">{row.material}</td> <td className="border border-gray-400 px-1 py-0.5 text-center">{row.material}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.length)}</td> <td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.length)}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.quantity)}</td> <td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.quantity)}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{row.lotCode}</td> <td className="border border-gray-400 px-1 py-0.5 text-center">-</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td> <td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
</tr> </tr>
))} ))}

View File

@@ -14,7 +14,8 @@ export interface LengthQuantity {
// 가이드레일 타입별 데이터 // 가이드레일 타입별 데이터
export interface GuideRailTypeData { export interface GuideRailTypeData {
lengthData: LengthQuantity[]; lengthData: LengthQuantity[];
baseSize: string; // "135*80" 또는 "135*130" baseSize: string; // BOM 프로파일 사이즈 "130*75" (섹션 제목용)
baseDimension?: string; // 실제 하부BASE 물리 치수 "135*130" (작업일지 표시/무게계산용)
} }
// 셔터박스 데이터 // 셔터박스 데이터

View File

@@ -27,9 +27,8 @@ const EGI_DENSITY = 7.85; // g/cm3
// 가이드레일 // 가이드레일
const WALL_PART_WIDTH = 412; // mm - 벽면형 파트 폭 const WALL_PART_WIDTH = 412; // mm - 벽면형 파트 폭
const SIDE_PART_WIDTH = 462; // mm - 측면형 파트 폭 const SIDE_PART_WIDTH = 462; // mm - 측면형 파트 폭
const WALL_BASE_HEIGHT_MIXED = 80; // 혼합형 벽면 하부BASE 높이 const WALL_BASE_HEIGHT = 80; // 벽면 하부BASE 높이 (legacy: wall_basesize 135*80)
const WALL_BASE_HEIGHT_ONLY = 130; // 면형 단독 하부BASE 높이 const SIDE_BASE_HEIGHT = 130; // 면형 하부BASE 높이 (legacy: side_basesize 135*130)
const SIDE_BASE_HEIGHT = 130; // 측면형 하부BASE 높이
const BASE_WIDTH = 135; // 하부BASE 폭 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 SHUTTER_BOX_LENGTH_BUCKETS = [1219, 2438, 3000, 3500, 4000, 4150];
const SMOKE_W50_LENGTH_BUCKETS = [2438, 3000, 3500, 4000, 4300]; 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) // 핵심 함수: calWeight (PHP Lines 27-55)
// ============================================================ // ============================================================
@@ -165,12 +179,10 @@ export function getSLengthCode(length: number, category: string): string | null
*/ */
export function buildWallGuideRailRows( export function buildWallGuideRailRows(
lengthData: LengthQuantity[], lengthData: LengthQuantity[],
baseSize: string, baseDimension: string,
mapping: MaterialMapping, mapping: MaterialMapping,
): GuideRailPartRow[] { ): GuideRailPartRow[] {
const rows: GuideRailPartRow[] = []; const rows: GuideRailPartRow[] = [];
const baseHeight = baseSize === '135*80' ? WALL_BASE_HEIGHT_MIXED : WALL_BASE_HEIGHT_ONLY;
for (const ld of lengthData) { for (const ld of lengthData) {
if (ld.quantity <= 0) continue; if (ld.quantity <= 0) continue;
@@ -211,9 +223,11 @@ export function buildWallGuideRailRows(
} }
// 하부BASE (길이 데이터와 무관하게 1행) // 하부BASE (길이 데이터와 무관하게 1행)
// baseDimension: "135*130" (KQTS01/KTE01) 또는 "135*80" (기타)
const totalQty = lengthData.reduce((sum, ld) => sum + ld.quantity, 0); const totalQty = lengthData.reduce((sum, ld) => sum + ld.quantity, 0);
if (totalQty > 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({ rows.push({
partName: '하부BASE', lotPrefix: 'XX', material: 'EGI 1.55T', partName: '하부BASE', lotPrefix: 'XX', material: 'EGI 1.55T',
length: 0, quantity: totalQty, weight: Math.round(baseW.weight * totalQty * 100) / 100, length: 0, quantity: totalQty, weight: Math.round(baseW.weight * totalQty * 100) / 100,
@@ -228,6 +242,7 @@ export function buildWallGuideRailRows(
*/ */
export function buildSideGuideRailRows( export function buildSideGuideRailRows(
lengthData: LengthQuantity[], lengthData: LengthQuantity[],
baseDimension: string,
mapping: MaterialMapping, mapping: MaterialMapping,
): GuideRailPartRow[] { ): GuideRailPartRow[] {
const rows: GuideRailPartRow[] = []; const rows: GuideRailPartRow[] = [];
@@ -259,9 +274,11 @@ export function buildSideGuideRailRows(
} }
// 하부BASE // 하부BASE
// baseDimension: "135*130" (측면형은 항상 135*130)
const totalQty = lengthData.reduce((sum, ld) => sum + ld.quantity, 0); const totalQty = lengthData.reduce((sum, ld) => sum + ld.quantity, 0);
if (totalQty > 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({ rows.push({
partName: '하부BASE', lotPrefix: 'XX', material: 'EGI 1.55T', partName: '하부BASE', lotPrefix: 'XX', material: 'EGI 1.55T',
length: 0, quantity: totalQty, weight: Math.round(baseW.weight * totalQty * 100) / 100, 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 { width: boxWidth, height: boxHeight } = parseBoxSize(box.size);
const isStandard = box.size === '500*380'; 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) { if (isStandard) {
// 표준 500*380 구성 parts = [
const parts = [ { name: '①전면부', prefix: 'CF', dim: boxHeight + 122 },
{ name: '①전면부', prefix: 'CF', dim: boxHeight + 122 }, { name: '②린텔부', prefix: 'CL', dim: boxWidth - 330 },
{ name: '②린텔부', prefix: 'CL', dim: boxWidth - 330 }, { name: '③⑤점검구', prefix: 'CP', dim: boxWidth - 200 },
{ name: '③⑤점검구', prefix: 'CP', dim: boxWidth - 200 }, { name: '④후면코너부', prefix: 'CB', dim: 170 },
{ name: '④후면코너부', prefix: 'CB', dim: 170 }, ];
]; } else if (box.direction === '양면') {
for (const p of parts) { parts = [
const w = calcWeight(BOX_FINISH_MATERIAL, p.dim, ld.length); { name: '①전면부', prefix: 'XX', dim: boxHeight + 122 },
rows.push({ { name: '②린텔부', prefix: 'CL', dim: boxWidth - 330 },
partName: p.name, lotPrefix: p.prefix, material: BOX_FINISH_MATERIAL, { name: '③점검구', prefix: 'XX', dim: boxWidth - 200 },
dimension: `${ld.length}`, quantity: ld.quantity, { name: '④후면코너부', prefix: 'CB', dim: 170 },
weight: Math.round(w.weight * ld.quantity * 100) / 100, { name: '⑤점검구', prefix: 'XX', dim: boxHeight - 100 },
}); ];
} } else if (box.direction === '밑면') {
} else if (box.direction === '양면') { parts = [
const parts = [ { name: '①전면부', prefix: 'XX', dim: boxHeight + 122 },
{ name: '①전면부', prefix: 'XX', dim: boxHeight + 122 }, { name: '②린텔부', prefix: 'CL', dim: boxWidth - 330 },
{ name: '②린텔부', prefix: 'CL', dim: boxWidth - 330 }, { name: '③점검구', prefix: 'XX', dim: boxWidth - 200 },
{ name: '③점검구', prefix: 'XX', dim: boxWidth - 200 }, { name: '④후면부', prefix: 'CB', dim: boxHeight + 85 * 2 },
{ name: '④후면코너부', prefix: 'CB', dim: 170 }, ];
{ name: '⑤점검구', prefix: 'XX', dim: boxHeight - 100 }, } else if (box.direction === '후면') {
]; parts = [
for (const p of parts) { { name: '①전면부', prefix: 'XX', dim: boxHeight + 122 },
const w = calcWeight(BOX_FINISH_MATERIAL, p.dim, ld.length); { name: '②린텔부', prefix: 'CL', dim: boxWidth + 85 * 2 },
rows.push({ { name: '③점검구', prefix: 'XX', dim: boxHeight - 200 },
partName: p.name, lotPrefix: p.prefix, material: BOX_FINISH_MATERIAL, { name: '④후면코너부', prefix: 'CB', dim: boxHeight + 85 * 2 },
dimension: `${ld.length}`, quantity: ld.quantity, ];
weight: Math.round(w.weight * ld.quantity * 100) / 100, } else {
}); parts = [];
} }
} else if (box.direction === '밑면') {
const parts = [ // 구성요소 기준 정렬: 파트 → 길이 순서
{ name: '①전면부', prefix: 'XX', dim: boxHeight + 122 }, for (const p of parts) {
{ name: '②린텔부', prefix: 'CL', dim: boxWidth - 330 }, for (const ld of box.lengthData) {
{ name: '③점검구', prefix: 'XX', dim: boxWidth - 200 }, if (ld.quantity <= 0) continue;
{ name: '④후면부', prefix: 'CB', dim: boxHeight + 85 * 2 }, const w = calcWeight(BOX_FINISH_MATERIAL, p.dim, ld.length);
]; rows.push({
for (const p of parts) { partName: p.name, lotPrefix: p.prefix, material: BOX_FINISH_MATERIAL,
const w = calcWeight(BOX_FINISH_MATERIAL, p.dim, ld.length); dimension: `${ld.length}`, quantity: ld.quantity,
rows.push({ weight: Math.round(w.weight * ld.quantity * 100) / 100,
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 && box.coverQty > 0) { if (box.coverQty > 0) {
const coverWidth = boxWidth - 111; const coverWidth = boxWidth - 111;
const w = calcWeight(BOX_FINISH_MATERIAL, coverWidth, BOX_COVER_LENGTH); const w = calcWeight(BOX_FINISH_MATERIAL, coverWidth, BOX_COVER_LENGTH);
rows.push({ 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, dimension: `1219 * ${coverWidth}`, quantity: box.coverQty,
weight: Math.round(w.weight * box.coverQty * 100) / 100, weight: Math.round(w.weight * box.coverQty * 100) / 100,
}); });
} }
// 마구리 (비표준일 때) // 마구리 (레거시: 무게는 원본 box 크기로 계산, 표시 치수만 +5)
if (!isStandard && box.finCoverQty > 0) { if (box.finCoverQty > 0) {
const finWidth = boxWidth + 5; const w = calcWeight(BOX_FINISH_MATERIAL, boxWidth, boxHeight);
const finHeight = boxHeight + 5;
const w = calcWeight(BOX_FINISH_MATERIAL, finWidth, finHeight);
rows.push({ rows.push({
partName: '측면부(마구리)', lotPrefix: 'XX', material: BOX_FINISH_MATERIAL, partName: isStandard ? '측면부(마구리)' : (box.direction === '양면' ? '⑦측면부(마구리)' : '⑥측면부(마구리)'),
dimension: `${finWidth} * ${finHeight}`, quantity: box.finCoverQty, lotPrefix: 'XX', material: BOX_FINISH_MATERIAL,
dimension: `${boxWidth + 5} * ${boxHeight + 5}`, quantity: box.finCoverQty,
weight: Math.round(w.weight * box.finCoverQty * 100) / 100, weight: Math.round(w.weight * box.finCoverQty * 100) / 100,
}); });
} }
@@ -493,8 +491,6 @@ export function calculateProductionSummary(
// 가이드레일 - 벽면형 // 가이드레일 - 벽면형
if (bendingInfo.guideRail.wall) { 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) { for (const ld of bendingInfo.guideRail.wall.lengthData) {
if (ld.quantity <= 0) continue; if (ld.quantity <= 0) continue;
addWeight(mapping.guideRailFinish, WALL_PART_WIDTH, ld.length, ld.quantity); 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); 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); addWeight(mapping.bodyMaterial, SIDE_PART_WIDTH, ld.length, ld.quantity * 3);
} }
const totalSideQty = bendingInfo.guideRail.side.lengthData.reduce((s, l) => s + l.quantity, 0); 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);
} }
// 하단마감재 // 하단마감재

View File

@@ -75,7 +75,7 @@ export function WorkOrderListPanel({
</div> </div>
{/* 품목명 */} {/* 품목명 */}
<p className="text-xs text-gray-600 truncate ml-8">{order.productName}</p> <p className="text-sm text-gray-600 truncate ml-8">{order.productName}</p>
{/* 현장명 + 수량 */} {/* 현장명 + 수량 */}
<div className="flex items-center justify-between mt-1.5 ml-8"> <div className="flex items-center justify-between mt-1.5 ml-8">

View File

@@ -134,10 +134,6 @@ export function LocationListPanel({
// 개소 추가 (BOM 계산 성공 시에만 폼 초기화) // 개소 추가 (BOM 계산 성공 시에만 폼 초기화)
const handleAdd = useCallback(async () => { const handleAdd = useCallback(async () => {
// 유효성 검사 // 유효성 검사
if (!formData.floor || !formData.code) {
toast.error("층과 부호를 입력해주세요.");
return;
}
if (!formData.openWidth || !formData.openHeight) { if (!formData.openWidth || !formData.openHeight) {
toast.error("가로와 세로를 입력해주세요."); toast.error("가로와 세로를 입력해주세요.");
return; return;
@@ -150,8 +146,8 @@ export function LocationListPanel({
const product = finishedGoods.find((fg) => fg.item_code === formData.productCode); const product = finishedGoods.find((fg) => fg.item_code === formData.productCode);
const newLocation: Omit<LocationItem, "id"> = { const newLocation: Omit<LocationItem, "id"> = {
floor: formData.floor, floor: formData.floor || "-",
code: formData.code, code: formData.code || "-",
openWidth: parseFloat(formData.openWidth) || 0, openWidth: parseFloat(formData.openWidth) || 0,
openHeight: parseFloat(formData.openHeight) || 0, openHeight: parseFloat(formData.openHeight) || 0,
productCode: formData.productCode, productCode: formData.productCode,

View File

@@ -221,47 +221,49 @@ export function QuoteRegistration({
// DevFill (개발/테스트용 자동 채우기) // DevFill (개발/테스트용 자동 채우기)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
useDevFill("quoteV2", useCallback(() => { useDevFill("quoteV2", useCallback(() => {
// BOM이 있는 제품만 필터링 // 제품 1개 고정 선택 (모델별 인증이라 섞을 수 없음)
const productsWithBom = finishedGoods.filter((fg) => fg.has_bom === true || (fg.bom && Array.isArray(fg.bom) && fg.bom.length > 0)); const fixedProduct = finishedGoods.length > 0
? finishedGoods[Math.floor(Math.random() * finishedGoods.length)]
: null;
// 랜덤 개소 생성 함수 // 층 순서 (정렬된 상태로 순차 할당)
const createRandomLocation = (index: number): LocationItem => { const sortedFloors = ["B2", "B1", "1F", "2F", "3F", "4F", "5F", "R"];
const floors = ["B2", "B1", "1F", "2F", "3F", "4F", "5F", "R"]; // 부호 접두사 1개 고정
const codePrefix = ["SD", "FSS", "FD", "SS", "DS"]; const codePrefixes = ["SD", "FSS", "FD", "SS", "DS"];
const guideRailTypes = ["wall", "floor", "mixed"]; const fixedPrefix = codePrefixes[Math.floor(Math.random() * codePrefixes.length)];
const motorPowers = ["single", "three"];
const controllers = ["basic", "smart", "premium"];
const randomFloor = floors[Math.floor(Math.random() * floors.length)]; const guideRailTypes = ["wall", "floor", "mixed"];
const randomPrefix = codePrefix[Math.floor(Math.random() * codePrefix.length)]; const motorPowers = ["single", "three"];
const randomWidth = (Math.floor(Math.random() * 40) + 20) * 100; // 2000~6000 (100단위) const controllers = ["basic", "smart", "premium"];
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)];
return { // 1~5개 랜덤 개소 생성
id: `loc-${Date.now()}-${index}`, const locationCount = Math.floor(Math.random() * 5) + 1;
floor: randomFloor,
code: `${randomPrefix}-${String(index + 1).padStart(2, "0")}`, // 층을 순차 할당할 시작 인덱스 (랜덤 시작점, 순서대로 올라감)
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, openWidth: randomWidth,
openHeight: randomHeight, openHeight: randomHeight,
productCode: randomProduct?.item_code || "FG-SCR-001", productCode: fixedProduct?.item_code || "FG-SCR-001",
productName: randomProduct?.item_name || "방화 스크린 셔터 (소형)", productName: fixedProduct?.item_name || "방화 스크린 셔터 (소형)",
quantity: Math.floor(Math.random() * 3) + 1, // 1~3 quantity: 1,
guideRailType: guideRailTypes[Math.floor(Math.random() * guideRailTypes.length)], guideRailType: guideRailTypes[Math.floor(Math.random() * guideRailTypes.length)],
motorPower: motorPowers[Math.floor(Math.random() * motorPowers.length)], motorPower: motorPowers[Math.floor(Math.random() * motorPowers.length)],
controller: controllers[Math.floor(Math.random() * controllers.length)], controller: controllers[Math.floor(Math.random() * controllers.length)],
wingSize: [50, 60, 70][Math.floor(Math.random() * 3)], wingSize: [50, 60, 70][Math.floor(Math.random() * 3)],
inspectionFee: [50000, 60000, 70000][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); const source = formData.locations.find((loc) => loc.id === locationId);
if (!source) return; if (!source) return;
// 부호에서 접두어와 번호 분리 (예: "DS-01" → prefix="DS-", num=1) // 층/부호가 없거나 "-"이면 그대로 유지
const codeMatch = source.code.match(/^(.*?)(\d+)$/); let newFloor = source.floor || "-";
let newCode = source.code + "-copy"; let newCode = source.code || "-";
if (codeMatch) { if (newCode !== "-") {
const prefix = codeMatch[1]; // "DS-" // 부호에서 접두어와 번호 분리 (예: "DS-01" → prefix="DS-", num=1)
const numLength = codeMatch[2].length; // 2 (자릿수 보존) const codeMatch = source.code.match(/^(.*?)(\d+)$/);
if (codeMatch) {
const prefix = codeMatch[1]; // "DS-"
const numLength = codeMatch[2].length; // 2 (자릿수 보존)
// 같은 접두어를 가진 부호 중 최대 번호 찾기 // 같은 접두어를 가진 부호 중 최대 번호 찾기
let maxNum = 0; let maxNum = 0;
formData.locations.forEach((loc) => { formData.locations.forEach((loc) => {
const m = loc.code.match(new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)$`)); const m = loc.code.match(new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)$`));
if (m) { if (m) {
maxNum = Math.max(maxNum, parseInt(m[1], 10)); 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 = { const clonedLocation: LocationItem = {
...source, ...source,
id: `loc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, id: `loc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
floor: newFloor,
code: newCode, code: newCode,
}; };

View File

@@ -309,7 +309,6 @@ export async function getFinishedGoods(category?: string): Promise<{
const result = await executeServerAction<FGApiResponse | Record<string, unknown>[]>({ const result = await executeServerAction<FGApiResponse | Record<string, unknown>[]>({
url: buildApiUrl('/api/v1/items', { url: buildApiUrl('/api/v1/items', {
item_type: 'FG', item_type: 'FG',
has_bom: '1',
item_category: category, item_category: category,
size: '5000', size: '5000',
}), }),

View File

@@ -766,6 +766,7 @@ export function transformV2ToApi(
total_price: number; total_price: number;
sort_order: number; sort_order: number;
note: string | null; note: string | null;
formula_source?: string;
item_index?: number; item_index?: number;
finished_goods_code?: string; finished_goods_code?: string;
formula_category?: string; formula_category?: string;
@@ -796,7 +797,8 @@ export function transformV2ToApi(
unit_price: bomItem.unit_price, unit_price: bomItem.unit_price,
total_price: bomItem.unit_price * calcQty, total_price: bomItem.unit_price * calcQty,
sort_order: sortOrder++, 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, item_index: locIndex,
finished_goods_code: bomResult.finished_goods.code, finished_goods_code: bomResult.finished_goods.code,
formula_category: bomItem.process_group || undefined, formula_category: bomItem.process_group || undefined,
@@ -827,7 +829,8 @@ export function transformV2ToApi(
unit_price: bomItem.unit_price, unit_price: bomItem.unit_price,
total_price: bomItem.unit_price * calcQty, total_price: bomItem.unit_price * calcQty,
sort_order: sortOrder++, 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, item_index: locIndex,
finished_goods_code: loc.bomResult!.finished_goods.code, finished_goods_code: loc.bomResult!.finished_goods.code,
formula_category: bomItem.process_group || undefined, formula_category: bomItem.process_group || undefined,
@@ -850,7 +853,8 @@ export function transformV2ToApi(
unit_price: loc.unitPrice || loc.inspectionFee || 0, unit_price: loc.unitPrice || loc.inspectionFee || 0,
total_price: loc.totalPrice || (loc.unitPrice || loc.inspectionFee || 0) * loc.quantity, total_price: loc.totalPrice || (loc.unitPrice || loc.inspectionFee || 0) * loc.quantity,
sort_order: index + 1, 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<string,
total_price: number; total_price: number;
sort_order: number; sort_order: number;
note: string | null; note: string | null;
formula_source?: string;
item_index?: number; item_index?: number;
finished_goods_code?: string; finished_goods_code?: string;
formula_category?: string; formula_category?: string;
@@ -1058,7 +1063,8 @@ export function transformFormDataToApi(formData: QuoteFormData): Record<string,
unit_price: bomItem.unit_price, unit_price: bomItem.unit_price,
total_price: totalPrice, total_price: totalPrice,
sort_order: sortOrder++, sort_order: sortOrder++,
note: `${formItem?.floor || ''} ${formItem?.code || ''}`.trim() || null, note: [formItem?.floor, formItem?.code].filter(v => v && v !== '-').join(' ') || null,
formula_source: `product_${calcItem.index}`,
item_index: calcItem.index, item_index: calcItem.index,
finished_goods_code: calcItem.result.finished_goods.code, finished_goods_code: calcItem.result.finished_goods.code,
formula_category: bomItem.process_group || undefined, formula_category: bomItem.process_group || undefined,
@@ -1084,7 +1090,8 @@ export function transformFormDataToApi(formData: QuoteFormData): Record<string,
unit_price: unitPrice, unit_price: unitPrice,
total_price: supplyAmount, total_price: supplyAmount,
sort_order: index + 1, sort_order: index + 1,
note: `${item.floor || ''} ${item.code || ''}`.trim() || null, note: [item.floor, item.code].filter(v => v && v !== '-').join(' ') || null,
formula_source: `product_${index}`,
}; };
}); });
} }

View File

@@ -240,6 +240,13 @@ export interface IntegratedListTemplateV2Props<T = any> {
// 로딩 상태 // 로딩 상태
isLoading?: boolean; isLoading?: boolean;
// ===== 컬럼 리사이즈 & 가시성 설정 (opt-in) =====
columnSettings?: {
columnWidths: Record<string, number>;
onColumnResize: (columnKey: string, width: number) => void;
settingsPopover: ReactNode;
};
} }
export function IntegratedListTemplateV2<T = any>({ export function IntegratedListTemplateV2<T = any>({
@@ -299,6 +306,7 @@ export function IntegratedListTemplateV2<T = any>({
pagination, pagination,
devMetadata, devMetadata,
isLoading, isLoading,
columnSettings,
}: IntegratedListTemplateV2Props<T>) { }: IntegratedListTemplateV2Props<T>) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
@@ -785,6 +793,7 @@ export function IntegratedListTemplateV2<T = any>({
))} ))}
{tableHeaderActions} {tableHeaderActions}
{renderAutoFilters()} {renderAutoFilters()}
{columnSettings?.settingsPopover}
</div> </div>
</div> </div>
</div> </div>
@@ -929,6 +938,17 @@ export function IntegratedListTemplateV2<T = any>({
/> />
) : ( ) : (
<Table className="table-fixed"> <Table className="table-fixed">
{columnSettings && (
<colgroup>
{showCheckbox && <col style={{ width: 50 }} />}
{tableColumns.map((col) => (
<col
key={col.key}
style={columnSettings.columnWidths[col.key] ? { width: columnSettings.columnWidths[col.key] } : undefined}
/>
))}
</colgroup>
)}
<TableHeader> <TableHeader>
<TableRow> <TableRow>
{renderCustomTableHeader ? ( {renderCustomTableHeader ? (
@@ -953,7 +973,7 @@ export function IntegratedListTemplateV2<T = any>({
return ( return (
<TableHead <TableHead
key={column.key} key={column.key}
className={`${column.className || ''} ${isSortable ? 'cursor-pointer select-none hover:bg-muted/50' : ''}`} className={`${column.className || ''} ${isSortable ? 'cursor-pointer select-none hover:bg-muted/50' : ''} ${columnSettings ? 'relative' : ''}`}
onClick={isSortable ? () => onSort(column.key) : undefined} onClick={isSortable ? () => onSort(column.key) : undefined}
> >
{column.key === "actions" && selectedItems.size === 0 ? "" : ( {column.key === "actions" && selectedItems.size === 0 ? "" : (
@@ -974,6 +994,33 @@ export function IntegratedListTemplateV2<T = any>({
)} )}
</div> </div>
)} )}
{columnSettings && (
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-blue-400 active:bg-blue-500 z-10"
onMouseDown={(e) => {
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);
}}
/>
)}
</TableHead> </TableHead>
); );
})} })}

View File

@@ -11,9 +11,11 @@
* - 클라이언트 사이드 필터링/페이지네이션 (clientSideFiltering: true) * - 클라이언트 사이드 필터링/페이지네이션 (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 { useRouter, useParams } from 'next/navigation';
import { usePermission } from '@/hooks/usePermission'; import { usePermission } from '@/hooks/usePermission';
import { useColumnSettings } from '@/hooks/useColumnSettings';
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Download, Loader2 } from 'lucide-react'; import { Download, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -818,6 +820,46 @@ export function UniversalListPage<T>({
})); }));
}, [config.columns, config.columnsPerTab, activeTab]); }, [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: (
<ColumnSettingsPopover
columns={allColumnsWithVisibility}
onToggle={toggleColumnVisibility}
onReset={resetSettings}
hasHiddenColumns={hasHiddenColumns}
/>
),
};
}, [enableColumnSettings, columnWidths, setColumnWidth, allColumnsWithVisibility, toggleColumnVisibility, resetSettings, hasHiddenColumns]);
// ===== ID로 아이템 찾기 헬퍼 ===== // ===== ID로 아이템 찾기 헬퍼 =====
const getItemById = useCallback( const getItemById = useCallback(
(id: string): T | undefined => { (id: string): T | undefined => {
@@ -852,19 +894,33 @@ export function UniversalListPage<T>({
); );
// ===== 렌더링 함수 래퍼 ===== // ===== 렌더링 함수 래퍼 =====
const showCheckbox = config.showCheckbox !== false;
const renderTableRow = useCallback( const renderTableRow = useCallback(
(item: T, index: number, globalIndex: number) => { (item: T, index: number, globalIndex: number) => {
const id = effectiveGetItemId(item); const id = effectiveGetItemId(item);
const isSelected = effectiveSelectedItems.has(id); const isSelected = effectiveSelectedItems.has(id);
return config.renderTableRow(item, index, globalIndex, { const row = config.renderTableRow(item, index, globalIndex, {
isSelected, isSelected,
onToggle: () => toggleSelection(id), onToggle: () => toggleSelection(id),
onRowClick: () => handleRowClick(item), onRowClick: () => handleRowClick(item),
onEdit: () => handleEdit(item), onEdit: () => handleEdit(item),
onDelete: permCanDelete ? () => handleDeleteClick(item) : undefined, 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( const renderMobileCard = useCallback(
@@ -955,8 +1011,10 @@ export function UniversalListPage<T>({
}) })
: config.tableHeaderActions : config.tableHeaderActions
} }
// 테이블 컬럼 (탭별 다른 컬럼 지원) // 테이블 컬럼 (가시성 필터링 적용)
tableColumns={effectiveColumns} tableColumns={templateColumns}
// 컬럼 리사이즈 & 가시성 설정
columnSettings={templateColumnSettings}
// 정렬 설정 (모든 페이지에서 활성화) // 정렬 설정 (모든 페이지에서 활성화)
sortBy={sortBy} sortBy={sortBy}
sortOrder={sortOrder} sortOrder={sortOrder}

View File

@@ -413,6 +413,10 @@ export interface UniversalListConfig<T> {
/** 검색어 변경 콜백 (config 내부에서 설정, 서버 사이드 검색용) */ /** 검색어 변경 콜백 (config 내부에서 설정, 서버 사이드 검색용) */
onSearchChange?: (search: string) => void; onSearchChange?: (search: string) => void;
// ===== 컬럼 리사이즈 & 가시성 설정 =====
/** 컬럼 설정 비활성화 (기본: false = 자동 활성화) */
disableColumnSettings?: boolean;
// ===== 커스텀 다이얼로그 슬롯 ===== // ===== 커스텀 다이얼로그 슬롯 =====
/** /**
* 커스텀 다이얼로그 렌더링 (DocumentDetailModal, SalaryDetailDialog 등) * 커스텀 다이얼로그 렌더링 (DocumentDetailModal, SalaryDetailDialog 등)

View File

@@ -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,
};
}

View File

@@ -0,0 +1,101 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { safeJsonParse } from '@/lib/utils';
interface PageColumnSettings {
columnWidths: Record<string, number>;
hiddenColumns: string[];
}
interface TableColumnState {
pageSettings: Record<string, PageColumnSettings>;
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<Record<string, unknown> | 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<TableColumnState>()(
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);
},
},
}
)
);