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

Reviewed-on: #52
This commit was merged in pull request #52.
This commit is contained in:
2026-05-14 03:03:33 +00:00
9 changed files with 256 additions and 186 deletions

View File

@@ -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 형식)

View File

@@ -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}
/>

View File

@@ -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>

View File

@@ -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());

View File

@@ -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 () => {

View File

@@ -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

View File

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

View File

@@ -91,6 +91,7 @@ export function useTableSettings<T extends { key: string }>(
setColumnWidths(widths);
setOrderedKeys(order);
// 화면에 표시된 컬럼만 필터 가능하도록 제한
// 화면에 표시된 컬럼만 필터 가능하도록 제한
setFilterConfig(
settings.filters?.filter((f) => visible.has(f.columnName)),

View File

@@ -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 "======================================"