Merge branch 'master' of http://114.203.209.83:3000/SamProject/sam-react-prod
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -120,3 +120,6 @@ src/app/**/dev/page-builder/
|
||||
|
||||
# ---> Dev Dashboard Prototypes (디자인 프로토타입 - 로컬 전용)
|
||||
src/app/**/dev/dashboard/
|
||||
|
||||
# ---> Serena MCP memories
|
||||
.serena/
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
- ✅ 견적 화면에서 조인트바 BOM 표시 확인 완료
|
||||
- ⬜ 절곡 실 데이터 테스트 (bending_info 채워진 작업지시로 확인)
|
||||
|
||||
### 관련 API 커밋
|
||||
- `23029b1` (api) fix: 작업지시 단건조회(show)에 materialInputs eager loading 추가
|
||||
|
||||
### 관련 문서
|
||||
- `docs/plans/bending-worklog-reimplementation-plan.md` (✅ 완료)
|
||||
|
||||
|
||||
@@ -43,13 +43,16 @@ const WRITERS = ['드미트리', '김철수', '이영희', '박지민', '최서
|
||||
export function generateQuoteFormItem(
|
||||
index: number,
|
||||
products?: Array<{ code: string; name: string; category?: string }>,
|
||||
category?: string
|
||||
category?: string,
|
||||
fixedProductCode?: string
|
||||
): QuoteFormItem {
|
||||
const selectedCategory = category || randomPick(PRODUCT_CATEGORIES);
|
||||
|
||||
// 카테고리에 맞는 제품 필터링
|
||||
let productCode = '';
|
||||
if (products && products.length > 0) {
|
||||
if (fixedProductCode) {
|
||||
productCode = fixedProductCode;
|
||||
} else if (products && products.length > 0) {
|
||||
const categoryProducts = products.filter(p =>
|
||||
p.category?.toUpperCase() === selectedCategory || !p.category
|
||||
);
|
||||
@@ -70,7 +73,7 @@ export function generateQuoteFormItem(
|
||||
guideRailType: randomPick(GUIDE_RAIL_TYPES),
|
||||
motorPower: randomPick(MOTOR_POWERS),
|
||||
controller: randomPick(CONTROLLERS),
|
||||
quantity: randomInt(1, 10),
|
||||
quantity: 1,
|
||||
wingSize: '50',
|
||||
inspectionFee: 50000,
|
||||
};
|
||||
@@ -104,11 +107,22 @@ export function generateQuoteData(options: GenerateQuoteDataOptions = {}): Quote
|
||||
// 품목 수 결정
|
||||
const count = itemCount ?? randomInt(1, 5);
|
||||
|
||||
// 품목 생성 (동일 카테고리 사용)
|
||||
// 제품 1개 고정 선택 (모델별 인증이라 섞을 수 없음)
|
||||
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[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
items.push(generateQuoteFormItem(i, products, selectedCategory));
|
||||
items.push(generateQuoteFormItem(i, products, selectedCategory, fixedProductCode));
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
73
src/components/molecules/ColumnSettingsPopover.tsx
Normal file
73
src/components/molecules/ColumnSettingsPopover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -234,13 +234,55 @@ export function OrderRegistration({
|
||||
}, [])
|
||||
);
|
||||
|
||||
// 아이템을 개소별(floor+code)로 그룹핑
|
||||
// 아이템을 개소별로 그룹핑
|
||||
const itemGroups = useMemo(() => {
|
||||
const calcItems = form.selectedQuotation?.calculationInputs?.items;
|
||||
if (!calcItems || calcItems.length === 0) {
|
||||
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 매핑 (개소 메타정보)
|
||||
const locationMetaMap = new Map<string, {
|
||||
productCode: string;
|
||||
|
||||
@@ -538,8 +538,8 @@ function transformApiToFrontend(apiData: ApiOrder): Order {
|
||||
note: apiData.note ?? undefined,
|
||||
items: apiData.items?.map(transformItemApiToFrontend) || [],
|
||||
nodes: apiData.root_nodes?.map(transformNodeApiToFrontend) || [],
|
||||
// 목록 페이지용 추가 필드
|
||||
productName: apiData.items?.[0]?.item_name ?? undefined,
|
||||
// 목록 페이지용 추가 필드: 첫 root_node의 options.product_name (FG 제품명)
|
||||
productName: (apiData.root_nodes?.[0]?.options?.product_name as string) || undefined,
|
||||
receiverAddress: apiData.options?.shipping_address ?? undefined,
|
||||
receiverPlace: apiData.options?.receiver_contact ?? undefined,
|
||||
frameCount: apiData.root_nodes_sum_quantity ?? apiData.quantity ?? undefined,
|
||||
|
||||
@@ -513,13 +513,36 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
{(() => {
|
||||
// 개소(층/부호)별로 그룹화
|
||||
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;
|
||||
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[] = [];
|
||||
|
||||
@@ -271,8 +271,8 @@ export function ScreenWorkLogContent({ data: order, materialLots = [] }: ScreenW
|
||||
return (
|
||||
<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 text-[10px]">{lotNoDisplay}</td>
|
||||
<td className="border border-gray-400 p-1 text-[10px]">{item.productName}</td>
|
||||
<td className="border border-gray-400 p-1 text-center">{lotNoDisplay}</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 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>
|
||||
|
||||
@@ -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">{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">
|
||||
{row.lotPrefix}-
|
||||
</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>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -60,9 +60,7 @@ function PartTable({ title, rows, imageUrl, lotNo, baseSize }: {
|
||||
{row.partName === '하부BASE' ? (baseSize || '-') : 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">
|
||||
{row.lotPrefix}-
|
||||
</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>
|
||||
</tr>
|
||||
))}
|
||||
@@ -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'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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.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">
|
||||
{row.lotPrefix}-
|
||||
</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>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -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">{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">{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>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -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" (작업일지 표시/무게계산용)
|
||||
}
|
||||
|
||||
// 셔터박스 데이터
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// 하단마감재
|
||||
|
||||
@@ -75,7 +75,7 @@ export function WorkOrderListPanel({
|
||||
</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">
|
||||
|
||||
@@ -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<LocationItem, "id"> = {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -309,7 +309,6 @@ export async function getFinishedGoods(category?: string): Promise<{
|
||||
const result = await executeServerAction<FGApiResponse | Record<string, unknown>[]>({
|
||||
url: buildApiUrl('/api/v1/items', {
|
||||
item_type: 'FG',
|
||||
has_bom: '1',
|
||||
item_category: category,
|
||||
size: '5000',
|
||||
}),
|
||||
|
||||
@@ -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<string,
|
||||
total_price: number;
|
||||
sort_order: number;
|
||||
note: string | null;
|
||||
formula_source?: string;
|
||||
item_index?: number;
|
||||
finished_goods_code?: string;
|
||||
formula_category?: string;
|
||||
@@ -1058,7 +1063,8 @@ export function transformFormDataToApi(formData: QuoteFormData): Record<string,
|
||||
unit_price: bomItem.unit_price,
|
||||
total_price: totalPrice,
|
||||
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,
|
||||
finished_goods_code: calcItem.result.finished_goods.code,
|
||||
formula_category: bomItem.process_group || undefined,
|
||||
@@ -1084,7 +1090,8 @@ export function transformFormDataToApi(formData: QuoteFormData): Record<string,
|
||||
unit_price: unitPrice,
|
||||
total_price: supplyAmount,
|
||||
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}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -240,6 +240,13 @@ export interface IntegratedListTemplateV2Props<T = any> {
|
||||
|
||||
// 로딩 상태
|
||||
isLoading?: boolean;
|
||||
|
||||
// ===== 컬럼 리사이즈 & 가시성 설정 (opt-in) =====
|
||||
columnSettings?: {
|
||||
columnWidths: Record<string, number>;
|
||||
onColumnResize: (columnKey: string, width: number) => void;
|
||||
settingsPopover: ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
export function IntegratedListTemplateV2<T = any>({
|
||||
@@ -299,6 +306,7 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
pagination,
|
||||
devMetadata,
|
||||
isLoading,
|
||||
columnSettings,
|
||||
}: IntegratedListTemplateV2Props<T>) {
|
||||
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
@@ -785,6 +793,7 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
))}
|
||||
{tableHeaderActions}
|
||||
{renderAutoFilters()}
|
||||
{columnSettings?.settingsPopover}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -929,6 +938,17 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
<TableRow>
|
||||
{renderCustomTableHeader ? (
|
||||
@@ -953,7 +973,7 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
return (
|
||||
<TableHead
|
||||
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}
|
||||
>
|
||||
{column.key === "actions" && selectedItems.size === 0 ? "" : (
|
||||
@@ -974,6 +994,33 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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<T>({
|
||||
}));
|
||||
}, [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로 아이템 찾기 헬퍼 =====
|
||||
const getItemById = useCallback(
|
||||
(id: string): T | undefined => {
|
||||
@@ -852,19 +894,33 @@ export function UniversalListPage<T>({
|
||||
);
|
||||
|
||||
// ===== 렌더링 함수 래퍼 =====
|
||||
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<T>({
|
||||
})
|
||||
: config.tableHeaderActions
|
||||
}
|
||||
// 테이블 컬럼 (탭별 다른 컬럼 지원)
|
||||
tableColumns={effectiveColumns}
|
||||
// 테이블 컬럼 (가시성 필터링 적용)
|
||||
tableColumns={templateColumns}
|
||||
// 컬럼 리사이즈 & 가시성 설정
|
||||
columnSettings={templateColumnSettings}
|
||||
// 정렬 설정 (모든 페이지에서 활성화)
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
|
||||
@@ -413,6 +413,10 @@ export interface UniversalListConfig<T> {
|
||||
/** 검색어 변경 콜백 (config 내부에서 설정, 서버 사이드 검색용) */
|
||||
onSearchChange?: (search: string) => void;
|
||||
|
||||
// ===== 컬럼 리사이즈 & 가시성 설정 =====
|
||||
/** 컬럼 설정 비활성화 (기본: false = 자동 활성화) */
|
||||
disableColumnSettings?: boolean;
|
||||
|
||||
// ===== 커스텀 다이얼로그 슬롯 =====
|
||||
/**
|
||||
* 커스텀 다이얼로그 렌더링 (DocumentDetailModal, SalaryDetailDialog 등)
|
||||
|
||||
64
src/hooks/useColumnSettings.ts
Normal file
64
src/hooks/useColumnSettings.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
101
src/stores/useTableColumnStore.ts
Normal file
101
src/stores/useTableColumnStore.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user