Merge pull request 'jskim-node' (#52) from jskim-node into main
Some checks failed
Build and Push Images / build-and-push (push) Failing after 52s
Some checks failed
Build and Push Images / build-and-push (push) Failing after 52s
Reviewed-on: #52
This commit was merged in pull request #52.
This commit is contained in:
@@ -11,6 +11,35 @@
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// ─── 근무일 계산 헬퍼 (토/일 제외) ───
|
||||
|
||||
function isWeekend(date: Date): boolean {
|
||||
const day = date.getDay();
|
||||
return day === 0 || day === 6;
|
||||
}
|
||||
|
||||
// 주말이면 평일로 이동 (forward: 다음 월요일, backward: 이전 금요일)
|
||||
function skipWeekend(date: Date, direction: "forward" | "backward"): Date {
|
||||
const d = new Date(date);
|
||||
while (isWeekend(d)) {
|
||||
d.setDate(d.getDate() + (direction === "forward" ? 1 : -1));
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
// 기준일에서 N 근무일 이동 (음수=과거, 양수=미래). 기준일은 카운트에 포함하지 않음.
|
||||
function addWorkingDays(date: Date, workingDays: number): Date {
|
||||
const d = new Date(date);
|
||||
if (workingDays === 0) return d;
|
||||
const step = workingDays > 0 ? 1 : -1;
|
||||
let remaining = Math.abs(workingDays);
|
||||
while (remaining > 0) {
|
||||
d.setDate(d.getDate() + step);
|
||||
if (!isWeekend(d)) remaining--;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
// ─── 수주 데이터 조회 (품목별 그룹핑) ───
|
||||
|
||||
export async function getOrderSummary(
|
||||
@@ -579,32 +608,28 @@ export async function previewSchedule(
|
||||
|
||||
if (requiredQty <= 0) continue;
|
||||
|
||||
// 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산
|
||||
// 리드타임 기반 날짜 계산 (근무일 기준, 토/일 제외, 시작·종료 포함 N일)
|
||||
const dueDate = new Date(item.earliest_due_date);
|
||||
let startDate: Date;
|
||||
const productionDays = itemLeadTime > 0 ? itemLeadTime : Math.ceil(requiredQty / dailyCapacity);
|
||||
let endDate: Date;
|
||||
let startDate: Date;
|
||||
|
||||
if (itemLeadTime > 0) {
|
||||
// 리드타임이 있으면: 종료일 = 납기일, 시작일 = 납기일 - 리드타임
|
||||
endDate = new Date(dueDate);
|
||||
startDate = new Date(dueDate);
|
||||
startDate.setDate(startDate.getDate() - itemLeadTime);
|
||||
// 종료일 = 납기일(주말이면 이전 평일), 시작일 = 종료일에서 (리드타임-1) 근무일 전
|
||||
endDate = skipWeekend(new Date(dueDate), "backward");
|
||||
startDate = addWorkingDays(endDate, -(productionDays - 1));
|
||||
} else {
|
||||
// 리드타임이 없으면 기존 로직 (생산능력 기반)
|
||||
const productionDays = Math.ceil(requiredQty / dailyCapacity);
|
||||
endDate = new Date(dueDate);
|
||||
endDate.setDate(endDate.getDate() - safetyLeadTime);
|
||||
startDate = new Date(endDate);
|
||||
startDate.setDate(startDate.getDate() - productionDays);
|
||||
// 리드타임이 없으면 생산능력 기반: 종료일 = 납기일 - 안전여유(근무일)
|
||||
endDate = addWorkingDays(skipWeekend(new Date(dueDate), "backward"), -safetyLeadTime);
|
||||
startDate = addWorkingDays(endDate, -(productionDays - 1));
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (startDate < today) {
|
||||
const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
startDate.setTime(today.getTime());
|
||||
endDate.setTime(startDate.getTime());
|
||||
endDate.setDate(endDate.getDate() + duration);
|
||||
// 시작일이 과거면 오늘(주말이면 다음 평일)부터 재배치, 작업 근무일 수 유지
|
||||
startDate = skipWeekend(today, "forward");
|
||||
endDate = addWorkingDays(startDate, productionDays - 1);
|
||||
}
|
||||
|
||||
// 해당 품목의 수주 건수 확인
|
||||
@@ -621,7 +646,7 @@ export async function previewSchedule(
|
||||
required_qty: requiredQty,
|
||||
daily_capacity: dailyCapacity,
|
||||
hourly_capacity: item.hourly_capacity || 100,
|
||||
production_days: itemLeadTime > 0 ? itemLeadTime : Math.ceil(requiredQty / dailyCapacity),
|
||||
production_days: productionDays,
|
||||
start_date: startDate.toISOString().split("T")[0],
|
||||
end_date: endDate.toISOString().split("T")[0],
|
||||
due_date: item.earliest_due_date,
|
||||
@@ -707,33 +732,26 @@ export async function generateSchedule(
|
||||
const requiredQty = item.required_qty;
|
||||
if (requiredQty <= 0) continue;
|
||||
|
||||
// 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산
|
||||
// 리드타임 기반 날짜 계산 (근무일 기준, 토/일 제외, 시작·종료 포함 N일)
|
||||
const dueDate = new Date(item.earliest_due_date);
|
||||
let startDate: Date;
|
||||
const productionDays = itemLeadTime > 0 ? itemLeadTime : Math.ceil(requiredQty / dailyCapacity);
|
||||
let endDate: Date;
|
||||
let startDate: Date;
|
||||
|
||||
if (itemLeadTime > 0) {
|
||||
// 리드타임이 있으면: 종료일 = 납기일, 시작일 = 납기일 - 리드타임
|
||||
endDate = new Date(dueDate);
|
||||
startDate = new Date(dueDate);
|
||||
startDate.setDate(startDate.getDate() - itemLeadTime);
|
||||
endDate = skipWeekend(new Date(dueDate), "backward");
|
||||
startDate = addWorkingDays(endDate, -(productionDays - 1));
|
||||
} else {
|
||||
// 리드타임이 없으면 기존 로직 (생산능력 기반)
|
||||
const productionDays = Math.ceil(requiredQty / dailyCapacity);
|
||||
endDate = new Date(dueDate);
|
||||
endDate.setDate(endDate.getDate() - safetyLeadTime);
|
||||
startDate = new Date(endDate);
|
||||
startDate.setDate(startDate.getDate() - productionDays);
|
||||
endDate = addWorkingDays(skipWeekend(new Date(dueDate), "backward"), -safetyLeadTime);
|
||||
startDate = addWorkingDays(endDate, -(productionDays - 1));
|
||||
}
|
||||
|
||||
// 시작일이 오늘보다 이전이면 오늘로 조정
|
||||
// 시작일이 오늘보다 이전이면 오늘(주말이면 다음 평일)로 조정
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (startDate < today) {
|
||||
const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
startDate.setTime(today.getTime());
|
||||
endDate.setTime(startDate.getTime());
|
||||
endDate.setDate(endDate.getDate() + duration);
|
||||
startDate = skipWeekend(today, "forward");
|
||||
endDate = addWorkingDays(startDate, productionDays - 1);
|
||||
}
|
||||
|
||||
// 계획번호 생성 (YYYYMMDD-NNNN 형식)
|
||||
|
||||
@@ -47,21 +47,21 @@ const parseNumber = (val: string) => val.replace(/,/g, "");
|
||||
|
||||
// 플랫 테이블 컬럼 정의 (마스터+디테일 통합)
|
||||
const FLAT_COLUMNS = [
|
||||
{ key: "order_no", label: "수주번호", source: "master" },
|
||||
{ key: "partner_id", label: "거래처", source: "master" },
|
||||
{ key: "order_date", label: "수주일", source: "master" },
|
||||
{ key: "part_code", label: "품번", source: "detail" },
|
||||
{ key: "part_name", label: "품명", source: "detail" },
|
||||
{ key: "spec", label: "규격", source: "detail" },
|
||||
{ key: "unit", label: "단위", source: "detail" },
|
||||
{ key: "qty", label: "수량", source: "detail" },
|
||||
{ key: "ship_qty", label: "출하수량", source: "detail" },
|
||||
{ key: "balance_qty", label: "잔량", source: "detail" },
|
||||
{ key: "unit_price", label: "단가", source: "detail" },
|
||||
{ key: "amount", label: "금액", source: "detail" },
|
||||
{ key: "due_date", label: "납기일", source: "detail" },
|
||||
{ key: "approval_status", label: "결재상태", source: "master" },
|
||||
{ key: "memo", label: "메모", source: "master" },
|
||||
{ key: "order_no", label: "수주번호", source: "master", width: 140, align: "left" as const },
|
||||
{ key: "partner_id", label: "거래처", source: "master", width: 140, align: "left" as const },
|
||||
{ key: "order_date", label: "수주일", source: "master", width: 100, align: "left" as const },
|
||||
{ key: "part_code", label: "품번", source: "detail", width: 120, align: "left" as const },
|
||||
{ key: "part_name", label: "품명", source: "detail", width: 140, align: "left" as const },
|
||||
{ key: "spec", label: "규격", source: "detail", width: 80, align: "left" as const },
|
||||
{ key: "unit", label: "단위", source: "detail", width: 70, align: "left" as const },
|
||||
{ key: "qty", label: "수량", source: "detail", width: 80, align: "right" as const },
|
||||
{ key: "ship_qty", label: "출하수량", source: "detail", width: 80, align: "right" as const },
|
||||
{ key: "balance_qty", label: "잔량", source: "detail", width: 80, align: "right" as const },
|
||||
{ key: "unit_price", label: "단가", source: "detail", width: 90, align: "right" as const },
|
||||
{ key: "amount", label: "금액", source: "detail", width: 110, align: "right" as const },
|
||||
{ key: "due_date", label: "납기일", source: "detail", width: 100, align: "left" as const },
|
||||
{ key: "approval_status", label: "결재상태", source: "master", width: 90, align: "center" as const },
|
||||
{ key: "memo", label: "메모", source: "master", width: 120, align: "left" as const },
|
||||
];
|
||||
|
||||
const DETAIL_HEADER_COLS = FLAT_COLUMNS.filter((c) => c.source === "detail");
|
||||
@@ -69,9 +69,6 @@ const DETAIL_HEADER_COLS = FLAT_COLUMNS.filter((c) => c.source === "detail");
|
||||
// 필터용 전체 키
|
||||
const GRID_COLUMNS_CONFIG = FLAT_COLUMNS.map(({ key, label }) => ({ key, label }));
|
||||
|
||||
// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(15) = 16
|
||||
const TOTAL_COLS = 16;
|
||||
|
||||
// 결재상태 라벨/색상
|
||||
const APPROVAL_STATUS_LABEL: Record<string, string> = {
|
||||
requested: "요청",
|
||||
@@ -1147,6 +1144,9 @@ export default function SalesOrderPage() {
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={orders.length}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
externalSelectOptions={{
|
||||
approval_status: Object.entries(APPROVAL_STATUS_LABEL).map(([value, label]) => ({ value, label })),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 액션 바 */}
|
||||
@@ -1235,24 +1235,20 @@ export default function SalesOrderPage() {
|
||||
{/* 데이터 테이블 (플랫 리스트) */}
|
||||
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card flex flex-col">
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table noWrapper style={{ minWidth: "1500px" }}>
|
||||
{(() => {
|
||||
// 컬럼 설정에서 활성화된 컬럼만 (FLAT_COLUMNS 메타 보존)
|
||||
const visibleCols = ts.visibleColumns
|
||||
.map((vc) => FLAT_COLUMNS.find((fc) => fc.key === vc.key))
|
||||
.filter((c): c is typeof FLAT_COLUMNS[number] => !!c);
|
||||
const totalCols = visibleCols.length + 1; // +1 = 체크박스
|
||||
const minWidth = 40 + visibleCols.reduce((sum, c) => sum + c.width, 0);
|
||||
return (
|
||||
<Table noWrapper style={{ minWidth: `${minWidth}px` }}>
|
||||
<colgroup>
|
||||
<col style={{ width: "40px" }} />
|
||||
<col style={{ width: "140px" }} />
|
||||
<col style={{ width: "140px" }} />
|
||||
<col style={{ width: "100px" }} />
|
||||
<col style={{ width: "120px" }} />
|
||||
<col style={{ width: "140px" }} />
|
||||
<col style={{ width: "80px" }} />
|
||||
<col style={{ width: "70px" }} />
|
||||
<col style={{ width: "80px" }} />
|
||||
<col style={{ width: "80px" }} />
|
||||
<col style={{ width: "80px" }} />
|
||||
<col style={{ width: "90px" }} />
|
||||
<col style={{ width: "110px" }} />
|
||||
<col style={{ width: "100px" }} />
|
||||
<col style={{ width: "90px" }} />
|
||||
<col style={{ width: "120px" }} />
|
||||
{visibleCols.map((c) => (
|
||||
<col key={c.key} style={{ width: `${c.width}px` }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
@@ -1272,11 +1268,12 @@ export default function SalesOrderPage() {
|
||||
onCheckedChange={() => {}}
|
||||
/>
|
||||
</TableHead>
|
||||
{FLAT_COLUMNS.map((col) => {
|
||||
const isRight = ["qty", "ship_qty", "balance_qty", "unit_price", "amount"].includes(col.key);
|
||||
{visibleCols.map((col) => {
|
||||
const isRight = col.align === "right";
|
||||
const isCenter = col.align === "center";
|
||||
return (
|
||||
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none", isRight && "text-right")}>
|
||||
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
|
||||
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none", isRight && "text-right", isCenter && "text-center")}>
|
||||
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full", isCenter && "justify-center w-full")}>
|
||||
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
|
||||
<span className="truncate">{col.label}</span>
|
||||
{sortState?.key === col.key && (
|
||||
@@ -1302,13 +1299,13 @@ export default function SalesOrderPage() {
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
|
||||
<TableCell colSpan={totalCols} className="py-16 text-center">
|
||||
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredFlatRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
|
||||
<TableCell colSpan={totalCols} className="py-16 text-center">
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<ClipboardList className="h-8 w-8 opacity-30" />
|
||||
<span className="text-sm">등록된 수주가 없어요</span>
|
||||
@@ -1321,7 +1318,7 @@ export default function SalesOrderPage() {
|
||||
if (row._isGroupHeader) {
|
||||
return (
|
||||
<TableRow key={`header-${row._groupValue}-${Math.random()}`} className="bg-primary/5 font-semibold border-t-2 border-primary/30">
|
||||
<TableCell colSpan={TOTAL_COLS} className="py-2 px-3 text-[13px] text-primary">
|
||||
<TableCell colSpan={totalCols} className="py-2 px-3 text-[13px] text-primary">
|
||||
📂 {row._groupValue} ({row._groupCount}건)
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -1331,7 +1328,7 @@ export default function SalesOrderPage() {
|
||||
if (row._isGroupSummary) {
|
||||
return (
|
||||
<TableRow key={`summary-${row._groupKey || Math.random()}`} className="bg-muted/60 font-semibold border-t border-primary/20">
|
||||
<TableCell colSpan={TOTAL_COLS} className="py-2 px-3 text-[13px] text-primary">
|
||||
<TableCell colSpan={totalCols} className="py-2 px-3 text-[13px] text-primary">
|
||||
{row._groupValue || "합계"}
|
||||
{row.qty ? ` · 수량 ${Number(row.qty).toLocaleString()}` : ""}
|
||||
{row.amount ? ` · 금액 ${Number(row.amount).toLocaleString()}` : ""}
|
||||
@@ -1365,44 +1362,65 @@ export default function SalesOrderPage() {
|
||||
>
|
||||
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
|
||||
</TableCell>
|
||||
<TableCell className="font-mono whitespace-nowrap text-[13px]">{row.order_no}</TableCell>
|
||||
<TableCell className="text-[13px] truncate max-w-[140px]"><span className="block truncate">{row.partner_id || ""}</span></TableCell>
|
||||
<TableCell className="whitespace-nowrap text-[13px]">{row.order_date || ""}</TableCell>
|
||||
<TableCell className="font-mono text-[13px]">{row.part_code}</TableCell>
|
||||
<TableCell className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.part_name}>{row.part_name}</span></TableCell>
|
||||
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
|
||||
<TableCell className="text-[13px]">{row.unit}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px]">{row.qty ? Number(row.qty).toLocaleString() : ""}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] text-muted-foreground">{row.ship_qty ? Number(row.ship_qty).toLocaleString() : ""}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px]">{row.balance_qty ? Number(row.balance_qty).toLocaleString() : ""}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
|
||||
<TableCell className="text-[13px]">{row.due_date || ""}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{row.approval_status && row.approval_request_id ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.dispatchEvent(new CustomEvent("open-approval-detail-modal", {
|
||||
detail: { requestId: row.approval_request_id },
|
||||
}));
|
||||
}}
|
||||
className={cn("inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold cursor-pointer hover:opacity-80 transition-opacity", APPROVAL_STATUS_CLASS[row.approval_status] || "bg-muted text-muted-foreground")}
|
||||
title="결재 상세보기"
|
||||
>
|
||||
{APPROVAL_STATUS_LABEL[row.approval_status] || row.approval_status}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-muted-foreground/40 text-[11px]">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.memo || ""}</span></TableCell>
|
||||
{visibleCols.map((col) => {
|
||||
const align = col.align;
|
||||
const cellClass = cn(
|
||||
"text-[13px]",
|
||||
align === "right" && "text-right font-mono",
|
||||
align === "center" && "text-center",
|
||||
col.key === "order_no" && "font-mono whitespace-nowrap",
|
||||
col.key === "part_code" && "font-mono",
|
||||
col.key === "order_date" && "whitespace-nowrap",
|
||||
col.key === "spec" && "text-muted-foreground",
|
||||
col.key === "ship_qty" && "text-muted-foreground",
|
||||
col.key === "amount" && "font-semibold",
|
||||
col.key === "memo" && "text-muted-foreground",
|
||||
);
|
||||
let content: React.ReactNode = "";
|
||||
switch (col.key) {
|
||||
case "order_no": content = row.order_no; break;
|
||||
case "partner_id": content = <span className="block truncate">{row.partner_id || ""}</span>; break;
|
||||
case "order_date": content = row.order_date || ""; break;
|
||||
case "part_code": content = row.part_code; break;
|
||||
case "part_name": content = <span className="block truncate" title={row.part_name}>{row.part_name}</span>; break;
|
||||
case "spec": content = row.spec; break;
|
||||
case "unit": content = row.unit; break;
|
||||
case "qty": content = row.qty ? Number(row.qty).toLocaleString() : ""; break;
|
||||
case "ship_qty": content = row.ship_qty ? Number(row.ship_qty).toLocaleString() : ""; break;
|
||||
case "balance_qty": content = row.balance_qty ? Number(row.balance_qty).toLocaleString() : ""; break;
|
||||
case "unit_price": content = row.unit_price ? Number(row.unit_price).toLocaleString() : ""; break;
|
||||
case "amount": content = row.amount ? Number(row.amount).toLocaleString() : ""; break;
|
||||
case "due_date": content = row.due_date || ""; break;
|
||||
case "approval_status":
|
||||
content = row.approval_status && row.approval_request_id ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.dispatchEvent(new CustomEvent("open-approval-detail-modal", {
|
||||
detail: { requestId: row.approval_request_id },
|
||||
}));
|
||||
}}
|
||||
className={cn("inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold cursor-pointer hover:opacity-80 transition-opacity", APPROVAL_STATUS_CLASS[row.approval_status] || "bg-muted text-muted-foreground")}
|
||||
title="결재 상세보기"
|
||||
>
|
||||
{APPROVAL_STATUS_LABEL[row.approval_status] || row.approval_status}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-muted-foreground/40 text-[11px]">-</span>
|
||||
);
|
||||
break;
|
||||
case "memo": content = <span className="block truncate max-w-[120px]">{row.memo || ""}</span>; break;
|
||||
}
|
||||
return <TableCell key={col.key} className={cellClass}>{content}</TableCell>;
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
@@ -2080,6 +2098,10 @@ export default function SalesOrderPage() {
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
extraTableNames={[MASTER_TABLE]}
|
||||
extraColumns={[
|
||||
{ columnName: "approval_status", displayName: "결재상태", inputType: "select" },
|
||||
]}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
|
||||
|
||||
@@ -110,9 +110,6 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 탭 상태
|
||||
const [activeTab, setActiveTab] = useState<"members" | "permissions">("members");
|
||||
|
||||
// 멤버 관리 상태
|
||||
const [memberMode, setMemberMode] = useState<"user" | "dept">("user");
|
||||
const [availableUsers, setAvailableUsers] = useState<Array<{ id: string; name: string; dept?: string }>>([]);
|
||||
@@ -257,12 +254,11 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
|
||||
}, [loadRoleGroup]);
|
||||
|
||||
useEffect(() => {
|
||||
if (roleGroup && activeTab === "members") {
|
||||
if (roleGroup) {
|
||||
loadMembers();
|
||||
} else if (roleGroup && activeTab === "permissions") {
|
||||
loadMenuPermissions();
|
||||
}
|
||||
}, [roleGroup, activeTab, loadMembers, loadMenuPermissions]);
|
||||
}, [roleGroup, loadMembers, loadMenuPermissions]);
|
||||
|
||||
// 부서 선택 변경 시 우측에 부서 표시 + 해당 사용자를 멤버에 추가
|
||||
const handleDeptSelectionChange = useCallback(
|
||||
@@ -402,35 +398,14 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭 네비게이션 */}
|
||||
<div className="flex gap-4 border-b">
|
||||
<button
|
||||
onClick={() => setActiveTab("members")}
|
||||
className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === "members"
|
||||
? "border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground border-transparent"
|
||||
}`}
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
{t("detail.tab.members")} ({selectedUsers.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("permissions")}
|
||||
className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === "permissions"
|
||||
? "border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground border-transparent"
|
||||
}`}
|
||||
>
|
||||
<MenuIcon className="h-4 w-4" />
|
||||
{t("detail.tab.permissions")} ({menuPermissions.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 탭 컨텐츠 */}
|
||||
<div className="space-y-6">
|
||||
{activeTab === "members" && (
|
||||
{/* 통합 컨텐츠: 위 멤버 관리 + 아래 메뉴 권한 */}
|
||||
<div className="space-y-10">
|
||||
{/* === 멤버 관리 섹션 === */}
|
||||
<section className="space-y-6">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Users className="h-4 w-4" />
|
||||
{t("detail.tab.members")} ({selectedUsers.length})
|
||||
</div>
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -516,28 +491,31 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{activeTab === "permissions" && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t("detail.permissions.title")}</h2>
|
||||
<p className="text-muted-foreground text-sm">{t("detail.permissions.description")}</p>
|
||||
</div>
|
||||
<Button onClick={handleSavePermissions} disabled={isSavingPermissions} className="gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
{isSavingPermissions ? t("detail.permissions.saving") : t("detail.permissions.save")}
|
||||
</Button>
|
||||
{/* === 메뉴 권한 섹션 === */}
|
||||
<section className="space-y-6 border-t pt-10">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<MenuIcon className="h-4 w-4" />
|
||||
{t("detail.tab.permissions")} ({menuPermissions.length})
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t("detail.permissions.title")}</h2>
|
||||
<p className="text-muted-foreground text-sm">{t("detail.permissions.description")}</p>
|
||||
</div>
|
||||
<Button onClick={handleSavePermissions} disabled={isSavingPermissions} className="gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
{isSavingPermissions ? t("detail.permissions.saving") : t("detail.permissions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<MenuPermissionsTable
|
||||
permissions={menuPermissions}
|
||||
onPermissionsChange={setMenuPermissions}
|
||||
roleGroup={roleGroup}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<MenuPermissionsTable
|
||||
permissions={menuPermissions}
|
||||
onPermissionsChange={setMenuPermissions}
|
||||
roleGroup={roleGroup}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -132,7 +132,9 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||
};
|
||||
|
||||
loadAllMenus();
|
||||
}, [currentUser, isSuperAdmin, roleGroup.companyCode, companyFilter]);
|
||||
// currentUser 객체 전체를 의존성에 넣으면 useAuth 재렌더링마다 새 reference로 인식되어 무한 호출 발생.
|
||||
// primitive(userId/companyCode/userType)만 의존성에 사용.
|
||||
}, [currentUser?.userId, currentUser?.companyCode, currentUser?.userType, isSuperAdmin, roleGroup.companyCode, companyFilter]);
|
||||
|
||||
// 메뉴 권한 상태 (로컬 상태 관리)
|
||||
const [menuPermissions, setMenuPermissions] = useState<Map<number, MenuPermission>>(new Map());
|
||||
|
||||
@@ -71,6 +71,8 @@ export interface DynamicSearchFilterProps {
|
||||
extraActions?: React.ReactNode;
|
||||
/** TableSettingsModal에서 전달된 외부 필터 설정 (제공 시 자체 설정 모달 숨김) */
|
||||
externalFilterConfig?: ExternalFilterConfig[];
|
||||
/** select 필터의 외부 주입 옵션 (가상 컬럼처럼 DB 조회로 잡히지 않는 컬럼용) */
|
||||
externalSelectOptions?: Record<string, { label: string; value: string }[]>;
|
||||
}
|
||||
|
||||
const FILTER_TYPE_OPTIONS: { value: FilterType; label: string }[] = [
|
||||
@@ -97,6 +99,7 @@ export function DynamicSearchFilter({
|
||||
dataCount,
|
||||
extraActions,
|
||||
externalFilterConfig,
|
||||
externalSelectOptions,
|
||||
}: DynamicSearchFilterProps) {
|
||||
const [allColumns, setAllColumns] = useState<FilterColumn[]>([]);
|
||||
const [activeFilters, setActiveFilters] = useState<FilterColumn[]>([]);
|
||||
@@ -187,6 +190,12 @@ export function DynamicSearchFilter({
|
||||
);
|
||||
}, [externalFilterConfig]);
|
||||
|
||||
// 외부 주입 select 옵션 병합 (가상 컬럼 등)
|
||||
useEffect(() => {
|
||||
if (!externalSelectOptions) return;
|
||||
setSelectOptions((prev) => ({ ...prev, ...externalSelectOptions }));
|
||||
}, [externalSelectOptions]);
|
||||
|
||||
// select 타입 필터의 옵션 로드 (카테고리 → 없으면 실제 데이터 distinct)
|
||||
useEffect(() => {
|
||||
const loadOptions = async () => {
|
||||
|
||||
@@ -88,6 +88,14 @@ export interface TableSettingsModalProps {
|
||||
defaultVisibleKeys?: string[];
|
||||
/** AUTO_COLS에서 제외하지 않을 컬럼 키 목록 (예: ["created_date", "updated_date", "writer"]) */
|
||||
includeAutoColumns?: string[];
|
||||
/** 보조 테이블명 (마스터/디테일 결합 그리드용). 메인 테이블 web-types에 없는 컬럼을 보강 */
|
||||
extraTableNames?: string[];
|
||||
/** 가상 컬럼 명세 (DB 실컬럼이 아닌 조인/계산 필드용). web-types 응답에 없을 때만 추가됨 */
|
||||
extraColumns?: Array<{
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
inputType?: "text" | "number" | "date" | "datetime" | "category" | "select";
|
||||
}>;
|
||||
}
|
||||
|
||||
// ===== 상수 =====
|
||||
@@ -219,6 +227,8 @@ export function TableSettingsModal({
|
||||
initialTab = "columns",
|
||||
defaultVisibleKeys,
|
||||
includeAutoColumns,
|
||||
extraTableNames,
|
||||
extraColumns,
|
||||
}: TableSettingsModalProps) {
|
||||
const [activeTab, setActiveTab] = useState(initialTab);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -249,8 +259,41 @@ export function TableSettingsModal({
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.get(`/table-management/tables/${tableName}/web-types`);
|
||||
const types: any[] = res.data?.data || [];
|
||||
// 메인 + 보조 테이블 web-types 병합 (먼저 등장한 테이블 컬럼 우선)
|
||||
// 보조 테이블 호출이 실패해도 메인 결과는 유지하기 위해 allSettled 사용
|
||||
const tableNamesToLoad = [tableName, ...(extraTableNames || [])];
|
||||
const settled = await Promise.allSettled(
|
||||
tableNamesToLoad.map((tn) => apiClient.get(`/table-management/tables/${tn}/web-types`))
|
||||
);
|
||||
const seenCols = new Set<string>();
|
||||
const types: any[] = [];
|
||||
settled.forEach((s, idx) => {
|
||||
if (s.status === "fulfilled") {
|
||||
const arr: any[] = s.value.data?.data || [];
|
||||
for (const t of arr) {
|
||||
if (!seenCols.has(t.columnName)) {
|
||||
seenCols.add(t.columnName);
|
||||
types.push(t);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(`web-types 로드 실패 (${tableNamesToLoad[idx]}):`, s.reason);
|
||||
}
|
||||
});
|
||||
|
||||
// 가상 컬럼 보강 (web-types에 없는 조인/계산 필드)
|
||||
if (extraColumns) {
|
||||
for (const ec of extraColumns) {
|
||||
if (!seenCols.has(ec.columnName)) {
|
||||
seenCols.add(ec.columnName);
|
||||
types.push({
|
||||
columnName: ec.columnName,
|
||||
displayName: ec.displayName,
|
||||
inputType: ec.inputType || "text",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 컬럼 설정 생성 (defaultVisibleKeys가 있으면 해당 컬럼만 표시)
|
||||
const unsortedColumns: ColumnSetting[] = types
|
||||
|
||||
@@ -514,6 +514,13 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_31/outsourcing/purchase-order": dynamic(() => import("@/app/(main)/COMPANY_31/outsourcing/purchase-order/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_31/outsourcing/purchase-status": dynamic(() => import("@/app/(main)/COMPANY_31/outsourcing/purchase-status/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_31/outsourcing/vendor-stock": dynamic(() => import("@/app/(main)/COMPANY_31/outsourcing/vendor-stock/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_31/purchase/order": dynamic(() => import("@/app/(main)/COMPANY_31/purchase/order/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_31/purchase/purchase-item": dynamic(() => import("@/app/(main)/COMPANY_31/purchase/purchase-item/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_31/purchase/supplier": dynamic(() => import("@/app/(main)/COMPANY_31/purchase/supplier/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_31/mold/info": dynamic(() => import("@/app/(main)/COMPANY_31/mold/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_31/quality/inspection": dynamic(() => import("@/app/(main)/COMPANY_31/quality/inspection/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_31/quality/item-inspection": dynamic(() => import("@/app/(main)/COMPANY_31/quality/item-inspection/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_31/quality/inspection-result": dynamic(() => import("@/app/(main)/COMPANY_31/quality/inspection-result/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_31/design/project": dynamic(() => import("@/app/(main)/COMPANY_31/design/project/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_31/design/change-management": dynamic(() => import("@/app/(main)/COMPANY_31/design/change-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_31/design/my-work": dynamic(() => import("@/app/(main)/COMPANY_31/design/my-work/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
@@ -91,6 +91,7 @@ export function useTableSettings<T extends { key: string }>(
|
||||
setColumnWidths(widths);
|
||||
setOrderedKeys(order);
|
||||
|
||||
// 화면에 표시된 컬럼만 필터 가능하도록 제한
|
||||
// 화면에 표시된 컬럼만 필터 가능하도록 제한
|
||||
setFilterConfig(
|
||||
settings.filters?.filter((f) => visible.has(f.columnName)),
|
||||
|
||||
@@ -17,34 +17,24 @@ echo "======================================"
|
||||
|
||||
# Git 최신 코드 가져오기
|
||||
echo ""
|
||||
echo "[1/6] Git 최신 코드 가져오기..."
|
||||
echo "[1/4] Git 최신 코드 가져오기..."
|
||||
git pull origin main
|
||||
|
||||
# Docker 볼륨 사용으로 호스트 디렉토리 준비 불필요
|
||||
# 새로운 이미지 빌드 (구 컨테이너는 계속 서빙 중)
|
||||
echo ""
|
||||
echo "[2/6] Docker 볼륨 확인..."
|
||||
echo "Docker named volumes 사용 (권한 문제 없음)"
|
||||
echo "[2/4] Docker 이미지 빌드 (서비스 무중단)..."
|
||||
docker-compose -f "$COMPOSE_FILE" build
|
||||
|
||||
# 기존 컨테이너 중지 및 제거
|
||||
# 변경된 컨테이너만 graceful 재생성 (이미지 해시 동일하면 건드리지 않음)
|
||||
echo ""
|
||||
echo "[3/6] 기존 컨테이너 중지..."
|
||||
docker-compose -f "$COMPOSE_FILE" down
|
||||
|
||||
# 오래된 이미지 정리
|
||||
echo ""
|
||||
echo "[4/6] Docker 이미지 정리..."
|
||||
docker image prune -f
|
||||
|
||||
# 새로운 이미지 빌드
|
||||
echo ""
|
||||
echo "[5/6] Docker 이미지 빌드..."
|
||||
docker-compose -f "$COMPOSE_FILE" build --no-cache
|
||||
|
||||
# 컨테이너 실행
|
||||
echo ""
|
||||
echo "[6/6] 컨테이너 실행..."
|
||||
echo "[3/4] 컨테이너 교체..."
|
||||
docker-compose -f "$COMPOSE_FILE" up -d
|
||||
|
||||
# 오래된 이미지 정리 (서비스 복귀 후 후처리)
|
||||
echo ""
|
||||
echo "[4/4] Docker 이미지 정리..."
|
||||
docker image prune -f
|
||||
|
||||
# 배포 완료
|
||||
echo ""
|
||||
echo "======================================"
|
||||
|
||||
Reference in New Issue
Block a user