merge: resolve conflicts accepting incoming changes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+12
-47
@@ -39,7 +39,6 @@
|
||||
"nodemailer": "^6.10.1",
|
||||
"oracledb": "^6.9.0",
|
||||
"pg": "^8.16.3",
|
||||
"playwright": "^1.58.2",
|
||||
"quill": "^2.0.3",
|
||||
"react-quill": "^2.0.0",
|
||||
"redis": "^4.6.10",
|
||||
@@ -1051,6 +1050,7 @@
|
||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
@@ -2384,6 +2384,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cluster-key-slot": "1.1.2",
|
||||
"generic-pool": "3.9.0",
|
||||
@@ -3501,6 +3502,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
|
||||
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -3746,6 +3748,7 @@
|
||||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "6.21.0",
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
@@ -3974,6 +3977,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -4523,6 +4527,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.3",
|
||||
"caniuse-lite": "^1.0.30001741",
|
||||
@@ -5898,6 +5903,7 @@
|
||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@@ -6176,6 +6182,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
@@ -7762,6 +7769,7 @@
|
||||
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/core": "^29.7.0",
|
||||
"@jest/types": "^29.6.3",
|
||||
@@ -8731,7 +8739,6 @@
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
@@ -9701,6 +9708,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.9.1",
|
||||
"pg-pool": "^3.10.1",
|
||||
@@ -9920,50 +9928,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
@@ -10668,7 +10632,6 @@
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
@@ -11562,6 +11525,7 @@
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
@@ -11667,6 +11631,7 @@
|
||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
"nodemailer": "^6.10.1",
|
||||
"oracledb": "^6.9.0",
|
||||
"pg": "^8.16.3",
|
||||
"playwright": "^1.58.2",
|
||||
"quill": "^2.0.3",
|
||||
"react-quill": "^2.0.0",
|
||||
"redis": "^4.6.10",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { JwtUtils } from "../utils/jwtUtils";
|
||||
import { LoginRequest, UserInfo, ApiResponse, PersonBean } from "../types/auth";
|
||||
import { logger } from "../utils/logger";
|
||||
import { sendSmartFactoryLog } from "../utils/smartFactoryLog";
|
||||
import { query, queryOne } from "../database/db";
|
||||
|
||||
export class AuthController {
|
||||
/**
|
||||
@@ -105,6 +106,14 @@ export class AuthController {
|
||||
popLandingPath = "/pop";
|
||||
}
|
||||
logger.debug(`POP 랜딩 경로: ${popLandingPath}`);
|
||||
|
||||
// POP 메뉴가 존재하면 해당 회사의 POP 레이아웃 자동 초기화 (비동기)
|
||||
if (popLandingPath) {
|
||||
const companyCode = loginResult.userInfo.companyCode || "ILSHIN";
|
||||
AuthController.initPopLayoutsForCompany(companyCode).catch((err) => {
|
||||
logger.warn("POP 레이아웃 자동 초기화 중 오류 (무시):", err);
|
||||
});
|
||||
}
|
||||
} catch (popError) {
|
||||
logger.warn("POP 메뉴 조회 중 오류 (무시):", popError);
|
||||
}
|
||||
@@ -563,4 +572,78 @@ export class AuthController {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POP 레이아웃 자동 초기화
|
||||
* 해당 회사의 screen_layouts_pop 레코드가 없으면
|
||||
* 템플릿(공통 '*' 또는 COMPANY_7)에서 복제하여 생성
|
||||
*
|
||||
* 기본 POP 화면 ID: 5, 6, 7, 8, 6526, 6527, 6528, 6529
|
||||
*/
|
||||
static async initPopLayoutsForCompany(companyCode: string): Promise<void> {
|
||||
// SUPER_ADMIN이나 공통(*)은 초기화 불필요
|
||||
if (companyCode === "*" || companyCode === "COMPANY_7") return;
|
||||
|
||||
const POP_SCREEN_IDS = [5, 6, 7, 8, 6526, 6527, 6528, 6529];
|
||||
|
||||
// 이미 해당 회사의 POP 레이아웃이 하나라도 있으면 스킵 (중복 초기화 방지)
|
||||
const existing = await query<{ cnt: string }>(
|
||||
`SELECT COUNT(*)::text AS cnt FROM screen_layouts_pop
|
||||
WHERE company_code = $1 AND screen_id = ANY($2::int[])`,
|
||||
[companyCode, POP_SCREEN_IDS],
|
||||
);
|
||||
const existingCount = parseInt(existing[0]?.cnt || "0", 10);
|
||||
if (existingCount > 0) {
|
||||
logger.debug(`POP 레이아웃 이미 존재 (${companyCode}): ${existingCount}개, 스킵`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`POP 레이아웃 자동 초기화 시작: ${companyCode}`);
|
||||
|
||||
// 회사명 조회 (레이아웃 내 회사명 치환용)
|
||||
const companyInfo = await queryOne<{ company_name: string }>(
|
||||
`SELECT company_name FROM company_mng WHERE company_code = $1`,
|
||||
[companyCode],
|
||||
);
|
||||
const companyName = companyInfo?.company_name || companyCode;
|
||||
|
||||
let initCount = 0;
|
||||
for (const screenId of POP_SCREEN_IDS) {
|
||||
// 템플릿 조회: 공통(*) 우선, 없으면 COMPANY_7 폴백
|
||||
let template = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_pop
|
||||
WHERE screen_id = $1 AND company_code = '*'`,
|
||||
[screenId],
|
||||
);
|
||||
if (!template) {
|
||||
template = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_pop
|
||||
WHERE screen_id = $1 AND company_code = 'COMPANY_7'`,
|
||||
[screenId],
|
||||
);
|
||||
}
|
||||
|
||||
if (!template) {
|
||||
logger.debug(`POP 템플릿 없음 (screen_id=${screenId}), 스킵`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 레이아웃 복제 + 회사명 치환
|
||||
const layoutStr = JSON.stringify(template.layout_data);
|
||||
const replacedStr = layoutStr
|
||||
.replace(/\(주\)탑씰/g, companyName)
|
||||
.replace(/탑씰/g, companyName)
|
||||
.replace(/TOPSEAL/gi, companyName);
|
||||
|
||||
await query(
|
||||
`INSERT INTO screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by)
|
||||
VALUES ($1, $2, $3, NOW(), NOW(), 'SYSTEM', 'SYSTEM')
|
||||
ON CONFLICT (screen_id, company_code) DO NOTHING`,
|
||||
[screenId, companyCode, replacedStr],
|
||||
);
|
||||
initCount++;
|
||||
}
|
||||
|
||||
logger.info(`POP 레이아웃 자동 초기화 완료: ${companyCode}, ${initCount}개 화면`);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,11 @@ import {
|
||||
updateTargetWarehouse,
|
||||
inventoryInbound,
|
||||
quickInventoryInbound,
|
||||
getReworkHistory,
|
||||
getBomMaterials,
|
||||
saveMaterialInput,
|
||||
getMaterialInputs,
|
||||
getChecklistItems,
|
||||
} from "../controllers/popProductionController";
|
||||
|
||||
const router = Router();
|
||||
@@ -41,5 +46,10 @@ router.get("/is-last-process/:processId", isLastProcess);
|
||||
router.post("/update-target-warehouse", updateTargetWarehouse);
|
||||
router.post("/inventory-inbound", inventoryInbound);
|
||||
router.post("/quick-inventory-inbound", quickInventoryInbound);
|
||||
router.get("/rework-history/:woId", getReworkHistory);
|
||||
router.get("/bom-materials/:processId", getBomMaterials);
|
||||
router.post("/material-input", saveMaterialInput);
|
||||
router.get("/material-inputs/:processId", getMaterialInputs);
|
||||
router.get("/checklist-items/:processId", getChecklistItems);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -5909,7 +5909,8 @@ export class ScreenManagementService {
|
||||
const existingScreen = screens[0];
|
||||
|
||||
// SUPER_ADMIN이 아니고 회사 코드가 다르면 권한 없음
|
||||
if (!isSuperAdmin && companyCode !== "*" && existingScreen.company_code !== companyCode) {
|
||||
// screen_definitions.company_code가 '*'(공통 화면)이면 모든 회사에서 접근 허용
|
||||
if (!isSuperAdmin && companyCode !== "*" && existingScreen.company_code !== companyCode && existingScreen.company_code !== '*') {
|
||||
throw new Error("이 화면의 POP 레이아웃을 조회할 권한이 없습니다.");
|
||||
}
|
||||
|
||||
@@ -5935,20 +5936,64 @@ export class ScreenManagementService {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 일반 사용자: 회사별 우선, 없으면 공통(*) 조회
|
||||
// 일반 사용자: 회사별 우선, 없으면 템플릿에서 자동 복제
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_pop
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
|
||||
// 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회
|
||||
// 회사별 레이아웃이 없으면 템플릿에서 자동 복제
|
||||
if (!layout && companyCode !== "*") {
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
// 1. 공통(*) 템플릿 조회
|
||||
let templateLayout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_pop
|
||||
WHERE screen_id = $1 AND company_code = '*'`,
|
||||
[screenId],
|
||||
);
|
||||
|
||||
// 2. 공통 없으면 COMPANY_7(탑씰) 폴백
|
||||
if (!templateLayout) {
|
||||
templateLayout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_pop
|
||||
WHERE screen_id = $1 AND company_code = 'COMPANY_7'`,
|
||||
[screenId],
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 템플릿이 있으면 해당 회사용으로 복제
|
||||
if (templateLayout) {
|
||||
console.log(`POP 레이아웃 자동 복제: screen_id=${screenId}, 대상 회사=${companyCode}`);
|
||||
|
||||
// 회사명 조회 (레이아웃 내 회사명 치환용)
|
||||
const companyInfo = await queryOne<{ company_name: string }>(
|
||||
`SELECT company_name FROM company_mng WHERE company_code = $1`,
|
||||
[companyCode],
|
||||
);
|
||||
const companyName = companyInfo?.company_name || companyCode;
|
||||
|
||||
let clonedData = JSON.parse(JSON.stringify(templateLayout.layout_data));
|
||||
|
||||
// layout_data 내 회사명 텍스트 치환 (탑씰 관련 문자열 → 대상 회사명)
|
||||
const layoutStr = JSON.stringify(clonedData);
|
||||
const replacedStr = layoutStr
|
||||
.replace(/\(주\)탑씰/g, companyName)
|
||||
.replace(/탑씰/g, companyName)
|
||||
.replace(/TOPSEAL/gi, companyName);
|
||||
clonedData = JSON.parse(replacedStr);
|
||||
|
||||
// 해당 회사 코드로 INSERT (UPSERT)
|
||||
await query(
|
||||
`INSERT INTO screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by)
|
||||
VALUES ($1, $2, $3, NOW(), NOW(), 'SYSTEM', 'SYSTEM')
|
||||
ON CONFLICT (screen_id, company_code)
|
||||
DO UPDATE SET layout_data = $3, updated_at = NOW(), updated_by = 'SYSTEM'`,
|
||||
[screenId, companyCode, JSON.stringify(clonedData)],
|
||||
);
|
||||
|
||||
console.log(`POP 레이아웃 자동 복제 완료: screen_id=${screenId}, company=${companyCode}`);
|
||||
layout = { layout_data: clonedData };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6041,13 +6086,15 @@ export class ScreenManagementService {
|
||||
|
||||
const existingScreen = screens[0];
|
||||
|
||||
if (companyCode !== "*" && existingScreen.company_code !== companyCode) {
|
||||
// screen_definitions.company_code가 '*'(공통 화면)이면 모든 회사에서 저장 허용
|
||||
if (companyCode !== "*" && existingScreen.company_code !== companyCode && existingScreen.company_code !== '*') {
|
||||
throw new Error("이 화면의 POP 레이아웃을 저장할 권한이 없습니다.");
|
||||
}
|
||||
|
||||
// SUPER_ADMIN인 경우: 화면 정의의 company_code로 저장 (로드와 동일하게)
|
||||
const targetCompanyCode = companyCode === "*"
|
||||
? (existingScreen.company_code || "*")
|
||||
// SUPER_ADMIN인 경우: 화면 정의의 company_code로 저장
|
||||
// 공통 화면(*)인 경우: 일반 사용자는 자기 회사 코드로 저장 (회사별 레이아웃 분리)
|
||||
const targetCompanyCode = companyCode === "*"
|
||||
? (existingScreen.company_code || "*")
|
||||
: companyCode;
|
||||
|
||||
console.log(`저장 대상 company_code: ${targetCompanyCode} (사용자: ${companyCode}, 화면: ${existingScreen.company_code})`);
|
||||
@@ -6086,10 +6133,11 @@ export class ScreenManagementService {
|
||||
[],
|
||||
);
|
||||
} else {
|
||||
// 일반 회사: 해당 회사 레이아웃만 조회 (company_code='*'는 최고관리자 전용)
|
||||
// 일반 회사: 해당 회사 레이아웃 + 공통(*)/COMPANY_7 템플릿도 포함
|
||||
// (getLayoutPop에서 자동 복제하므로 템플릿이 있으면 해당 회사도 사용 가능)
|
||||
result = await query<{ screen_id: number }>(
|
||||
`SELECT DISTINCT screen_id FROM screen_layouts_pop
|
||||
WHERE company_code = $1`,
|
||||
WHERE company_code IN ($1, '*', 'COMPANY_7')`,
|
||||
[companyCode],
|
||||
);
|
||||
}
|
||||
@@ -6121,7 +6169,8 @@ export class ScreenManagementService {
|
||||
|
||||
const existingScreen = screens[0];
|
||||
|
||||
if (companyCode !== "*" && existingScreen.company_code !== companyCode) {
|
||||
// screen_definitions.company_code가 '*'(공통 화면)이면 모든 회사에서 삭제 허용
|
||||
if (companyCode !== "*" && existingScreen.company_code !== companyCode && existingScreen.company_code !== '*') {
|
||||
throw new Error("이 화면의 POP 레이아웃을 삭제할 권한이 없습니다.");
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,955 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import type { PopSettings } from "@/hooks/pop/usePopSettings";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Save,
|
||||
RotateCcw,
|
||||
X,
|
||||
Plus,
|
||||
Trash2,
|
||||
Settings2,
|
||||
Loader2,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
PackageOpen,
|
||||
Truck,
|
||||
Factory,
|
||||
Home,
|
||||
Cpu,
|
||||
} from "lucide-react";
|
||||
|
||||
// ============================================================
|
||||
// Default Settings (mirrors usePopSettings.ts DEFAULT_SETTINGS)
|
||||
// ============================================================
|
||||
const DEFAULT_SETTINGS: PopSettings = {
|
||||
version: "hardcoded-1.0",
|
||||
screens: {
|
||||
processExecution: {
|
||||
materialInput: true,
|
||||
photoUpload: true,
|
||||
plcEnabled: false,
|
||||
bomFlexible: true,
|
||||
packagingOptions: ["낱개", "박스", "파렛트"],
|
||||
defectTypes: ["스크래치", "치수불량", "변색", "크랙", "기포"],
|
||||
reworkTargetSelection: true,
|
||||
groupPhotoEnabled: false,
|
||||
dateFilter: false,
|
||||
lastProcessInventory: "manual",
|
||||
defaultWarehouse: false,
|
||||
inspectionAutoJudge: "off",
|
||||
standardTimeDisplay: false,
|
||||
progressDisplay: false,
|
||||
},
|
||||
inbound: {
|
||||
inspectionRequired: false,
|
||||
photoUpload: false,
|
||||
barcodeEnabled: true,
|
||||
packagingRecord: false,
|
||||
defectSeparation: false,
|
||||
},
|
||||
outbound: {
|
||||
photoUpload: false,
|
||||
barcodeEnabled: true,
|
||||
},
|
||||
home: {
|
||||
kpiCarousel: true,
|
||||
recentActivity: true,
|
||||
bannerEnabled: false,
|
||||
bannerText: "",
|
||||
iconThemeColor: "#2563eb",
|
||||
iconCustomImages: false,
|
||||
dashboardLayout: "default",
|
||||
},
|
||||
plc: {
|
||||
connectionType: "db",
|
||||
refreshInterval: 5,
|
||||
tagMappings: [],
|
||||
alarmThresholds: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Screen Groups & Items
|
||||
// ============================================================
|
||||
interface ScreenItem {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
settingsKey: string;
|
||||
screenId: number;
|
||||
}
|
||||
|
||||
interface ScreenGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
screens: ScreenItem[];
|
||||
}
|
||||
|
||||
const SCREEN_GROUPS: ScreenGroup[] = [
|
||||
{
|
||||
id: "inbound",
|
||||
name: "입고",
|
||||
icon: "PackageOpen",
|
||||
screens: [
|
||||
{ id: "purchase-inbound", name: "구매입고", url: "/pop/inbound/purchase", settingsKey: "inbound", screenId: 6528 },
|
||||
{ id: "inbound-cart", name: "입고 장바구니", url: "/pop/inbound/cart", settingsKey: "inbound", screenId: 6527 },
|
||||
{ id: "inbound-type", name: "입고유형선택", url: "/pop/inbound", settingsKey: "inbound", screenId: 6529 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "outbound",
|
||||
name: "출고",
|
||||
icon: "Truck",
|
||||
screens: [
|
||||
{ id: "sales-outbound", name: "판매출고", url: "/pop/outbound/sales", settingsKey: "outbound", screenId: 5 },
|
||||
{ id: "outbound-type", name: "출고유형선택", url: "/pop/outbound", settingsKey: "outbound", screenId: 6 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "production",
|
||||
name: "생산",
|
||||
icon: "Factory",
|
||||
screens: [
|
||||
{ id: "process-execution", name: "공정실행", url: "/pop/production/process", settingsKey: "processExecution", screenId: 7 },
|
||||
{ id: "production-main", name: "생산관리", url: "/pop/production", settingsKey: "processExecution", screenId: 8 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "home",
|
||||
name: "홈",
|
||||
icon: "Home",
|
||||
screens: [
|
||||
{ id: "home-screen", name: "홈 화면", url: "/pop/home", settingsKey: "home", screenId: 6526 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "plc",
|
||||
name: "PLC",
|
||||
icon: "Cpu",
|
||||
screens: [
|
||||
{ id: "plc-settings", name: "PLC 연동", url: "/pop/home", settingsKey: "plc", screenId: 6526 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
PackageOpen,
|
||||
Truck,
|
||||
Factory,
|
||||
Home,
|
||||
Cpu,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Settings Schema
|
||||
// ============================================================
|
||||
interface SettingField {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
type: "toggle" | "text" | "number" | "select" | "color" | "tags" | "array-object";
|
||||
defaultValue?: unknown;
|
||||
options?: { value: string; label: string }[];
|
||||
fields?: { key: string; label: string; type: string }[];
|
||||
}
|
||||
|
||||
const SETTINGS_SCHEMA: Record<string, SettingField[]> = {
|
||||
inbound: [
|
||||
{ key: "barcodeEnabled", label: "바코드 스캔", description: "바코드/QR 스캔 기능을 사용합니다", type: "toggle" },
|
||||
{ key: "inspectionRequired", label: "검사 필수", description: "입고 시 검사 항목을 필수로 표시합니다", type: "toggle" },
|
||||
{ key: "photoUpload", label: "사진 첨부", description: "입고 확정 시 사진 첨부를 허용합니다", type: "toggle" },
|
||||
{ key: "packagingRecord", label: "포장 기록", description: "포장/적재 상세 기록을 사용합니다", type: "toggle" },
|
||||
{ key: "defectSeparation", label: "불량 분리", description: "양품/불량 수량을 분리 입력합니다", type: "toggle" },
|
||||
],
|
||||
outbound: [
|
||||
{ key: "barcodeEnabled", label: "바코드 스캔", description: "바코드/QR 스캔 기능을 사용합니다", type: "toggle" },
|
||||
{ key: "photoUpload", label: "사진 첨부", description: "출고 시 사진 첨부를 허용합니다", type: "toggle" },
|
||||
],
|
||||
processExecution: [
|
||||
{ key: "materialInput", label: "자재 투입", description: "BOM 기반 자재 투입 탭을 표시합니다", type: "toggle" },
|
||||
{ key: "bomFlexible", label: "BOM 유동 투입", description: "기준과 다른 수량 투입을 허용합니다", type: "toggle" },
|
||||
{ key: "photoUpload", label: "사진 첨부", description: "실적 입력 시 사진 첨부를 허용합니다", type: "toggle" },
|
||||
{ key: "groupPhotoEnabled", label: "그룹별 사진", description: "체크리스트 그룹마다 사진을 첨부합니다", type: "toggle" },
|
||||
{ key: "plcEnabled", label: "PLC 연동", description: "설비 PLC 데이터를 자동 연동합니다", type: "toggle" },
|
||||
{ key: "reworkTargetSelection", label: "재작업 공정 지정", description: "불량 처리 시 특정 공정을 선택할 수 있습니다", type: "toggle" },
|
||||
{ key: "dateFilter", label: "날짜 필터", description: "작업지시 목록에 날짜 필터를 표시합니다", type: "toggle" },
|
||||
{
|
||||
key: "lastProcessInventory", label: "마지막 공정 입고", description: "마지막 공정 완료 시 재고 입고 방식", type: "select", options: [
|
||||
{ value: "auto", label: "자동 입고" },
|
||||
{ value: "manual", label: "수동 선택" },
|
||||
{ value: "button", label: "버튼 활성화" },
|
||||
],
|
||||
},
|
||||
{ key: "defaultWarehouse", label: "기본 창고 기억", description: "선택한 창고를 다음에도 자동 선택합니다", type: "toggle" },
|
||||
{
|
||||
key: "inspectionAutoJudge", label: "검사 자동 판정", description: "수치 검사 시 상/하한 초과 처리 방식", type: "select", options: [
|
||||
{ value: "off", label: "사용 안 함" },
|
||||
{ value: "warn", label: "경고만 표시" },
|
||||
{ value: "fail", label: "자동 불량" },
|
||||
],
|
||||
},
|
||||
{ key: "standardTimeDisplay", label: "표준시간 비교", description: "표준시간 대비 실제시간을 표시합니다", type: "toggle" },
|
||||
{ key: "progressDisplay", label: "진행률 표시", description: "작업지시 전체 진행률을 표시합니다", type: "toggle" },
|
||||
{ key: "packagingOptions", label: "포장 옵션", description: "포장 단위 선택지를 관리합니다", type: "tags" },
|
||||
{ key: "defectTypes", label: "불량 유형", description: "불량 유형 선택지를 관리합니다", type: "tags" },
|
||||
],
|
||||
home: [
|
||||
{ key: "kpiCarousel", label: "KPI 캐러셀", description: "오늘의 현황 캐러셀을 표시합니다", type: "toggle" },
|
||||
{ key: "recentActivity", label: "최근 활동", description: "최근 입출고 활동을 표시합니다", type: "toggle" },
|
||||
{ key: "bannerEnabled", label: "공지 배너", description: "상단에 공지 배너를 표시합니다", type: "toggle" },
|
||||
{ key: "bannerText", label: "배너 텍스트", description: "공지 배너에 표시할 텍스트", type: "text" },
|
||||
{ key: "iconThemeColor", label: "아이콘 테마색", description: "메뉴 아이콘의 테마 색상", type: "color" },
|
||||
{ key: "iconCustomImages", label: "아이콘 커스텀", description: "메뉴 아이콘 이미지를 커스터마이즈합니다", type: "toggle" },
|
||||
{
|
||||
key: "dashboardLayout", label: "대시보드 구성", description: "홈 대시보드 레이아웃", type: "select", options: [
|
||||
{ value: "default", label: "기본" },
|
||||
{ value: "compact", label: "컴팩트" },
|
||||
{ value: "detailed", label: "상세" },
|
||||
],
|
||||
},
|
||||
],
|
||||
plc: [
|
||||
{
|
||||
key: "connectionType", label: "연결 방식", description: "PLC 데이터 연동 방식", type: "select", options: [
|
||||
{ value: "db", label: "DB 직접 연결" },
|
||||
{ value: "opcua", label: "OPC-UA" },
|
||||
{ value: "rest", label: "REST API" },
|
||||
],
|
||||
},
|
||||
{ key: "refreshInterval", label: "갱신 주기(초)", description: "PLC 데이터 갱신 주기", type: "number" },
|
||||
{
|
||||
key: "tagMappings", label: "태그 매핑", description: "PLC 태그와 공정/체크리스트 연결", type: "array-object", fields: [
|
||||
{ key: "tagName", label: "태그명", type: "text" },
|
||||
{ key: "processCode", label: "공정코드", type: "text" },
|
||||
{ key: "checklistItemId", label: "체크리스트 항목", type: "text" },
|
||||
{ key: "unit", label: "단위", type: "text" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "alarmThresholds", label: "알람 임계값", description: "PLC 값 임계치 경고 설정", type: "array-object", fields: [
|
||||
{ key: "tagName", label: "태그명", type: "text" },
|
||||
{ key: "lowerLimit", label: "하한", type: "number" },
|
||||
{ key: "upperLimit", label: "상한", type: "number" },
|
||||
{ key: "action", label: "동작", type: "select" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Sub-components: TagEditor, ArrayObjectEditor
|
||||
// ============================================================
|
||||
|
||||
function TagEditor({
|
||||
label,
|
||||
description,
|
||||
tags,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
onChange: (tags: string[]) => void;
|
||||
}) {
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
return (
|
||||
<div className="py-3 border-b last:border-0">
|
||||
<Label className="text-sm font-medium">{label}</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 mb-2">{description}</p>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{tags.map((tag, idx) => (
|
||||
<Badge key={`${tag}-${idx}`} variant="secondary" className="gap-1 pr-1">
|
||||
{tag}
|
||||
<button
|
||||
onClick={() => onChange(tags.filter((_, i) => i !== idx))}
|
||||
className="ml-0.5 hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && input.trim()) {
|
||||
e.preventDefault();
|
||||
onChange([...tags, input.trim()]);
|
||||
setInput("");
|
||||
}
|
||||
}}
|
||||
placeholder="추가 후 Enter"
|
||||
className="flex-1 h-8 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (input.trim()) {
|
||||
onChange([...tags, input.trim()]);
|
||||
setInput("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArrayObjectEditor({
|
||||
field,
|
||||
items,
|
||||
onChange,
|
||||
}: {
|
||||
field: SettingField;
|
||||
items: Record<string, unknown>[];
|
||||
onChange: (items: Record<string, unknown>[]) => void;
|
||||
}) {
|
||||
const addRow = () => {
|
||||
const newRow: Record<string, unknown> = {};
|
||||
field.fields?.forEach((f) => {
|
||||
newRow[f.key] = f.type === "number" ? 0 : "";
|
||||
});
|
||||
onChange([...items, newRow]);
|
||||
};
|
||||
|
||||
const updateRow = (index: number, key: string, value: unknown) => {
|
||||
const updated = items.map((item, i) => (i === index ? { ...item, [key]: value } : item));
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const removeRow = (index: number) => {
|
||||
onChange(items.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-3 border-b last:border-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">{field.label}</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{field.description}</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addRow}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
{items.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground py-2">항목이 없습니다. 추가 버튼을 눌러주세요.</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="flex items-start gap-2 p-2 bg-muted/30 rounded-md">
|
||||
<div className="flex-1 grid grid-cols-2 gap-2">
|
||||
{field.fields?.map((f) => (
|
||||
<div key={f.key}>
|
||||
<Label className="text-xs text-muted-foreground">{f.label}</Label>
|
||||
{f.type === "number" ? (
|
||||
<Input
|
||||
type="number"
|
||||
value={item[f.key] as number || 0}
|
||||
onChange={(e) => updateRow(index, f.key, Number(e.target.value))}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
) : f.type === "select" ? (
|
||||
<Select
|
||||
value={(item[f.key] as string) || ""}
|
||||
onValueChange={(v) => updateRow(index, f.key, v)}
|
||||
>
|
||||
<SelectTrigger size="sm" className="h-7 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="warn">경고</SelectItem>
|
||||
<SelectItem value="stop">정지</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={(item[f.key] as string) || ""}
|
||||
onChange={(e) => updateRow(index, f.key, e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 mt-4 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeRow(index)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SettingRow — renders a single setting field
|
||||
// ============================================================
|
||||
function SettingRow({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
field: SettingField;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
}) {
|
||||
switch (field.type) {
|
||||
case "toggle":
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 border-b last:border-0">
|
||||
<div className="pr-4">
|
||||
<Label className="text-sm font-medium">{field.label}</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{field.description}</p>
|
||||
</div>
|
||||
<Switch checked={!!value} onCheckedChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
case "text":
|
||||
return (
|
||||
<div className="py-3 border-b last:border-0 space-y-1.5">
|
||||
<Label className="text-sm font-medium">{field.label}</Label>
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
<Input
|
||||
value={(value as string) || ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.label}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "number":
|
||||
return (
|
||||
<div className="py-3 border-b last:border-0 space-y-1.5">
|
||||
<Label className="text-sm font-medium">{field.label}</Label>
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
<Input
|
||||
type="number"
|
||||
value={(value as number) ?? 0}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="h-9 w-32"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "select":
|
||||
return (
|
||||
<div className="py-3 border-b last:border-0 space-y-1.5">
|
||||
<Label className="text-sm font-medium">{field.label}</Label>
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
<Select value={(value as string) || field.options?.[0]?.value || ""} onValueChange={onChange}>
|
||||
<SelectTrigger size="sm" className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options?.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
case "color":
|
||||
return (
|
||||
<div className="py-3 border-b last:border-0 space-y-1.5">
|
||||
<Label className="text-sm font-medium">{field.label}</Label>
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={(value as string) || "#2563eb"}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-9 h-9 rounded-md cursor-pointer border border-input p-0.5"
|
||||
/>
|
||||
<Input
|
||||
value={(value as string) || "#2563eb"}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-9 w-32"
|
||||
placeholder="#hex"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "tags":
|
||||
return (
|
||||
<TagEditor
|
||||
label={field.label}
|
||||
description={field.description}
|
||||
tags={(value as string[]) || []}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
case "array-object":
|
||||
return (
|
||||
<ArrayObjectEditor
|
||||
field={field}
|
||||
items={(value as Record<string, unknown>[]) || []}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ScreenNav — top collapsible screen selector (세로 펼침)
|
||||
// ============================================================
|
||||
function ScreenNav({
|
||||
groups,
|
||||
selectedScreen,
|
||||
onSelect,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}: {
|
||||
groups: ScreenGroup[];
|
||||
selectedScreen: ScreenItem | null;
|
||||
onSelect: (screen: ScreenItem) => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}) {
|
||||
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
|
||||
|
||||
const handleGroupClick = (groupId: string) => {
|
||||
setExpandedGroup(expandedGroup === groupId ? null : groupId);
|
||||
};
|
||||
|
||||
const handleScreenSelect = (screen: ScreenItem) => {
|
||||
onSelect(screen);
|
||||
setExpandedGroup(null);
|
||||
if (!collapsed) onToggleCollapse(); // 선택 후 자동 접기
|
||||
};
|
||||
|
||||
if (collapsed) {
|
||||
// 접힌 상태: 현재 선택된 화면명 + 펼치기 버튼
|
||||
return (
|
||||
<div className="border-b bg-muted/20 px-4 py-2 flex items-center gap-3 shrink-0">
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors text-sm"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground rotate-90" />
|
||||
<span className="text-muted-foreground">화면 선택</span>
|
||||
</button>
|
||||
{selectedScreen && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
📍 {selectedScreen.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 펼친 상태: 메뉴 그룹 가로 나열 + 클릭 시 하위 화면 드롭
|
||||
return (
|
||||
<div className="border-b bg-muted/20 shrink-0">
|
||||
{/* 상단: 그룹 탭 가로 나열 + 접기 버튼 */}
|
||||
<div className="flex items-center gap-1 px-4 py-2 border-b border-border/50">
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors mr-2"
|
||||
title="접기"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground -rotate-90" />
|
||||
</button>
|
||||
{groups.map((group) => {
|
||||
const Icon = ICON_MAP[group.icon];
|
||||
const isExpanded = expandedGroup === group.id;
|
||||
const hasSelected = group.screens.some((s) => s.id === selectedScreen?.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={group.id}
|
||||
onClick={() => handleGroupClick(group.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
isExpanded
|
||||
? "bg-primary text-primary-foreground"
|
||||
: hasSelected
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted text-foreground/70"
|
||||
}`}
|
||||
>
|
||||
{Icon && <Icon className="h-3.5 w-3.5" />}
|
||||
{group.name}
|
||||
<ChevronRight
|
||||
className={`h-3 w-3 transition-transform ${isExpanded ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 하위 화면 목록 (펼쳐진 그룹) */}
|
||||
{expandedGroup && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-muted/30">
|
||||
<span className="text-xs text-muted-foreground mr-2">
|
||||
{groups.find((g) => g.id === expandedGroup)?.name}:
|
||||
</span>
|
||||
{groups
|
||||
.find((g) => g.id === expandedGroup)
|
||||
?.screens.map((screen) => {
|
||||
const isSelected = selectedScreen?.id === screen.id;
|
||||
return (
|
||||
<button
|
||||
key={screen.id}
|
||||
onClick={() => handleScreenSelect(screen)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground font-medium"
|
||||
: "bg-background border hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
{screen.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SettingsForm — auto-rendered from schema
|
||||
// ============================================================
|
||||
function SettingsForm({
|
||||
screenName,
|
||||
settingsKey,
|
||||
fields,
|
||||
values,
|
||||
onChange,
|
||||
}: {
|
||||
screenName: string;
|
||||
settingsKey: string;
|
||||
fields: SettingField[];
|
||||
values: Record<string, unknown>;
|
||||
onChange: (key: string, value: unknown) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 pb-3 border-b mb-1">
|
||||
<Settings2 className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-bold">{screenName} 설정</h2>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{settingsKey}
|
||||
</Badge>
|
||||
</div>
|
||||
{fields.map((field) => (
|
||||
<SettingRow
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={values[field.key]}
|
||||
onChange={(v) => onChange(field.key, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Main Page Component
|
||||
// ============================================================
|
||||
export default function PopSettingsMngPage() {
|
||||
const { user } = useAuth();
|
||||
const [settings, setSettings] = useState<PopSettings>(DEFAULT_SETTINGS);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedScreen, setSelectedScreen] = useState<ScreenItem | null>(
|
||||
SCREEN_GROUPS[0].screens[0],
|
||||
);
|
||||
const [navCollapsed, setNavCollapsed] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [syncToAll, setSyncToAll] = useState(false);
|
||||
const [lastPath, setLastPath] = useState("");
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
// ---- Load settings from screen_layouts_pop per screen ----
|
||||
const fetchSettings = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Collect all unique screenIds from SCREEN_GROUPS
|
||||
const allScreens: { screenId: number; settingsKey: string }[] = [];
|
||||
for (const group of SCREEN_GROUPS) {
|
||||
for (const screen of group.screens) {
|
||||
if (!allScreens.some((s) => s.screenId === screen.screenId && s.settingsKey === screen.settingsKey)) {
|
||||
allScreens.push({ screenId: screen.screenId, settingsKey: screen.settingsKey });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch popConfig from each screen's layout-pop
|
||||
const merged: PopSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
||||
|
||||
await Promise.all(
|
||||
allScreens.map(async ({ screenId, settingsKey }) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/screen-management/screens/${screenId}/layout-pop`);
|
||||
const popConfig = res.data?.data?.settings?.popConfig;
|
||||
if (popConfig) {
|
||||
const key = settingsKey as keyof PopSettings["screens"];
|
||||
(merged.screens as Record<string, Record<string, unknown>>)[key] = {
|
||||
...(merged.screens as Record<string, Record<string, unknown>>)[key],
|
||||
...popConfig,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Screen may not have layout-pop yet, use defaults
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
setSettings(merged);
|
||||
} catch {
|
||||
// Fallback: use defaults
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, [fetchSettings]);
|
||||
|
||||
// ---- iframe navigation sync ----
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
try {
|
||||
const path = iframeRef.current?.contentWindow?.location.pathname;
|
||||
if (path && path !== lastPath) {
|
||||
setLastPath(path);
|
||||
for (const group of SCREEN_GROUPS) {
|
||||
const found = group.screens.find((s) => path === s.url || path.startsWith(s.url + "/"));
|
||||
if (found) {
|
||||
setSelectedScreen(found);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// cross-origin: silently ignore
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [lastPath]);
|
||||
|
||||
// ---- Screen select handler ----
|
||||
const handleScreenSelect = (screen: ScreenItem) => {
|
||||
setSelectedScreen(screen);
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.src = screen.url;
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Settings update helper ----
|
||||
const updateScreenSetting = (settingsKey: string, fieldKey: string, value: unknown) => {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
screens: {
|
||||
...prev.screens,
|
||||
[settingsKey]: {
|
||||
...(prev.screens as Record<string, Record<string, unknown>>)[settingsKey],
|
||||
[fieldKey]: value,
|
||||
},
|
||||
},
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
// ---- Save to screen_layouts_pop per screen ----
|
||||
const handleSave = async () => {
|
||||
if (!selectedScreen) return;
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const currentKey = selectedScreen.settingsKey as keyof PopSettings["screens"];
|
||||
const popConfigToSave = (settings.screens as Record<string, Record<string, unknown>>)[currentKey];
|
||||
|
||||
// Helper: save popConfig to a single screen's layout-pop
|
||||
const saveToScreen = async (screenId: number, popConfig: Record<string, unknown>) => {
|
||||
// 1. Get current layout
|
||||
const layoutRes = await apiClient.get(`/screen-management/screens/${screenId}/layout-pop`).catch(() => null);
|
||||
const layoutData = layoutRes?.data?.data || { version: "pop-5.0", components: {}, settings: {}, gridConfig: {} };
|
||||
|
||||
// 2. Update settings.popConfig
|
||||
layoutData.settings = {
|
||||
...(layoutData.settings || {}),
|
||||
popConfig,
|
||||
};
|
||||
|
||||
// 3. Save
|
||||
await apiClient.post(`/screen-management/screens/${screenId}/layout-pop`, layoutData);
|
||||
};
|
||||
|
||||
// Save to the selected screen
|
||||
await saveToScreen(selectedScreen.screenId, popConfigToSave);
|
||||
|
||||
// If syncToAll is on, save to all other screens with the same settingsKey
|
||||
if (syncToAll) {
|
||||
const otherScreens: number[] = [];
|
||||
for (const group of SCREEN_GROUPS) {
|
||||
for (const screen of group.screens) {
|
||||
if (screen.settingsKey === selectedScreen.settingsKey && screen.screenId !== selectedScreen.screenId) {
|
||||
if (!otherScreens.includes(screen.screenId)) {
|
||||
otherScreens.push(screen.screenId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(otherScreens.map((sid) => saveToScreen(sid, popConfigToSave)));
|
||||
}
|
||||
|
||||
setHasChanges(false);
|
||||
// Reload iframe to apply settings
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.contentWindow?.location.reload();
|
||||
}
|
||||
alert("설정이 저장되었습니다.");
|
||||
} catch {
|
||||
alert("저장에 실패했습니다.");
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
// ---- Reset to defaults ----
|
||||
const handleReset = () => {
|
||||
if (window.confirm("모든 설정을 기본값으로 초기화하시겠습니까?")) {
|
||||
setSettings(DEFAULT_SETTINGS);
|
||||
setHasChanges(true);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Current screen schema values ----
|
||||
const currentSettingsKey = selectedScreen?.settingsKey || "inbound";
|
||||
const currentFields = SETTINGS_SCHEMA[currentSettingsKey] || [];
|
||||
const currentValues = (settings.screens as Record<string, Record<string, unknown>>)[currentSettingsKey] || {};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* ---- Header ---- */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b bg-background">
|
||||
<div className="flex items-center gap-3">
|
||||
<Settings2 className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-base font-bold">POP 화면 설정</h1>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{user?.companyCode || "COMPANY_7"}
|
||||
</Badge>
|
||||
{hasChanges && (
|
||||
<Badge variant="warning" className="text-xs">
|
||||
미저장 변경
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleReset} disabled={saving}>
|
||||
<RotateCcw className="h-3.5 w-3.5 mr-1" />
|
||||
기본값 초기화
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={saving || !hasChanges}>
|
||||
{saving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
{saving ? "저장중..." : "저장"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- Screen Nav (top, collapsible vertically) ---- */}
|
||||
<ScreenNav
|
||||
groups={SCREEN_GROUPS}
|
||||
selectedScreen={selectedScreen}
|
||||
onSelect={handleScreenSelect}
|
||||
collapsed={navCollapsed}
|
||||
onToggleCollapse={() => setNavCollapsed((prev) => !prev)}
|
||||
/>
|
||||
|
||||
{/* ---- Body: iframe (left) + settings (right) ---- */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left: iframe (POP preview) */}
|
||||
<div className="flex-1 min-w-0 bg-muted/10 relative">
|
||||
{loading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={selectedScreen?.url || "/pop/home"}
|
||||
className="w-full h-full border-0"
|
||||
title="POP 미리보기"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Settings form */}
|
||||
<div className="w-[420px] flex-shrink-0 overflow-y-auto p-4 border-l bg-background">
|
||||
{selectedScreen && currentFields.length > 0 ? (
|
||||
<>
|
||||
<SettingsForm
|
||||
screenName={selectedScreen.name}
|
||||
settingsKey={currentSettingsKey}
|
||||
fields={currentFields}
|
||||
values={currentValues}
|
||||
onChange={(key, value) => updateScreenSetting(currentSettingsKey, key, value)}
|
||||
/>
|
||||
{/* Sync to all screens with same settingsKey */}
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="sync-to-all"
|
||||
checked={syncToAll}
|
||||
onCheckedChange={(checked) => setSyncToAll(!!checked)}
|
||||
/>
|
||||
<Label htmlFor="sync-to-all" className="text-sm cursor-pointer">
|
||||
같은 유형의 모든 화면에 적용
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-6">
|
||||
저장 시 동일 설정키({currentSettingsKey})를 공유하는 화면에도 설정을 동기화합니다.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Settings2 className="h-12 w-12 mb-3 opacity-30" />
|
||||
<p className="text-sm">상단에서 화면을 선택하세요</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -159,7 +159,6 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_16/purchase/order": dynamic(() => import("@/app/(main)/COMPANY_16/purchase/order/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/purchase/purchase-item": dynamic(() => import("@/app/(main)/COMPANY_16/purchase/purchase-item/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/purchase/supplier": dynamic(() => import("@/app/(main)/COMPANY_16/purchase/supplier/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/quality/inspection": dynamic(() => import("@/app/(main)/COMPANY_16/quality/inspection/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/quality/item-inspection": dynamic(() => import("@/app/(main)/COMPANY_16/quality/item-inspection/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/mold/info": dynamic(() => import("@/app/(main)/COMPANY_16/mold/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/design/project": dynamic(() => import("@/app/(main)/COMPANY_16/design/project/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
@@ -252,6 +252,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [showCompanySwitcher, setShowCompanySwitcher] = useState(false);
|
||||
const [currentCompanyName, setCurrentCompanyName] = useState<string>("");
|
||||
const [hasPopMenus, setHasPopMenus] = useState(false);
|
||||
|
||||
// URL 직접 접근 시 탭 자동 열기
|
||||
useEffect(() => {
|
||||
@@ -316,6 +317,26 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
return () => window.removeEventListener("resize", checkIsMobile);
|
||||
}, []);
|
||||
|
||||
// POP 메뉴 존재 여부 확인
|
||||
useEffect(() => {
|
||||
const checkPopMenus = async () => {
|
||||
try {
|
||||
const response = await menuApi.getPopMenus();
|
||||
if (response.success && response.data) {
|
||||
const { childMenus, landingMenu } = response.data;
|
||||
setHasPopMenus(!!(landingMenu?.menu_url || childMenus.length > 0));
|
||||
} else {
|
||||
setHasPopMenus(false);
|
||||
}
|
||||
} catch {
|
||||
setHasPopMenus(false);
|
||||
}
|
||||
};
|
||||
if (user) {
|
||||
checkPopMenus();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// 프로필 관련 로직
|
||||
const {
|
||||
isModalOpen,
|
||||
@@ -670,10 +691,12 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
<FileCheck className="mr-2 h-4 w-4" />
|
||||
<span>결재함</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handlePopModeClick}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
<span>POP 모드</span>
|
||||
</DropdownMenuItem>
|
||||
{hasPopMenus && (
|
||||
<DropdownMenuItem onClick={handlePopModeClick}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
<span>POP 모드</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-1 py-0.5">
|
||||
<ThemeToggle />
|
||||
@@ -846,10 +869,12 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
<FileCheck className="mr-2 h-4 w-4" />
|
||||
<span>결재함</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handlePopModeClick}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
<span>POP 모드</span>
|
||||
</DropdownMenuItem>
|
||||
{hasPopMenus && (
|
||||
<DropdownMenuItem onClick={handlePopModeClick}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
<span>POP 모드</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect, useRef, ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
interface PopShellProps {
|
||||
children: ReactNode;
|
||||
@@ -13,6 +14,10 @@ interface PopShellProps {
|
||||
|
||||
export function PopShell({ children, showBanner = true, title, showBack = false, headerRight }: PopShellProps) {
|
||||
const router = useRouter();
|
||||
const { user, logout } = useAuth();
|
||||
const displayName = user?.userName || user?.userId || "사용자";
|
||||
const deptName = user?.deptName || "";
|
||||
const initial = displayName.charAt(0);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [hours, setHours] = useState("00");
|
||||
const [minutes, setMinutes] = useState("00");
|
||||
@@ -90,10 +95,7 @@ export function PopShell({ children, showBanner = true, title, showBack = false,
|
||||
|
||||
const handleLogout = () => {
|
||||
setProfileOpen(false);
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("accessToken");
|
||||
localStorage.removeItem("refreshToken");
|
||||
window.location.href = "/login";
|
||||
logout();
|
||||
};
|
||||
|
||||
const marqueeText =
|
||||
@@ -148,7 +150,7 @@ export function PopShell({ children, showBanner = true, title, showBack = false,
|
||||
) : (
|
||||
<>
|
||||
<span className="text-white text-lg font-bold tracking-tight leading-tight truncate">
|
||||
탑씰
|
||||
{user?.companyName || "POP"}
|
||||
</span>
|
||||
<span className="text-white/50 text-xs font-medium leading-tight">
|
||||
현장 관리 시스템
|
||||
@@ -224,14 +226,14 @@ export function PopShell({ children, showBanner = true, title, showBack = false,
|
||||
className="flex items-center gap-2.5 cursor-pointer"
|
||||
>
|
||||
<div className="hidden sm:flex flex-col items-end">
|
||||
<span className="text-sm text-white/90 font-semibold leading-tight">김철수</span>
|
||||
<span className="text-xs text-white/40 font-medium leading-tight">생산1팀</span>
|
||||
<span className="text-sm text-white/90 font-semibold leading-tight">{displayName}</span>
|
||||
<span className="text-xs text-white/40 font-medium leading-tight">{deptName}</span>
|
||||
</div>
|
||||
<div
|
||||
className="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center text-sm font-bold text-white shrink-0 transition-transform active:scale-95"
|
||||
style={{ boxShadow: "0 2px 8px rgba(59,130,246,.35)" }}
|
||||
>
|
||||
김
|
||||
{initial}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -245,8 +247,8 @@ export function PopShell({ children, showBanner = true, title, showBack = false,
|
||||
>
|
||||
{/* User Info */}
|
||||
<div className="px-4 py-3 border-b border-gray-100">
|
||||
<p className="text-sm font-semibold text-gray-900">김철수</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">생산1팀</p>
|
||||
<p className="text-sm font-semibold text-gray-900">{displayName}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{deptName || user?.userId}</p>
|
||||
</div>
|
||||
|
||||
{/* Menu Items */}
|
||||
@@ -316,17 +318,7 @@ export function PopShell({ children, showBanner = true, title, showBack = false,
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* ===== FOOTER ===== */}
|
||||
<footer className="border-t border-gray-200 bg-white px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
|
||||
<div className="max-w-[1400px] mx-auto flex flex-col sm:flex-row items-center justify-between gap-2 text-xs text-gray-400">
|
||||
<span>© 2026 탑씰. All rights reserved.</span>
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
<span>Version 1.0.0</span>
|
||||
<span className="hidden sm:inline">|</span>
|
||||
<span>긴급연락: 042-XXX-XXXX</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
{/* FOOTER 삭제 — POP 화면에서 불필요 */}
|
||||
|
||||
{/* Marquee keyframes */}
|
||||
<style jsx global>{`
|
||||
|
||||
@@ -145,9 +145,9 @@ export function RecentActivity() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 sm:gap-3">
|
||||
{activities.map((item) => (
|
||||
{activities.map((item, idx) => (
|
||||
<div
|
||||
key={item.id}
|
||||
key={`${item.id}-${idx}`}
|
||||
className="flex items-center gap-3 sm:gap-4 p-3 rounded-xl transition-all duration-150 hover:bg-gray-50 hover:translate-x-1"
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -36,18 +36,22 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
||||
}) => {
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [scannedCode, setScannedCode] = useState<string>("");
|
||||
const [manualInput, setManualInput] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
|
||||
const webcamRef = useRef<Webcam>(null);
|
||||
const codeReaderRef = useRef<BrowserMultiFormatReader | null>(null);
|
||||
const scanIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const manualInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 바코드 리더 초기화 + 모달 열릴 때 상태 리셋
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setScannedCode("");
|
||||
setManualInput("");
|
||||
setError("");
|
||||
setIsScanning(false);
|
||||
setHasPermission(null);
|
||||
codeReaderRef.current = new BrowserMultiFormatReader();
|
||||
}
|
||||
|
||||
@@ -73,10 +77,15 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
// 후면 카메라 먼저 시도, 실패하면 전면 카메라 fallback
|
||||
let stream: MediaStream;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } });
|
||||
} catch {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
}
|
||||
setHasPermission(true);
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
toast.success("카메라 권한이 허용되었습니다.");
|
||||
} catch (err: any) {
|
||||
setHasPermission(false);
|
||||
|
||||
@@ -154,12 +163,14 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 수동 확인 버튼
|
||||
// 수동 확인 버튼 (스캔 결과 또는 직접 입력)
|
||||
const handleConfirm = () => {
|
||||
if (scannedCode) {
|
||||
onScanSuccess(scannedCode);
|
||||
const code = scannedCode || manualInput.trim();
|
||||
if (code) {
|
||||
onScanSuccess(code); // 호출 측에서 검색 필드를 덮어쓰기
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error("스캔된 바코드가 없습니다.");
|
||||
toast.error("바코드를 스캔하거나 직접 입력해주세요.");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -254,7 +265,10 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
||||
audio={false}
|
||||
screenshotFormat="image/jpeg"
|
||||
videoConstraints={{
|
||||
facingMode: "environment",
|
||||
facingMode: { ideal: "environment" },
|
||||
}}
|
||||
onUserMediaError={() => {
|
||||
// environment 카메라 실패 시 자동 fallback (Webcam 내부 처리)
|
||||
}}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
@@ -285,6 +299,41 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 수동 입력 (카메라 사용 불가 시 또는 외장 스캐너 사용 시) */}
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">직접 입력 또는 외장 스캐너</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={manualInputRef}
|
||||
type="text"
|
||||
value={manualInput}
|
||||
onChange={(e) => setManualInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && manualInput.trim()) {
|
||||
e.preventDefault();
|
||||
onScanSuccess(manualInput.trim());
|
||||
onOpenChange(false);
|
||||
}
|
||||
}}
|
||||
placeholder="바코드/QR 번호 입력 후 Enter"
|
||||
className="flex-1 h-11 rounded-lg border border-border px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
autoFocus={hasPermission === false}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (manualInput.trim()) {
|
||||
onScanSuccess(manualInput.trim());
|
||||
onOpenChange(false);
|
||||
}
|
||||
}}
|
||||
disabled={!manualInput.trim()}
|
||||
className="h-11 px-4"
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 바코드 포맷 정보 */}
|
||||
<div className="rounded-md border border-border bg-muted/50 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
|
||||
@@ -43,27 +43,89 @@ function QtyInput({
|
||||
onChange: (v: number) => void;
|
||||
max: number;
|
||||
}) {
|
||||
const [padOpen, setPadOpen] = useState(false);
|
||||
const [padValue, setPadValue] = useState(String(value));
|
||||
|
||||
const handlePadOpen = () => {
|
||||
setPadValue("");
|
||||
setPadOpen(true);
|
||||
};
|
||||
|
||||
const handlePadKey = (key: string) => {
|
||||
if (key === "backspace") {
|
||||
setPadValue((prev) => prev.length > 1 ? prev.slice(0, -1) : "0");
|
||||
} else if (key === "clear") {
|
||||
setPadValue("0");
|
||||
} else if (key === "max") {
|
||||
setPadValue(String(max));
|
||||
} else {
|
||||
setPadValue((prev) => prev === "0" ? key : prev + key);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePadConfirm = () => {
|
||||
const num = Math.min(Math.max(0, parseInt(padValue, 10) || 0), max);
|
||||
onChange(num);
|
||||
setPadOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onChange(Math.max(0, value - 1))}
|
||||
className="w-10 h-10 rounded-lg bg-gray-100 text-gray-600 text-lg font-bold flex items-center justify-center active:scale-95 transition-all hover:bg-gray-200"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span
|
||||
className="w-12 text-center text-lg font-bold text-gray-900"
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onChange(Math.min(max, value + 1))}
|
||||
className="w-10 h-10 rounded-lg bg-gray-100 text-gray-600 text-lg font-bold flex items-center justify-center active:scale-95 transition-all hover:bg-gray-200"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onChange(Math.max(0, value - 1))}
|
||||
className="w-10 h-10 rounded-lg bg-gray-100 text-gray-600 text-lg font-bold flex items-center justify-center active:scale-95 transition-all hover:bg-gray-200"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePadOpen}
|
||||
className="w-16 h-10 text-center text-lg font-bold text-gray-900 bg-gray-50 rounded-lg border-2 border-gray-200 hover:border-red-300 active:scale-95 transition-all"
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onChange(Math.min(max, value + 1))}
|
||||
className="w-10 h-10 rounded-lg bg-gray-100 text-gray-600 text-lg font-bold flex items-center justify-center active:scale-95 transition-all hover:bg-gray-200"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 숫자 키패드 모달 */}
|
||||
{padOpen && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={() => setPadOpen(false)} />
|
||||
<div className="relative bg-white rounded-2xl shadow-2xl p-4 w-[280px] z-10">
|
||||
<div className="text-center mb-3">
|
||||
<p className="text-sm text-gray-500">불량 수량 (최대 {max})</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1" style={{ fontVariantNumeric: "tabular-nums" }}>{padValue || "0"}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
{["1","2","3","4","5","6","7","8","9"].map((k) => (
|
||||
<button key={k} onClick={() => handlePadKey(k)}
|
||||
className="h-12 rounded-xl bg-gray-100 text-lg font-bold text-gray-800 active:scale-95 active:bg-gray-200 transition-all">{k}</button>
|
||||
))}
|
||||
<button onClick={() => handlePadKey("clear")}
|
||||
className="h-12 rounded-xl bg-gray-200 text-sm font-bold text-gray-600 active:scale-95 transition-all">C</button>
|
||||
<button onClick={() => handlePadKey("0")}
|
||||
className="h-12 rounded-xl bg-gray-100 text-lg font-bold text-gray-800 active:scale-95 transition-all">0</button>
|
||||
<button onClick={() => handlePadKey("backspace")}
|
||||
className="h-12 rounded-xl bg-gray-200 text-sm font-bold text-gray-600 active:scale-95 transition-all">←</button>
|
||||
</div>
|
||||
<button onClick={() => handlePadKey("max")}
|
||||
className="w-full h-10 rounded-xl bg-red-50 text-red-600 text-sm font-bold mb-2 active:scale-95 transition-all">MAX ({max})</button>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setPadOpen(false)}
|
||||
className="flex-1 h-11 rounded-xl bg-gray-100 text-gray-700 font-semibold active:scale-95 transition-all">취소</button>
|
||||
<button onClick={handlePadConfirm}
|
||||
className="flex-1 h-11 rounded-xl bg-red-500 text-white font-bold active:scale-95 transition-all">확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
export interface PopSettings {
|
||||
version: string;
|
||||
screens: {
|
||||
processExecution: {
|
||||
materialInput: boolean;
|
||||
photoUpload: boolean;
|
||||
plcEnabled: boolean;
|
||||
bomFlexible: boolean;
|
||||
packagingOptions: string[];
|
||||
defectTypes: string[];
|
||||
reworkTargetSelection: boolean;
|
||||
groupPhotoEnabled: boolean;
|
||||
dateFilter: boolean;
|
||||
lastProcessInventory: "auto" | "manual" | "button";
|
||||
defaultWarehouse: boolean;
|
||||
inspectionAutoJudge: "off" | "warn" | "fail";
|
||||
standardTimeDisplay: boolean;
|
||||
progressDisplay: boolean;
|
||||
};
|
||||
inbound: {
|
||||
inspectionRequired: boolean;
|
||||
photoUpload: boolean;
|
||||
barcodeEnabled: boolean;
|
||||
packagingRecord: boolean;
|
||||
defectSeparation: boolean;
|
||||
};
|
||||
outbound: {
|
||||
photoUpload: boolean;
|
||||
barcodeEnabled: boolean;
|
||||
};
|
||||
home: {
|
||||
kpiCarousel: boolean;
|
||||
recentActivity: boolean;
|
||||
bannerEnabled: boolean;
|
||||
bannerText: string;
|
||||
iconThemeColor: string;
|
||||
iconCustomImages: boolean;
|
||||
dashboardLayout: "default" | "compact" | "detailed";
|
||||
};
|
||||
plc: {
|
||||
connectionType: "db" | "opcua" | "rest";
|
||||
refreshInterval: number;
|
||||
tagMappings: Array<{
|
||||
tagName: string;
|
||||
processCode: string;
|
||||
checklistItemId: string;
|
||||
unit: string;
|
||||
}>;
|
||||
alarmThresholds: Array<{
|
||||
tagName: string;
|
||||
lowerLimit: number;
|
||||
upperLimit: number;
|
||||
action: "warn" | "stop";
|
||||
}>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: PopSettings = {
|
||||
version: "hardcoded-1.0",
|
||||
screens: {
|
||||
processExecution: {
|
||||
materialInput: true,
|
||||
photoUpload: true,
|
||||
plcEnabled: false,
|
||||
bomFlexible: true,
|
||||
packagingOptions: ["낱개", "박스", "파렛트"],
|
||||
defectTypes: ["스크래치", "치수불량", "변색", "크랙", "기포"],
|
||||
reworkTargetSelection: true,
|
||||
groupPhotoEnabled: false,
|
||||
dateFilter: false,
|
||||
lastProcessInventory: "manual",
|
||||
defaultWarehouse: false,
|
||||
inspectionAutoJudge: "off",
|
||||
standardTimeDisplay: false,
|
||||
progressDisplay: false,
|
||||
},
|
||||
inbound: {
|
||||
inspectionRequired: false,
|
||||
photoUpload: false,
|
||||
barcodeEnabled: true,
|
||||
packagingRecord: false,
|
||||
defectSeparation: false,
|
||||
},
|
||||
outbound: {
|
||||
photoUpload: false,
|
||||
barcodeEnabled: true,
|
||||
},
|
||||
home: {
|
||||
kpiCarousel: true,
|
||||
recentActivity: true,
|
||||
bannerEnabled: false,
|
||||
bannerText: "",
|
||||
iconThemeColor: "#2563eb",
|
||||
iconCustomImages: false,
|
||||
dashboardLayout: "default",
|
||||
},
|
||||
plc: {
|
||||
connectionType: "db",
|
||||
refreshInterval: 5,
|
||||
tagMappings: [],
|
||||
alarmThresholds: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// URL -> screen_id mapping
|
||||
const POP_SCREEN_MAP: Record<string, number> = {
|
||||
"/pop/home": 6526,
|
||||
"/pop/inbound": 6529,
|
||||
"/pop/inbound/purchase": 6528,
|
||||
"/pop/inbound/cart": 6527,
|
||||
"/pop/outbound": 6,
|
||||
"/pop/outbound/sales": 5,
|
||||
"/pop/production": 8,
|
||||
"/pop/production/process": 7,
|
||||
};
|
||||
|
||||
// URL -> settingsKey mapping
|
||||
const PATH_TO_SETTINGS_KEY: Record<string, keyof PopSettings["screens"]> = {
|
||||
"/pop/home": "home",
|
||||
"/pop/inbound": "inbound",
|
||||
"/pop/inbound/purchase": "inbound",
|
||||
"/pop/inbound/cart": "inbound",
|
||||
"/pop/outbound": "outbound",
|
||||
"/pop/outbound/sales": "outbound",
|
||||
"/pop/production": "processExecution",
|
||||
"/pop/production/process": "processExecution",
|
||||
};
|
||||
|
||||
function getScreenIdFromPath(pathname: string): number | null {
|
||||
// Exact match first
|
||||
if (POP_SCREEN_MAP[pathname]) return POP_SCREEN_MAP[pathname];
|
||||
// Longest-prefix match (e.g. /pop/production/process/xxx -> 7)
|
||||
const sorted = Object.keys(POP_SCREEN_MAP).sort((a, b) => b.length - a.length);
|
||||
for (const path of sorted) {
|
||||
if (pathname.startsWith(path)) return POP_SCREEN_MAP[path];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSettingsKeyFromPath(pathname: string): keyof PopSettings["screens"] | null {
|
||||
// Exact match first
|
||||
if (PATH_TO_SETTINGS_KEY[pathname]) return PATH_TO_SETTINGS_KEY[pathname];
|
||||
// Longest-prefix match
|
||||
const sorted = Object.keys(PATH_TO_SETTINGS_KEY).sort((a, b) => b.length - a.length);
|
||||
for (const path of sorted) {
|
||||
if (pathname.startsWith(path)) return PATH_TO_SETTINGS_KEY[path];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Per-screenId cache to avoid redundant fetches
|
||||
const screenCache: Record<number, Record<string, unknown>> = {};
|
||||
|
||||
export function usePopSettings(screenPath?: string) {
|
||||
const autoPathname = usePathname();
|
||||
const pathname = screenPath || autoPathname || "";
|
||||
|
||||
const [settings, setSettings] = useState<PopSettings>(DEFAULT_SETTINGS);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
const screenId = getScreenIdFromPath(pathname);
|
||||
const settingsKey = getSettingsKeyFromPath(pathname);
|
||||
|
||||
if (!screenId || !settingsKey) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use cache if available
|
||||
if (screenCache[screenId]) {
|
||||
const popConfig = screenCache[screenId];
|
||||
const merged = { ...DEFAULT_SETTINGS };
|
||||
merged.screens = {
|
||||
...merged.screens,
|
||||
[settingsKey]: { ...merged.screens[settingsKey], ...popConfig },
|
||||
};
|
||||
setSettings(merged);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiClient
|
||||
.get(`/screen-management/screens/${screenId}/layout-pop`)
|
||||
.catch(() => null);
|
||||
|
||||
if (res?.data?.data?.settings?.popConfig) {
|
||||
const popConfig = res.data.data.settings.popConfig;
|
||||
screenCache[screenId] = popConfig;
|
||||
|
||||
const merged = { ...DEFAULT_SETTINGS };
|
||||
merged.screens = {
|
||||
...merged.screens,
|
||||
[settingsKey]: { ...merged.screens[settingsKey], ...popConfig },
|
||||
};
|
||||
setSettings(merged);
|
||||
}
|
||||
} catch {
|
||||
// Use default settings on failure
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, [fetchSettings]);
|
||||
|
||||
return { settings, loading };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+39
-26
@@ -107,7 +107,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tanstack/react-query-devtools": "^5.86.0",
|
||||
"@types/jsbarcode": "^3.11.4",
|
||||
@@ -119,7 +118,6 @@
|
||||
"eslint-config-next": "15.4.4",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"playwright": "^1.58.2",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"prisma": "^6.14.0",
|
||||
@@ -272,6 +270,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -313,6 +312,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -346,6 +346,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -1416,22 +1417,6 @@
|
||||
"url": "https://opencollective.com/pkgr"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
@@ -3077,6 +3062,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
|
||||
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.17.8",
|
||||
"@types/react-reconciler": "^0.32.0",
|
||||
@@ -3736,6 +3722,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
|
||||
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.6"
|
||||
},
|
||||
@@ -3830,6 +3817,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz",
|
||||
"integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
@@ -4173,6 +4161,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz",
|
||||
"integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.3.0",
|
||||
"prosemirror-collab": "^1.3.1",
|
||||
@@ -6673,6 +6662,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -6683,6 +6673,7 @@
|
||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -6725,6 +6716,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
@@ -6807,6 +6799,7 @@
|
||||
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.2",
|
||||
"@typescript-eslint/types": "8.46.2",
|
||||
@@ -7439,6 +7432,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -8589,7 +8583,8 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/d3": {
|
||||
"version": "7.9.0",
|
||||
@@ -8911,6 +8906,7 @@
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -9692,6 +9688,7 @@
|
||||
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -9780,6 +9777,7 @@
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@@ -9881,6 +9879,7 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -10502,7 +10501,6 @@
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -11052,6 +11050,7 @@
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
@@ -11832,7 +11831,8 @@
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
@@ -13035,8 +13035,8 @@
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
@@ -13054,8 +13054,8 @@
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
@@ -13183,6 +13183,7 @@
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -13476,6 +13477,7 @@
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
@@ -13505,6 +13507,7 @@
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
@@ -13553,6 +13556,7 @@
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
|
||||
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.20.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
@@ -13756,6 +13760,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -13825,6 +13830,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
@@ -13875,6 +13881,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
||||
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -13916,7 +13923,8 @@
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0",
|
||||
@@ -14224,6 +14232,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
@@ -14246,7 +14255,8 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/recharts/node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
@@ -15304,7 +15314,8 @@
|
||||
"version": "0.180.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/three-mesh-bvh": {
|
||||
"version": "0.8.3",
|
||||
@@ -15392,6 +15403,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -15740,6 +15752,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
||||
@@ -116,7 +116,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tanstack/react-query-devtools": "^5.86.0",
|
||||
"@types/jsbarcode": "^3.11.4",
|
||||
@@ -128,7 +127,6 @@
|
||||
"eslint-config-next": "15.4.4",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"playwright": "^1.58.2",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"prisma": "^6.14.0",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+2
-50
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "ERP-node",
|
||||
"name": "vexplor_dev",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
@@ -17,8 +17,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/oracledb": "^6.9.1",
|
||||
"@types/pg": "^8.15.5",
|
||||
"playwright": "^1.58.2"
|
||||
"@types/pg": "^8.15.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure-rest/core-client": {
|
||||
@@ -1476,21 +1475,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -2079,38 +2063,6 @@
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
|
||||
+1
-2
@@ -12,7 +12,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/oracledb": "^6.9.1",
|
||||
"@types/pg": "^8.15.5",
|
||||
"playwright": "^1.58.2"
|
||||
"@types/pg": "^8.15.5"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user