Merge branch 'main' of https://g.wace.me/jskim/vexplor_dev
This commit is contained in:
Generated
+2
-12
@@ -1050,7 +1050,6 @@
|
||||
"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,7 +2383,6 @@
|
||||
"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",
|
||||
@@ -3502,7 +3500,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -3748,7 +3745,6 @@
|
||||
"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",
|
||||
@@ -3977,7 +3973,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -4527,7 +4522,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.3",
|
||||
"caniuse-lite": "^1.0.30001741",
|
||||
@@ -5903,7 +5897,6 @@
|
||||
"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",
|
||||
@@ -6182,7 +6175,6 @@
|
||||
"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",
|
||||
@@ -7769,7 +7761,6 @@
|
||||
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/core": "^29.7.0",
|
||||
"@jest/types": "^29.6.3",
|
||||
@@ -8739,6 +8730,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -9708,7 +9700,6 @@
|
||||
"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",
|
||||
@@ -10632,6 +10623,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -11525,7 +11517,6 @@
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
@@ -11631,7 +11622,6 @@
|
||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
||||
@@ -7,9 +7,8 @@ import { query, queryOne } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { encryptionService } from "../services/encryptionService";
|
||||
import {
|
||||
runScheduleNow,
|
||||
sendSmartFactoryLog,
|
||||
getTodayPlanStatus,
|
||||
planDailySends,
|
||||
} from "../utils/smartFactoryLog";
|
||||
|
||||
/**
|
||||
@@ -254,7 +253,7 @@ export const upsertSchedule = async (
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode, isActive, timeStart, timeEnd, excludeWeekend, excludeHolidays } = req.body;
|
||||
const { companyCode, isActive, timeStart, timeEnd, excludeWeekend, excludeHolidays, dailyCount } = req.body;
|
||||
|
||||
if (!companyCode) {
|
||||
res.status(400).json({ success: false, message: "회사코드는 필수입니다." });
|
||||
@@ -262,11 +261,11 @@ export const upsertSchedule = async (
|
||||
}
|
||||
|
||||
await query(
|
||||
`INSERT INTO smart_factory_schedule (company_code, is_active, time_start, time_end, exclude_weekend, exclude_holidays, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||
`INSERT INTO smart_factory_schedule (company_code, is_active, time_start, time_end, exclude_weekend, exclude_holidays, daily_count, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||
ON CONFLICT (company_code) DO UPDATE SET
|
||||
is_active = $2, time_start = $3, time_end = $4,
|
||||
exclude_weekend = $5, exclude_holidays = $6, updated_at = NOW()`,
|
||||
exclude_weekend = $5, exclude_holidays = $6, daily_count = $7, updated_at = NOW()`,
|
||||
[
|
||||
companyCode,
|
||||
isActive ?? false,
|
||||
@@ -274,13 +273,12 @@ export const upsertSchedule = async (
|
||||
timeEnd || "17:30",
|
||||
excludeWeekend ?? true,
|
||||
excludeHolidays ?? true,
|
||||
Math.max(1, Math.min(3, dailyCount || 1)),
|
||||
]
|
||||
);
|
||||
|
||||
// 스케줄 변경 시 오늘 계획 재생성
|
||||
await planDailySends();
|
||||
|
||||
res.json({ success: true, message: "스케줄이 저장되었습니다." });
|
||||
// 계획은 매일 00:05에만 생성 (즉시 재생성하면 지난 시각 소급 전송 위험)
|
||||
res.json({ success: true, message: "스케줄이 저장되었습니다. 내일 00:05부터 적용됩니다." });
|
||||
} catch (error) {
|
||||
logger.error("스케줄 저장 실패:", error);
|
||||
res.status(500).json({ success: false, message: "스케줄 저장 실패" });
|
||||
@@ -307,23 +305,6 @@ export const deleteSchedule = async (
|
||||
/**
|
||||
* POST /api/admin/smart-factory-log/schedules/:companyCode/run-now
|
||||
*/
|
||||
export const runScheduleNowHandler = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode } = req.params;
|
||||
const result = await runScheduleNow(companyCode);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
logger.error("즉시 실행 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : "즉시 실행 실패",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/admin/smart-factory-log/schedules/today-plan
|
||||
*/
|
||||
@@ -502,3 +483,92 @@ export const deleteApiKey = async (
|
||||
res.status(500).json({ success: false, message: "API 키 삭제 실패" });
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 즉시 전송 ───
|
||||
|
||||
/**
|
||||
* GET /api/admin/smart-factory-log/users/:companyCode
|
||||
* 회사별 사용자 목록 조회 (즉시 전송 대상 선택용)
|
||||
*/
|
||||
export const getCompanyUsers = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode } = req.params;
|
||||
|
||||
const users = await query<any>(
|
||||
`SELECT user_id, user_name, dept_name
|
||||
FROM user_info
|
||||
WHERE company_code = $1 AND (status = 'active' OR status IS NULL)
|
||||
ORDER BY user_name`,
|
||||
[companyCode]
|
||||
);
|
||||
|
||||
res.json({ success: true, data: users });
|
||||
} catch (error) {
|
||||
logger.error("사용자 목록 조회 실패:", error);
|
||||
res.status(500).json({ success: false, message: "사용자 목록 조회 실패" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/admin/smart-factory-log/send-now
|
||||
* 선택한 사용자 즉시 전송
|
||||
* body: { companyCode, userIds: string[], timeStart?, timeEnd? }
|
||||
*/
|
||||
export const sendNow = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode, userIds } = req.body;
|
||||
|
||||
logger.info(`=== 즉시 전송 API 호출 === companyCode=${companyCode}, userIds=${JSON.stringify(userIds)}`);
|
||||
|
||||
if (!companyCode || !userIds || userIds.length === 0) {
|
||||
res.status(400).json({ success: false, message: "회사코드와 사용자를 선택해주세요." });
|
||||
return;
|
||||
}
|
||||
|
||||
// 사용자 정보 조회
|
||||
const users = await query<{ user_id: string; user_name: string }>(
|
||||
`SELECT user_id, user_name FROM user_info WHERE company_code = $1 AND user_id = ANY($2)`,
|
||||
[companyCode, userIds]
|
||||
);
|
||||
|
||||
logger.info(`즉시 전송 대상: ${users.length}명 (조회된 사용자: ${users.map(u => u.user_id).join(", ")})`);
|
||||
|
||||
// 현재 시간으로 즉시 전송
|
||||
let success = 0;
|
||||
let fail = 0;
|
||||
const remoteAddr = req.ip || "127.0.0.1";
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
logger.info(`즉시 전송 시작: ${user.user_id}`);
|
||||
await sendSmartFactoryLog({
|
||||
userId: user.user_id,
|
||||
userName: user.user_name,
|
||||
remoteAddr,
|
||||
useType: "접속",
|
||||
companyCode,
|
||||
});
|
||||
success++;
|
||||
logger.info(`즉시 전송 성공: ${user.user_id}`);
|
||||
} catch (e) {
|
||||
fail++;
|
||||
logger.error(`즉시 전송 실패: ${user.user_id}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { total: users.length, success, fail },
|
||||
message: `${success}명 전송 완료${fail > 0 ? `, ${fail}명 실패` : ""}`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("즉시 전송 실패:", error);
|
||||
res.status(500).json({ success: false, message: "즉시 전송 실패" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
getSchedules,
|
||||
upsertSchedule,
|
||||
deleteSchedule,
|
||||
runScheduleNowHandler,
|
||||
getTodayPlanHandler,
|
||||
getHolidays,
|
||||
addHoliday,
|
||||
@@ -46,6 +45,8 @@ import {
|
||||
getApiKeys,
|
||||
saveApiKey,
|
||||
deleteApiKey,
|
||||
getCompanyUsers,
|
||||
sendNow,
|
||||
} from "../controllers/smartFactoryLogController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { requireSuperAdmin } from "../middleware/permissionMiddleware";
|
||||
@@ -108,13 +109,17 @@ router.get("/smart-factory-log/schedules", requireSuperAdmin, getSchedules);
|
||||
router.get("/smart-factory-log/schedules/today-plan", requireSuperAdmin, getTodayPlanHandler);
|
||||
router.post("/smart-factory-log/schedules", requireSuperAdmin, upsertSchedule);
|
||||
router.delete("/smart-factory-log/schedules/:companyCode", requireSuperAdmin, deleteSchedule);
|
||||
router.post("/smart-factory-log/schedules/:companyCode/run-now", requireSuperAdmin, runScheduleNowHandler);
|
||||
|
||||
|
||||
// 스마트공장 공휴일 관리 (최고관리자 전용)
|
||||
router.get("/smart-factory-log/holidays", requireSuperAdmin, getHolidays);
|
||||
router.post("/smart-factory-log/holidays", requireSuperAdmin, addHoliday);
|
||||
router.delete("/smart-factory-log/holidays/:id", requireSuperAdmin, deleteHoliday);
|
||||
|
||||
// 스마트공장 즉시 전송 (최고관리자 전용)
|
||||
router.get("/smart-factory-log/users/:companyCode", requireSuperAdmin, getCompanyUsers);
|
||||
router.post("/smart-factory-log/send-now", requireSuperAdmin, sendNow);
|
||||
|
||||
// 스마트공장 API 키 관리 (최고관리자 전용)
|
||||
router.get("/smart-factory-log/api-keys", requireSuperAdmin, getApiKeys);
|
||||
router.post("/smart-factory-log/api-keys", requireSuperAdmin, saveApiKey);
|
||||
|
||||
@@ -16,7 +16,8 @@ interface ScheduledEntry {
|
||||
userId: string;
|
||||
userName: string;
|
||||
companyCode: string;
|
||||
scheduledTime: Date; // 초 단위까지 배정된 시각
|
||||
scheduledTime: Date;
|
||||
useType: "접속" | "종료";
|
||||
sent: boolean;
|
||||
}
|
||||
|
||||
@@ -74,17 +75,19 @@ export async function sendSmartFactoryLog(params: {
|
||||
dataUsgqty: "",
|
||||
};
|
||||
|
||||
const encodedLogData = encodeURIComponent(JSON.stringify(logData));
|
||||
const logDataJson = JSON.stringify(logData);
|
||||
|
||||
const response = await axios.get(SMART_FACTORY_LOG_URL, {
|
||||
params: { logData: encodedLogData },
|
||||
params: { logData: logDataJson },
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
logger.info("스마트공장 로그 전송 완료", {
|
||||
userId: params.userId,
|
||||
status: response.status,
|
||||
});
|
||||
const responseBody = typeof response.data === "string" ? response.data : JSON.stringify(response.data);
|
||||
|
||||
logger.info(`스마트공장 로그 전송 완료: userId=${params.userId}, status=${response.status}, body=${responseBody}`);
|
||||
|
||||
// 응답 body에 에러가 있을 수 있음 (HTTP 200이지만 실제 실패)
|
||||
const isRealSuccess = !responseBody.includes("FAIL") && !responseBody.includes("error") && !responseBody.includes("ERR");
|
||||
|
||||
await saveLog({
|
||||
companyCode: params.companyCode || "",
|
||||
@@ -92,9 +95,9 @@ export async function sendSmartFactoryLog(params: {
|
||||
userName: params.userName,
|
||||
useType,
|
||||
connectIp: params.remoteAddr,
|
||||
sendStatus: "SUCCESS",
|
||||
sendStatus: isRealSuccess ? "SUCCESS" : "FAIL",
|
||||
responseStatus: response.status,
|
||||
errorMessage: null,
|
||||
errorMessage: isRealSuccess ? null : responseBody,
|
||||
logDt: logTimeToUse,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -142,9 +145,8 @@ export async function initSmartFactoryScheduler(): Promise<void> {
|
||||
}
|
||||
}, { timezone: "Asia/Seoul" });
|
||||
|
||||
// 서버 시작 시 오늘 계획이 아직 없으면 바로 생성
|
||||
await planDailySends();
|
||||
|
||||
// 서버 시작 시에는 계획 생성하지 않음 (00:05 cron에서만 생성)
|
||||
// 서버 재시작 시 이미 지난 시각의 로그가 한꺼번에 전송되는 것 방지
|
||||
logger.info("스마트공장 로그 스케줄러 초기화 완료 (매일 00:05 계획 생성, 매분 전송 실행)");
|
||||
}
|
||||
|
||||
@@ -163,8 +165,9 @@ export async function planDailySends(): Promise<void> {
|
||||
time_end: string;
|
||||
exclude_weekend: boolean;
|
||||
exclude_holidays: boolean;
|
||||
daily_count: number;
|
||||
}>(
|
||||
"SELECT company_code, time_start, time_end, exclude_weekend, exclude_holidays FROM smart_factory_schedule WHERE is_active = true"
|
||||
"SELECT company_code, time_start, time_end, exclude_weekend, exclude_holidays, daily_count FROM smart_factory_schedule WHERE is_active = true"
|
||||
);
|
||||
|
||||
if (schedules.length === 0) return;
|
||||
@@ -173,7 +176,7 @@ export async function planDailySends(): Promise<void> {
|
||||
await refreshHolidayCache();
|
||||
|
||||
for (const schedule of schedules) {
|
||||
const { company_code, time_start, time_end, exclude_weekend, exclude_holidays } = schedule;
|
||||
const { company_code, time_start, time_end, exclude_weekend, exclude_holidays, daily_count } = schedule;
|
||||
|
||||
// 주말 체크
|
||||
if (exclude_weekend && (dayOfWeek === 0 || dayOfWeek === 6)) {
|
||||
@@ -225,11 +228,12 @@ export async function planDailySends(): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 랜덤 시각 배정 (초 단위)
|
||||
const entries = assignRandomTimes(attendees, today, time_start, time_end, company_code);
|
||||
// 접속/종료 쌍 + 다회 시각 배정
|
||||
const entries = assignSessionPairs(attendees, today, time_start, time_end, company_code, daily_count);
|
||||
dailyPlan.set(company_code, entries);
|
||||
|
||||
logger.info(`스마트공장 스케줄 ${company_code}: ${entries.length}/${pendingUsers.length}명 계획 생성 (${time_start}~${time_end})`);
|
||||
const sessionCount = entries.filter((e) => e.useType === "접속").length;
|
||||
logger.info(`스마트공장 스케줄 ${company_code}: ${attendees.length}명 × 최대${daily_count}회 = ${sessionCount}세션 계획 (${time_start}~${time_end})`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,10 +249,7 @@ async function executeScheduledSends(): Promise<void> {
|
||||
if (entry.sent) continue;
|
||||
|
||||
const entryMinute = entry.scheduledTime.getHours() * 60 + entry.scheduledTime.getMinutes();
|
||||
if (entryMinute > currentMinute) continue; // 아직 안 됨
|
||||
if (entryMinute < currentMinute) {
|
||||
// 이미 지난 분인데 못 보낸 것 — 보냄
|
||||
}
|
||||
if (entryMinute !== currentMinute) continue; // 정확히 해당 분에만 전송
|
||||
|
||||
// 전송
|
||||
entry.sent = true;
|
||||
@@ -261,7 +262,7 @@ async function executeScheduledSends(): Promise<void> {
|
||||
userId: entry.userId,
|
||||
userName: entry.userName,
|
||||
remoteAddr: randomIp,
|
||||
useType: "접속",
|
||||
useType: entry.useType,
|
||||
companyCode: entry.companyCode,
|
||||
logTime: entry.scheduledTime,
|
||||
});
|
||||
@@ -275,71 +276,6 @@ async function executeScheduledSends(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 즉시 실행 (관리자 테스트용)
|
||||
*/
|
||||
export async function runScheduleNow(companyCode: string): Promise<{ total: number; sent: number; skipped: number }> {
|
||||
const schedule = await query<{
|
||||
time_start: string;
|
||||
time_end: string;
|
||||
}>(
|
||||
"SELECT time_start, time_end FROM smart_factory_schedule WHERE company_code = $1 AND is_active = true",
|
||||
[companyCode]
|
||||
);
|
||||
|
||||
if (schedule.length === 0) {
|
||||
throw new Error("활성 스케줄이 없습니다.");
|
||||
}
|
||||
|
||||
// API 키 확인
|
||||
const apiKey = await getApiKey(companyCode);
|
||||
if (!apiKey) {
|
||||
throw new Error("API 키가 설정되지 않았습니다. API 키 관리에서 먼저 등록해주세요.");
|
||||
}
|
||||
|
||||
const { time_start, time_end } = schedule[0];
|
||||
const today = new Date();
|
||||
|
||||
// 사용자 조회
|
||||
const users = await query<{ user_id: string; user_name: string }>(
|
||||
"SELECT user_id, user_name FROM user_info WHERE company_code = $1 AND (status = 'active' OR status IS NULL)",
|
||||
[companyCode]
|
||||
);
|
||||
|
||||
// 오늘 이미 전송된 사용자 제외
|
||||
const todayStr = formatDate(today);
|
||||
const alreadySent = await query<{ user_id: string }>(
|
||||
"SELECT DISTINCT user_id FROM smart_factory_log WHERE company_code = $1 AND send_status = 'SUCCESS' AND created_at >= $2::date AND created_at < ($2::date + 1)",
|
||||
[companyCode, todayStr]
|
||||
);
|
||||
const alreadySentSet = new Set(alreadySent.map((r) => r.user_id));
|
||||
const pendingUsers = users.filter((u) => !alreadySentSet.has(u.user_id));
|
||||
|
||||
let sent = 0;
|
||||
for (const user of pendingUsers) {
|
||||
// 시간 범위 내 랜덤 시각 생성
|
||||
const randomTime = generateRandomTime(today, time_start, time_end);
|
||||
const randomIp = `192.168.0.${Math.floor(Math.random() * 254) + 1}`;
|
||||
|
||||
try {
|
||||
await sendSmartFactoryLog({
|
||||
userId: user.user_id,
|
||||
userName: user.user_name,
|
||||
remoteAddr: randomIp,
|
||||
useType: "접속",
|
||||
companyCode,
|
||||
logTime: randomTime,
|
||||
});
|
||||
sent++;
|
||||
} catch (e) {
|
||||
logger.error(`스마트공장 즉시 전송 실패: ${user.user_id}`, e);
|
||||
}
|
||||
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
return { total: users.length, sent, skipped: alreadySentSet.size };
|
||||
}
|
||||
|
||||
/**
|
||||
* 오늘 실행 계획 현황 반환
|
||||
@@ -365,13 +301,17 @@ export function getTodayPlanStatus(): Array<{
|
||||
|
||||
// ─── 내부 함수 ───
|
||||
|
||||
/** 시간 범위 내에서 사용자들에게 랜덤 시각(초 단위) 배정 */
|
||||
function assignRandomTimes(
|
||||
/**
|
||||
* 사용자별 접속/종료 쌍을 생성
|
||||
* dailyCount: 최대 접속 횟수 (사용자별 1~dailyCount 랜덤)
|
||||
*/
|
||||
function assignSessionPairs(
|
||||
users: Array<{ user_id: string; user_name: string }>,
|
||||
today: Date,
|
||||
timeStart: string,
|
||||
timeEnd: string,
|
||||
companyCode: string
|
||||
companyCode: string,
|
||||
dailyCount: number
|
||||
): ScheduledEntry[] {
|
||||
const [startH, startM] = timeStart.split(":").map(Number);
|
||||
const [endH, endM] = timeEnd.split(":").map(Number);
|
||||
@@ -381,49 +321,68 @@ function assignRandomTimes(
|
||||
|
||||
if (totalSec <= 0) return [];
|
||||
|
||||
const slotSize = totalSec / users.length;
|
||||
const allEntries: ScheduledEntry[] = [];
|
||||
const maxCount = Math.max(1, Math.min(3, dailyCount));
|
||||
|
||||
const entries: ScheduledEntry[] = users.map((user, idx) => {
|
||||
// 각 슬롯 내에서 랜덤 오프셋 (초 단위)
|
||||
const slotStart = startSec + Math.floor(slotSize * idx);
|
||||
const randomOffset = Math.floor(Math.random() * slotSize);
|
||||
const assignedSec = Math.min(slotStart + randomOffset, endSec - 1);
|
||||
for (const user of users) {
|
||||
// 사용자별 1 ~ maxCount 사이 랜덤 횟수
|
||||
const count = Math.floor(Math.random() * maxCount) + 1;
|
||||
// 시간대를 횟수로 균등 분할
|
||||
const slotSec = Math.floor(totalSec / count);
|
||||
|
||||
const h = Math.floor(assignedSec / 3600);
|
||||
const m = Math.floor((assignedSec % 3600) / 60);
|
||||
const s = assignedSec % 60;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const slotStart = startSec + slotSec * i;
|
||||
const slotEnd = i < count - 1 ? slotStart + slotSec : endSec;
|
||||
|
||||
const scheduledTime = new Date(today);
|
||||
scheduledTime.setHours(h, m, s, Math.floor(Math.random() * 1000));
|
||||
// 접속 시각: 슬롯 전반부에서 랜덤
|
||||
const loginWindow = Math.floor((slotEnd - slotStart) * 0.4); // 슬롯의 앞 40%
|
||||
const loginSec = slotStart + Math.floor(Math.random() * Math.max(loginWindow, 60));
|
||||
const clampedLoginSec = Math.min(loginSec, endSec - 120); // 최소 2분 여유
|
||||
|
||||
return {
|
||||
userId: user.user_id,
|
||||
userName: user.user_name,
|
||||
companyCode,
|
||||
scheduledTime,
|
||||
sent: false,
|
||||
};
|
||||
});
|
||||
// 종료 시각: 접속 후 30분~2시간 사이 랜덤
|
||||
const minSession = 30 * 60; // 30분
|
||||
const maxSession = 120 * 60; // 2시간
|
||||
const sessionLen = minSession + Math.floor(Math.random() * (maxSession - minSession));
|
||||
const logoutSec = Math.min(clampedLoginSec + sessionLen, endSec - 1);
|
||||
|
||||
// 접속과 종료 시각이 너무 가까우면(2분 미만) 스킵
|
||||
if (logoutSec - clampedLoginSec < 120) continue;
|
||||
|
||||
const loginTime = secToDate(today, clampedLoginSec);
|
||||
const logoutTime = secToDate(today, logoutSec);
|
||||
|
||||
allEntries.push({
|
||||
userId: user.user_id,
|
||||
userName: user.user_name,
|
||||
companyCode,
|
||||
scheduledTime: loginTime,
|
||||
useType: "접속",
|
||||
sent: false,
|
||||
});
|
||||
|
||||
allEntries.push({
|
||||
userId: user.user_id,
|
||||
userName: user.user_name,
|
||||
companyCode,
|
||||
scheduledTime: logoutTime,
|
||||
useType: "종료",
|
||||
sent: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 시각순 정렬
|
||||
return entries.sort((a, b) => a.scheduledTime.getTime() - b.scheduledTime.getTime());
|
||||
return allEntries.sort((a, b) => a.scheduledTime.getTime() - b.scheduledTime.getTime());
|
||||
}
|
||||
|
||||
/** 단일 랜덤 시각 생성 (즉시 실행용) */
|
||||
function generateRandomTime(today: Date, timeStart: string, timeEnd: string): Date {
|
||||
const [startH, startM] = timeStart.split(":").map(Number);
|
||||
const [endH, endM] = timeEnd.split(":").map(Number);
|
||||
const startSec = startH * 3600 + startM * 60;
|
||||
const endSec = endH * 3600 + endM * 60;
|
||||
const randomSec = startSec + Math.floor(Math.random() * (endSec - startSec));
|
||||
|
||||
const h = Math.floor(randomSec / 3600);
|
||||
const m = Math.floor((randomSec % 3600) / 60);
|
||||
const s = randomSec % 60;
|
||||
|
||||
const time = new Date(today);
|
||||
time.setHours(h, m, s, Math.floor(Math.random() * 1000));
|
||||
return time;
|
||||
/** 초(하루 내)를 Date로 변환 */
|
||||
function secToDate(today: Date, sec: number): Date {
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
const s = sec % 60;
|
||||
const d = new Date(today);
|
||||
d.setHours(h, m, s, Math.floor(Math.random() * 1000));
|
||||
return d;
|
||||
}
|
||||
|
||||
/** 공휴일 캐시 갱신 */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@
|
||||
|
||||
import React, { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
Circle,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
@@ -39,7 +38,6 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
@@ -78,6 +76,7 @@ import {
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import {
|
||||
getMyWork,
|
||||
@@ -171,14 +170,14 @@ interface MyTask extends Task {
|
||||
|
||||
// ========== 유틸 ==========
|
||||
const USER_INFO: Record<string, { role: string; color: string }> = {
|
||||
최기구: { role: "기구설계 파트", color: "bg-blue-500" },
|
||||
이설계: { role: "기구설계 파트", color: "bg-emerald-500" },
|
||||
김전장: { role: "전장설계 파트", color: "bg-amber-500" },
|
||||
박도면: { role: "기구설계 파트", color: "bg-violet-500" },
|
||||
정SW: { role: "SW개발 파트", color: "bg-pink-500" },
|
||||
한조립: { role: "조립/시운전 파트", color: "bg-red-500" },
|
||||
박구매: { role: "구매/조달 파트", color: "bg-teal-500" },
|
||||
팀장: { role: "설계팀 팀장", color: "bg-indigo-500" },
|
||||
최기구: { role: "기구설계 파트", color: "bg-primary" },
|
||||
이설계: { role: "기구설계 파트", color: "bg-chart-2" },
|
||||
김전장: { role: "전장설계 파트", color: "bg-warning" },
|
||||
박도면: { role: "기구설계 파트", color: "bg-chart-3" },
|
||||
정SW: { role: "SW개발 파트", color: "bg-destructive" },
|
||||
한조립: { role: "조립/시운전 파트", color: "bg-chart-1" },
|
||||
박구매: { role: "구매/조달 파트", color: "bg-info" },
|
||||
팀장: { role: "설계팀 팀장", color: "bg-success" },
|
||||
};
|
||||
|
||||
const USERS = Object.keys(USER_INFO);
|
||||
@@ -240,47 +239,46 @@ function getFileType(name: string): string {
|
||||
}
|
||||
|
||||
function getProgressColor(p: number) {
|
||||
if (p >= 80) return "text-emerald-500";
|
||||
if (p >= 40) return "text-blue-500";
|
||||
if (p > 0) return "text-amber-500";
|
||||
if (p >= 80) return "text-success";
|
||||
if (p >= 40) return "text-primary";
|
||||
if (p > 0) return "text-warning";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
function getProgressBg(p: number) {
|
||||
if (p >= 80) return "bg-emerald-500";
|
||||
if (p >= 40) return "bg-blue-500";
|
||||
if (p > 0) return "bg-amber-500";
|
||||
if (p >= 80) return "bg-success";
|
||||
if (p >= 40) return "bg-primary";
|
||||
if (p > 0) return "bg-warning";
|
||||
return "bg-muted";
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
대기: "bg-muted text-foreground",
|
||||
진행중: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
|
||||
검토중:
|
||||
"bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
|
||||
완료: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
|
||||
지연: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
|
||||
진행중: "bg-primary/10 text-primary",
|
||||
검토중: "bg-warning/10 text-warning",
|
||||
완료: "bg-success/10 text-success",
|
||||
지연: "bg-destructive/10 text-destructive",
|
||||
};
|
||||
|
||||
const PRIORITY_STYLES: Record<string, string> = {
|
||||
긴급: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
|
||||
높음: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
|
||||
긴급: "bg-destructive/10 text-destructive",
|
||||
높음: "bg-warning/10 text-warning",
|
||||
보통: "bg-muted text-foreground",
|
||||
낮음: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
|
||||
낮음: "bg-success/10 text-success",
|
||||
};
|
||||
|
||||
const PR_STATUS_STYLES: Record<string, string> = {
|
||||
요청: "bg-red-100 text-red-800",
|
||||
견적중: "bg-amber-100 text-amber-800",
|
||||
발주완료: "bg-blue-100 text-blue-800",
|
||||
입고완료: "bg-emerald-100 text-emerald-800",
|
||||
요청: "bg-destructive/10 text-destructive",
|
||||
견적중: "bg-warning/10 text-warning",
|
||||
발주완료: "bg-primary/10 text-primary",
|
||||
입고완료: "bg-success/10 text-success",
|
||||
};
|
||||
|
||||
const CR_STATUS_STYLES: Record<string, string> = {
|
||||
요청: "bg-amber-100 text-amber-800",
|
||||
접수: "bg-amber-100 text-amber-800",
|
||||
진행중: "bg-blue-100 text-blue-800",
|
||||
완료: "bg-emerald-100 text-emerald-800",
|
||||
요청: "bg-warning/10 text-warning",
|
||||
접수: "bg-warning/10 text-warning",
|
||||
진행중: "bg-primary/10 text-primary",
|
||||
완료: "bg-success/10 text-success",
|
||||
};
|
||||
|
||||
const CR_NEXT: Record<string, string> = {
|
||||
@@ -445,7 +443,7 @@ export default function MyWorkPage() {
|
||||
setProjects([]);
|
||||
}
|
||||
} catch {
|
||||
toast.error("업무 목록을 불러오는데 실패했습니다.");
|
||||
toast.error("업무 목록을 불러오는데 실패했어요.");
|
||||
setProjects([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -585,12 +583,12 @@ export default function MyWorkPage() {
|
||||
if (!taskId) return;
|
||||
const res = await createSubItem(taskId, { name: newSiName.trim(), weight: w, progress: 0, status: "대기" });
|
||||
if (res.success) {
|
||||
toast.success("수행항목이 추가되었습니다.");
|
||||
toast.success("수행항목이 추가되었어요.");
|
||||
setNewSiName("");
|
||||
setNewSiWeight("");
|
||||
fetchMyWork();
|
||||
} else {
|
||||
toast.error(res.message || "수행항목 추가에 실패했습니다.");
|
||||
toast.error(res.message || "수행항목 추가에 실패했어요.");
|
||||
}
|
||||
}, [selectedTask, newSiName, newSiWeight, fetchMyWork]);
|
||||
|
||||
@@ -603,10 +601,10 @@ export default function MyWorkPage() {
|
||||
if (siId) {
|
||||
const res = await deleteSubItem(String(siId));
|
||||
if (res.success) {
|
||||
toast.success("수행항목이 삭제되었습니다.");
|
||||
toast.success("수행항목이 삭제되었어요.");
|
||||
fetchMyWork();
|
||||
} else {
|
||||
toast.error(res.message || "수행항목 삭제에 실패했습니다.");
|
||||
toast.error(res.message || "수행항목 삭제에 실패했어요.");
|
||||
}
|
||||
} else {
|
||||
updateProjects((draft) => {
|
||||
@@ -636,10 +634,10 @@ export default function MyWorkPage() {
|
||||
if (siId) {
|
||||
const res = await updateSubItem(String(siId), { progress: newProgress, status: newStatus });
|
||||
if (res.success) {
|
||||
toast.success("수행항목이 업데이트되었습니다.");
|
||||
toast.success("수행항목이 업데이트되었어요.");
|
||||
fetchMyWork();
|
||||
} else {
|
||||
toast.error(res.message || "수행항목 업데이트에 실패했습니다.");
|
||||
toast.error(res.message || "수행항목 업데이트에 실패했어요.");
|
||||
}
|
||||
} else {
|
||||
updateProjects((draft) => {
|
||||
@@ -673,7 +671,7 @@ export default function MyWorkPage() {
|
||||
if (res.success) {
|
||||
fetchMyWork();
|
||||
} else {
|
||||
toast.error(res.message || "진행률 업데이트에 실패했습니다.");
|
||||
toast.error(res.message || "진행률 업데이트에 실패했어요.");
|
||||
}
|
||||
} else {
|
||||
updateProjects((draft) => {
|
||||
@@ -740,11 +738,11 @@ export default function MyWorkPage() {
|
||||
sub_item_id: siId ?? null,
|
||||
});
|
||||
if (res.success) {
|
||||
toast.success("작업일지가 등록되었습니다.");
|
||||
toast.success("작업일지가 등록되었어요.");
|
||||
setEditingLogIdx(-1);
|
||||
fetchMyWork();
|
||||
} else {
|
||||
toast.error(res.message || "작업일지 등록에 실패했습니다.");
|
||||
toast.error(res.message || "작업일지 등록에 실패했어요.");
|
||||
}
|
||||
} else {
|
||||
updateProjects((draft) => {
|
||||
@@ -772,11 +770,11 @@ export default function MyWorkPage() {
|
||||
if (workLogId) {
|
||||
const res = await deleteWorkLog(String(workLogId));
|
||||
if (res.success) {
|
||||
toast.success("작업일지가 삭제되었습니다.");
|
||||
toast.success("작업일지가 삭제되었어요.");
|
||||
setEditingLogIdx(-1);
|
||||
fetchMyWork();
|
||||
} else {
|
||||
toast.error(res.message || "작업일지 삭제에 실패했습니다.");
|
||||
toast.error(res.message || "작업일지 삭제에 실패했어요.");
|
||||
}
|
||||
} else {
|
||||
updateProjects((draft) => {
|
||||
@@ -845,11 +843,11 @@ export default function MyWorkPage() {
|
||||
status: pr.status,
|
||||
});
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "구매요청 등록에 실패했습니다.");
|
||||
toast.error(res.message || "구매요청 등록에 실패했어요.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (newItems.length > 0) toast.success("구매요청이 등록되었습니다.");
|
||||
if (newItems.length > 0) toast.success("구매요청이 등록되었어요.");
|
||||
}
|
||||
updateProjects((draft) => {
|
||||
for (const p of draft) {
|
||||
@@ -890,11 +888,11 @@ export default function MyWorkPage() {
|
||||
due_date: cr.dueDate,
|
||||
});
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "협조요청 등록에 실패했습니다.");
|
||||
toast.error(res.message || "협조요청 등록에 실패했어요.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (newItems.length > 0) toast.success("협조요청이 등록되었습니다.");
|
||||
if (newItems.length > 0) toast.success("협조요청이 등록되었어요.");
|
||||
}
|
||||
updateProjects((draft) => {
|
||||
for (const p of draft) {
|
||||
@@ -919,9 +917,9 @@ export default function MyWorkPage() {
|
||||
|
||||
const kanbanConfig = [
|
||||
{ key: "대기", icon: <Circle className="h-3.5 w-3.5" />, color: "border-muted-foreground/30", titleColor: "text-muted-foreground" },
|
||||
{ key: "진행중", icon: <Circle className="h-3.5 w-3.5 text-blue-500" />, color: "border-blue-500", titleColor: "text-blue-500" },
|
||||
{ key: "검토중", icon: <Circle className="h-3.5 w-3.5 text-amber-500" />, color: "border-amber-500", titleColor: "text-amber-500" },
|
||||
{ key: "완료", icon: <CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />, color: "border-emerald-500", titleColor: "text-emerald-500" },
|
||||
{ key: "진행중", icon: <Circle className="h-3.5 w-3.5 text-primary" />, color: "border-primary", titleColor: "text-primary" },
|
||||
{ key: "검토중", icon: <Circle className="h-3.5 w-3.5 text-warning" />, color: "border-warning", titleColor: "text-warning" },
|
||||
{ key: "완료", icon: <CheckCircle2 className="h-3.5 w-3.5 text-success" />, color: "border-success", titleColor: "text-success" },
|
||||
];
|
||||
|
||||
// ===== 타임시트 =====
|
||||
@@ -969,14 +967,18 @@ export default function MyWorkPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2 p-3">
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col gap-3 p-3">
|
||||
{/* 헤더 */}
|
||||
<div className="flex shrink-0 items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-bold">
|
||||
<BarChart3 className="mr-1.5 inline h-5 w-5" />
|
||||
내 업무 현황
|
||||
</h1>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<span>설계관리</span>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-foreground">내업무현황</span>
|
||||
</div>
|
||||
<h1 className="text-lg font-bold leading-tight">내 업무 현황</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-card px-2.5 py-1.5">
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarFallback className={cn("text-xs font-bold text-white", USER_INFO[currentUser]?.color || "bg-primary")}>
|
||||
@@ -1006,68 +1008,135 @@ export default function MyWorkPage() {
|
||||
</div>
|
||||
|
||||
{/* 검색 필터 */}
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 rounded-lg border bg-card px-2.5 py-1.5">
|
||||
<Select value={filterProject} onValueChange={setFilterProject}>
|
||||
<SelectTrigger className="h-7 w-[130px] text-xs" size="xs"><SelectValue placeholder="전체 프로젝트" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체 프로젝트</SelectItem>
|
||||
{projectList.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>{p.id} {p.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
||||
<SelectTrigger className="h-7 w-[100px] text-xs" size="xs"><SelectValue placeholder="전체 상태" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체 상태</SelectItem>
|
||||
{["대기", "진행중", "검토중", "완료"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filterPriority} onValueChange={setFilterPriority}>
|
||||
<SelectTrigger className="h-7 w-[110px] text-xs" size="xs"><SelectValue placeholder="전체 우선순위" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체 우선순위</SelectItem>
|
||||
{["긴급", "높음", "보통", "낮음"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filterCategory} onValueChange={setFilterCategory}>
|
||||
<SelectTrigger className="h-7 w-[110px] text-xs" size="xs"><SelectValue placeholder="전체 유형" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체 유형</SelectItem>
|
||||
{["기구설계", "전장설계", "SW개발", "구매/조달", "조립/시운전", "검토/승인"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={filterKeyword}
|
||||
onChange={(e) => setFilterKeyword(e.target.value)}
|
||||
placeholder="업무명 검색..."
|
||||
className="h-7 w-[120px] pl-7 text-xs"
|
||||
/>
|
||||
<div className="shrink-0 rounded-lg border border-border bg-card px-5 py-4">
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] items-end gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">프로젝트</span>
|
||||
<Select value={filterProject} onValueChange={setFilterProject}>
|
||||
<SelectTrigger className="h-9 w-full text-xs">
|
||||
<SelectValue placeholder="전체 프로젝트" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체 프로젝트</SelectItem>
|
||||
{projectList.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>{p.id} {p.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">진행상태</span>
|
||||
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
||||
<SelectTrigger className="h-9 w-full text-xs">
|
||||
<SelectValue placeholder="전체 상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체 상태</SelectItem>
|
||||
{["대기", "진행중", "검토중", "완료"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">우선순위</span>
|
||||
<Select value={filterPriority} onValueChange={setFilterPriority}>
|
||||
<SelectTrigger className="h-9 w-full text-xs">
|
||||
<SelectValue placeholder="전체 우선순위" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체 우선순위</SelectItem>
|
||||
{["긴급", "높음", "보통", "낮음"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">업무유형</span>
|
||||
<Select value={filterCategory} onValueChange={setFilterCategory}>
|
||||
<SelectTrigger className="h-9 w-full text-xs">
|
||||
<SelectValue placeholder="전체 유형" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체 유형</SelectItem>
|
||||
{["기구설계", "전장설계", "SW개발", "구매/조달", "조립/시운전", "검토/승인"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">검색어</span>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={filterKeyword}
|
||||
onChange={(e) => setFilterKeyword(e.target.value)}
|
||||
placeholder="업무명 검색..."
|
||||
className="h-9 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end justify-end gap-2">
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={handleResetFilter}>
|
||||
<RotateCcw className="mr-1.5 h-4 w-4" />
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleResetFilter}>
|
||||
<RotateCcw className="mr-1 h-3 w-3" />초기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid shrink-0 grid-cols-5 gap-2">
|
||||
{[
|
||||
{ icon: <ClipboardList className="h-4 w-4 text-blue-500" />, value: stats.total, label: "전체 업무", bg: "bg-blue-50 dark:bg-blue-900/20" },
|
||||
{ icon: <Circle className="h-4 w-4 text-amber-500" />, value: stats.progress, label: "진행중", bg: "bg-amber-50 dark:bg-amber-900/20" },
|
||||
{ icon: <CheckCircle2 className="h-4 w-4 text-emerald-500" />, value: stats.done, label: "완료", bg: "bg-emerald-50 dark:bg-emerald-900/20" },
|
||||
{ icon: <AlertCircle className="h-4 w-4 text-red-500" />, value: stats.delay, label: "지연", bg: "bg-red-50 dark:bg-red-900/20" },
|
||||
{ icon: <Clock className="h-4 w-4 text-violet-500" />, value: `${stats.weekHours}h`, label: "이번주 투입", bg: "bg-violet-50 dark:bg-violet-900/20" },
|
||||
].map((s, i) => (
|
||||
<Card key={i} className="flex flex-row items-center gap-2 rounded-lg border px-3 py-2 shadow-none">
|
||||
<div className={cn("flex h-8 w-8 items-center justify-center rounded-lg", s.bg)}>{s.icon}</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold">{s.value}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{s.label}</div>
|
||||
{/* 통계 요약 바 */}
|
||||
<div className="shrink-0 rounded-lg border border-border bg-card px-5 py-3">
|
||||
<div className="flex flex-wrap items-center gap-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-primary/10">
|
||||
<ClipboardList className="h-3.5 w-3.5 text-primary" />
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
<div className="leading-none">
|
||||
<span className="text-sm font-bold">{stats.total}</span>
|
||||
<span className="ml-1.5 text-[11px] text-muted-foreground">전체</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-border" />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-warning/10">
|
||||
<Circle className="h-3.5 w-3.5 text-warning" />
|
||||
</div>
|
||||
<div className="leading-none">
|
||||
<span className="text-sm font-bold">{stats.progress}</span>
|
||||
<span className="ml-1.5 text-[11px] text-muted-foreground">진행중</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-success/10">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-success" />
|
||||
</div>
|
||||
<div className="leading-none">
|
||||
<span className="text-sm font-bold">{stats.done}</span>
|
||||
<span className="ml-1.5 text-[11px] text-muted-foreground">완료</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn("flex h-7 w-7 items-center justify-center rounded-md", stats.delay > 0 ? "bg-destructive/10" : "bg-muted")}>
|
||||
<AlertCircle className={cn("h-3.5 w-3.5", stats.delay > 0 ? "text-destructive" : "text-muted-foreground")} />
|
||||
</div>
|
||||
<div className="leading-none">
|
||||
<span className={cn("text-sm font-bold", stats.delay > 0 && "text-destructive")}>{stats.delay}</span>
|
||||
<span className="ml-1.5 text-[11px] text-muted-foreground">지연</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-border" />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-info/10">
|
||||
<Clock className="h-3.5 w-3.5 text-info" />
|
||||
</div>
|
||||
<div className="leading-none">
|
||||
<span className="text-sm font-bold">{stats.weekHours}h</span>
|
||||
<span className="ml-1.5 text-[11px] text-muted-foreground">이번주 투입</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className="rounded-full border border-primary/15 bg-primary/8 px-2.5 py-0.5 font-mono text-[11px] font-bold text-primary">
|
||||
{filteredTasks.length}건
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 영역 */}
|
||||
@@ -1075,11 +1144,14 @@ export default function MyWorkPage() {
|
||||
{/* 왼쪽: 업무 현황 */}
|
||||
<ResizablePanel defaultSize={45} minSize={25}>
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-card">
|
||||
<div className="flex shrink-0 items-center justify-between border-b bg-muted/30 px-3 py-1.5">
|
||||
<span className="text-sm font-bold">
|
||||
<ClipboardList className="mr-1 inline h-4 w-4" />
|
||||
업무 현황
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center justify-between border-b bg-muted/50 px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">업무 현황</span>
|
||||
<span className="rounded-full border border-primary/15 bg-primary/8 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
|
||||
{filteredTasks.length}건
|
||||
</span>
|
||||
</div>
|
||||
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as any)}>
|
||||
<TabsList className="h-7">
|
||||
<TabsTrigger value="kanban" className="h-5 gap-1 px-2 text-[11px]">
|
||||
@@ -1102,22 +1174,22 @@ export default function MyWorkPage() {
|
||||
const tasks = kanbanCols[col.key] || [];
|
||||
const extraCount = col.key === "대기" ? receivedCoops.length : 0;
|
||||
return (
|
||||
<div key={col.key} className="flex flex-col rounded-lg bg-muted/50 p-2">
|
||||
<div className={cn("mb-2 flex items-center justify-between border-b-2 pb-1.5", col.color)}>
|
||||
<span className={cn("flex items-center gap-1 text-xs font-bold", col.titleColor)}>
|
||||
<div key={col.key} className="flex flex-col rounded-lg border bg-muted/30 p-2">
|
||||
<div className={cn("mb-2 flex items-center justify-between rounded-md border-l-[3px] bg-card px-2 py-1.5", col.color)}>
|
||||
<span className={cn("flex items-center gap-1.5 text-xs font-bold", col.titleColor)}>
|
||||
{col.icon}{col.key}
|
||||
</span>
|
||||
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">{tasks.length + extraCount}</Badge>
|
||||
<span className="rounded-full border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] font-bold text-muted-foreground">{tasks.length + extraCount}</span>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1.5 overflow-y-auto">
|
||||
{col.key === "대기" && receivedCoops.map((cr, i) => (
|
||||
<div key={`coop-${i}`} className="rounded-md border border-amber-200 bg-amber-50 p-2 dark:border-amber-900/50 dark:bg-amber-900/10">
|
||||
<div className="text-[10px] text-amber-600">
|
||||
<div key={`coop-${i}`} className="rounded-md border border-warning/30 bg-warning/10 p-2">
|
||||
<div className="text-[10px] text-warning">
|
||||
<Handshake className="mr-0.5 inline h-3 w-3" /> 협조 · {cr.fromUser}
|
||||
</div>
|
||||
<div className="text-xs font-semibold">{cr.title}</div>
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground">{cr.projectName}</div>
|
||||
<div className={cn("mt-0.5 text-[10px]", new Date(cr.dueDate) < today ? "font-semibold text-destructive" : "text-emerald-600")}>
|
||||
<div className={cn("mt-0.5 text-[10px]", new Date(cr.dueDate) < today ? "font-semibold text-destructive" : "text-success")}>
|
||||
<Calendar className="mr-0.5 inline h-3 w-3" />{cr.dueDate}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1133,7 +1205,7 @@ export default function MyWorkPage() {
|
||||
<div
|
||||
key={`${t.projectId}-${t.name}`}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-md border bg-card p-2 transition-all hover:shadow-sm",
|
||||
"cursor-pointer rounded-md border bg-card p-2 transition-all",
|
||||
isSelected && "border-primary ring-2 ring-primary/20"
|
||||
)}
|
||||
onClick={() => handleSelectTask(t.projectId, t.name)}
|
||||
@@ -1150,7 +1222,7 @@ export default function MyWorkPage() {
|
||||
</div>
|
||||
<span className={cn("text-[10px] font-semibold", getProgressColor(t.progress))}>{t.progress}%</span>
|
||||
</div>
|
||||
<div className={cn("mt-0.5 text-[10px]", isDelay ? "font-semibold text-destructive" : dd <= 3 ? "text-amber-500" : "text-emerald-600")}>
|
||||
<div className={cn("mt-0.5 text-[10px]", isDelay ? "font-semibold text-destructive" : dd <= 3 ? "text-warning" : "text-success")}>
|
||||
<Calendar className="mr-0.5 inline h-3 w-3" />{dueText}
|
||||
</div>
|
||||
{(coopCount > 0 || attCount > 0) && (
|
||||
@@ -1163,7 +1235,7 @@ export default function MyWorkPage() {
|
||||
);
|
||||
})}
|
||||
{tasks.length === 0 && extraCount === 0 && (
|
||||
<div className="py-3 text-center text-[11px] text-muted-foreground">없음</div>
|
||||
<div className="py-3 text-center text-[11px] text-muted-foreground">업무가 없어요</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1173,66 +1245,50 @@ export default function MyWorkPage() {
|
||||
)}
|
||||
|
||||
{viewMode === "list" && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[90px] text-[11px]">프로젝트</TableHead>
|
||||
<TableHead className="text-[11px]">업무명</TableHead>
|
||||
<TableHead className="w-[65px] text-[11px]">유형</TableHead>
|
||||
<TableHead className="w-[55px] text-center text-[11px]">상태</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px]">종료일</TableHead>
|
||||
<TableHead className="w-[70px] text-[11px]">진행률</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredTasks
|
||||
.sort((a, b) => {
|
||||
const ad = a.status !== "완료" && new Date(a.end) < today;
|
||||
const bd = b.status !== "완료" && new Date(b.end) < today;
|
||||
if (ad && !bd) return -1;
|
||||
if (!ad && bd) return 1;
|
||||
const ord: Record<string, number> = { 진행중: 0, 대기: 1, 검토중: 2, 완료: 3 };
|
||||
return (ord[a.status] ?? 9) - (ord[b.status] ?? 9);
|
||||
})
|
||||
.map((t) => {
|
||||
const isDelay = t.status !== "완료" && new Date(t.end) < today;
|
||||
const displayStatus = isDelay ? "지연" : t.status;
|
||||
const isSelected = selectedTaskKey === `${t.projectId}||${t.name}`;
|
||||
return (
|
||||
<TableRow
|
||||
key={`${t.projectId}-${t.name}`}
|
||||
className={cn("cursor-pointer", isSelected && "bg-accent")}
|
||||
onClick={() => handleSelectTask(t.projectId, t.name)}
|
||||
>
|
||||
<TableCell className="text-[10px]">
|
||||
<span className="font-semibold text-primary">{t.projectId}</span>
|
||||
<br />
|
||||
<span className="text-muted-foreground">{t.projectName}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-medium">{t.name}</TableCell>
|
||||
<TableCell className="text-[11px]">{t.category}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={cn("text-[10px]", STATUS_STYLES[displayStatus])}>{displayStatus}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className={cn("text-[11px]", isDelay && "font-semibold text-destructive")}>{t.end}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-1 w-12 overflow-hidden rounded-full bg-muted">
|
||||
<div className={cn("h-full rounded-full", getProgressBg(t.progress))} style={{ width: `${t.progress}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px]">{t.progress}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{filteredTasks.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-xs text-muted-foreground">검색 결과 없음</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<EDataTable
|
||||
columns={[
|
||||
{ key: "projectId", label: "프로젝트", width: "w-[90px]", render: (_v, row) => (
|
||||
<div className="text-[10px]">
|
||||
<span className="font-semibold text-primary">{row.projectId}</span>
|
||||
<br />
|
||||
<span className="text-muted-foreground">{row.projectName}</span>
|
||||
</div>
|
||||
)},
|
||||
{ key: "name", label: "업무명" },
|
||||
{ key: "category", label: "유형", width: "w-[65px]" },
|
||||
{ key: "status", label: "상태", width: "w-[55px]", align: "center", render: (_v, row) => {
|
||||
const isDelay = row.status !== "완료" && new Date(row.end) < today;
|
||||
const displayStatus = isDelay ? "지연" : row.status;
|
||||
return <Badge className={cn("text-[10px]", STATUS_STYLES[displayStatus])}>{displayStatus}</Badge>;
|
||||
}},
|
||||
{ key: "end", label: "종료일", width: "w-[80px]", render: (v, row) => {
|
||||
const isDelay = row.status !== "완료" && new Date(row.end) < today;
|
||||
return <span className={cn("text-[11px]", isDelay && "font-semibold text-destructive")}>{v}</span>;
|
||||
}},
|
||||
{ key: "progress", label: "진행률", width: "w-[70px]", sortable: true, render: (v) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-1 w-12 overflow-hidden rounded-full bg-muted">
|
||||
<div className={cn("h-full rounded-full", getProgressBg(v))} style={{ width: `${v}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px]">{v}%</span>
|
||||
</div>
|
||||
)},
|
||||
] as EDataTableColumn<MyTask>[]}
|
||||
data={[...filteredTasks].sort((a, b) => {
|
||||
const ad = a.status !== "완료" && new Date(a.end) < today;
|
||||
const bd = b.status !== "완료" && new Date(b.end) < today;
|
||||
if (ad && !bd) return -1;
|
||||
if (!ad && bd) return 1;
|
||||
const ord: Record<string, number> = { 진행중: 0, 대기: 1, 검토중: 2, 완료: 3 };
|
||||
return (ord[a.status] ?? 9) - (ord[b.status] ?? 9);
|
||||
})}
|
||||
rowKey={(row) => `${row.projectId}||${row.name}`}
|
||||
selectedId={selectedTaskKey}
|
||||
onRowClick={(row) => handleSelectTask(row.projectId, row.name)}
|
||||
emptyMessage="검색 결과가 없어요"
|
||||
showPagination={false}
|
||||
draggableColumns={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === "timesheet" && (
|
||||
@@ -1256,16 +1312,16 @@ export default function MyWorkPage() {
|
||||
</span>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[120px] text-[11px]">프로젝트/업무</TableHead>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="min-w-[120px] text-[11px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">프로젝트/업무</TableHead>
|
||||
{timesheetData.wds.map((d, i) => (
|
||||
<TableHead
|
||||
key={i}
|
||||
className={cn(
|
||||
"text-center text-[10px]",
|
||||
i >= 5 && "bg-red-50 dark:bg-red-900/10",
|
||||
d.toDateString() === today.toDateString() && "bg-blue-50 text-primary dark:bg-blue-900/10"
|
||||
i >= 5 && "bg-destructive/5",
|
||||
d.toDateString() === today.toDateString() && "bg-primary/5 text-primary"
|
||||
)}
|
||||
>
|
||||
{timesheetData.dn[i]}
|
||||
@@ -1273,25 +1329,25 @@ export default function MyWorkPage() {
|
||||
<span className="text-[9px]">{d.getMonth() + 1}/{d.getDate()}</span>
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead className="text-center text-[11px] font-bold">합계</TableHead>
|
||||
<TableHead className="text-center text-[11px] font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">합계</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Object.keys(timesheetData.ph).length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="py-8 text-center text-xs text-muted-foreground">기록 없음</TableCell>
|
||||
<TableCell colSpan={9} className="py-8 text-center text-[13px] text-muted-foreground">기록이 없어요</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{Object.values(timesheetData.ph).map((p) => (
|
||||
<React.Fragment key={p.id}>
|
||||
<TableRow className="bg-muted/30">
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableCell className="text-[10px] font-bold text-primary">{p.id} {p.name}</TableCell>
|
||||
{p.days.map((v, i) => (
|
||||
<TableCell key={i} className={cn("text-center text-[11px] font-semibold", i >= 5 && "bg-red-50 dark:bg-red-900/10", v > 0 ? "" : "text-muted-foreground/30")}>
|
||||
<TableCell key={i} className={cn("text-center text-[11px] font-semibold", i >= 5 && "bg-destructive/5", v > 0 ? "" : "text-muted-foreground/30")}>
|
||||
{v > 0 ? `${v}h` : "-"}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell className="text-center text-xs font-bold text-primary">{p.total}h</TableCell>
|
||||
<TableCell className="text-center text-[13px] font-bold text-primary">{p.total}h</TableCell>
|
||||
</TableRow>
|
||||
{Object.entries(p.tasks).map(([tn, days]) => {
|
||||
const tt = days.reduce((s, v) => s + v, 0);
|
||||
@@ -1299,7 +1355,7 @@ export default function MyWorkPage() {
|
||||
<TableRow key={tn}>
|
||||
<TableCell className="pl-4 text-[10px] text-muted-foreground">└ {tn}</TableCell>
|
||||
{days.map((v, i) => (
|
||||
<TableCell key={i} className={cn("text-center text-[10px]", i >= 5 && "bg-red-50 dark:bg-red-900/10", v > 0 ? "" : "text-muted-foreground/30")}>
|
||||
<TableCell key={i} className={cn("text-center text-[10px]", i >= 5 && "bg-destructive/5", v > 0 ? "" : "text-muted-foreground/30")}>
|
||||
{v > 0 ? `${v}h` : "-"}
|
||||
</TableCell>
|
||||
))}
|
||||
@@ -1309,10 +1365,10 @@ export default function MyWorkPage() {
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<TableRow className="bg-blue-50 font-bold dark:bg-blue-900/10">
|
||||
<TableRow className="bg-primary/5 font-bold">
|
||||
<TableCell className="text-[11px]">합계</TableCell>
|
||||
{timesheetData.dt.map((v, i) => (
|
||||
<TableCell key={i} className={cn("text-center text-[11px]", i >= 5 && "bg-red-50 dark:bg-red-900/10", v > 0 ? "text-primary" : "text-muted-foreground/30")}>
|
||||
<TableCell key={i} className={cn("text-center text-[11px]", i >= 5 && "bg-destructive/5", v > 0 ? "text-primary" : "text-muted-foreground/30")}>
|
||||
{v > 0 ? `${v}h` : "-"}
|
||||
</TableCell>
|
||||
))}
|
||||
@@ -1333,15 +1389,20 @@ export default function MyWorkPage() {
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-card">
|
||||
{!selectedTask ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<PointerIcon className="h-8 w-8" />
|
||||
<span className="text-sm">왼쪽에서 업무를 선택하세요</span>
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full border-2 border-dashed border-border">
|
||||
<PointerIcon className="h-6 w-6 text-muted-foreground/50" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">업무를 선택해주세요</p>
|
||||
<p className="text-xs text-muted-foreground/60">왼쪽 목록에서 업무를 클릭하면 상세 정보가 표시돼요</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 상세 헤더 */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b bg-muted/30 px-3 py-2">
|
||||
<span className="text-sm font-bold">{selectedTask.task.name}</span>
|
||||
<div className="flex shrink-0 items-center justify-between border-b bg-muted/50 px-3 py-2">
|
||||
<h3 className="text-[13px] font-bold">{selectedTask.task.name}</h3>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => setSelectedTaskKey(null)}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -1375,8 +1436,8 @@ export default function MyWorkPage() {
|
||||
{/* 수행항목 */}
|
||||
<ResizablePanel defaultSize={40} minSize={20}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="shrink-0 border-b bg-muted/30 px-3 py-1.5 text-xs font-bold">
|
||||
<ClipboardList className="mr-1 inline h-3.5 w-3.5" />수행항목
|
||||
<div className="shrink-0 border-b bg-muted/50 px-3 py-1.5">
|
||||
<h3 className="text-[13px] font-bold">수행항목</h3>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
{/* 추가 입력 */}
|
||||
@@ -1408,7 +1469,7 @@ export default function MyWorkPage() {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className={cn("min-w-0 flex-1 truncate text-xs font-semibold", done && "text-muted-foreground line-through")}>
|
||||
<span className={cn("min-w-0 flex-1 truncate text-[13px] font-semibold", done && "text-muted-foreground line-through")}>
|
||||
{si.name}
|
||||
</span>
|
||||
<Badge variant="outline" className="h-4 px-1 text-[9px]">{si.weight}%</Badge>
|
||||
@@ -1426,7 +1487,7 @@ export default function MyWorkPage() {
|
||||
|
||||
{/* 요약 */}
|
||||
{selectedTask.task.subItems.length > 0 && (
|
||||
<div className="flex items-center justify-between border-b bg-blue-50 px-2.5 py-1 text-[11px] dark:bg-blue-900/10">
|
||||
<div className="flex items-center justify-between border-b bg-primary/5 px-2.5 py-1 text-[11px]">
|
||||
<span>
|
||||
가중 진행률: <strong className="text-primary">{calcAutoProgress(selectedTask.task)}%</strong>
|
||||
{selectedTask.task.subItems.reduce((s, i) => s + i.weight, 0) !== 100 && (
|
||||
@@ -1462,8 +1523,8 @@ export default function MyWorkPage() {
|
||||
{/* 수행기록 */}
|
||||
<ResizablePanel defaultSize={60} minSize={30}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="shrink-0 border-b bg-muted/30 px-3 py-1.5 text-xs font-bold">
|
||||
<FileEdit className="mr-1 inline h-3.5 w-3.5" />수행기록
|
||||
<div className="shrink-0 border-b bg-muted/50 px-3 py-1.5">
|
||||
<h3 className="text-[13px] font-bold">수행기록</h3>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
{selectedSubItemId === "__unassigned__" ? (
|
||||
@@ -1556,7 +1617,7 @@ export default function MyWorkPage() {
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
{modalAttachments.map((f, i) => (
|
||||
<div key={f.id} className="flex items-center gap-2 rounded-md border bg-muted/30 p-2">
|
||||
<div key={f.id} className="flex items-center gap-2 rounded-md border bg-muted/50 p-2">
|
||||
{getFileIcon(f.type)}
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-semibold">{f.name}</div>
|
||||
@@ -1636,9 +1697,9 @@ export default function MyWorkPage() {
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs font-semibold">등록된 구매요청 ({modalPurchaseReqs.length})</div>
|
||||
{modalPurchaseReqs.length === 0 && <div className="py-3 text-center text-xs text-muted-foreground">등록된 요청이 없습니다.</div>}
|
||||
{modalPurchaseReqs.length === 0 && <div className="py-3 text-center text-xs text-muted-foreground">등록된 요청이 없어요.</div>}
|
||||
{modalPurchaseReqs.map((pr, i) => (
|
||||
<div key={pr.id} className="flex items-center gap-2 rounded-md border bg-muted/30 p-2">
|
||||
<div key={pr.id} className="flex items-center gap-2 rounded-md border bg-muted/50 p-2">
|
||||
<ShoppingCart className="h-3.5 w-3.5" />
|
||||
<strong className="text-xs">{pr.item}</strong>
|
||||
<span className="text-[10px] text-muted-foreground">{pr.qty} {pr.unit}</span>
|
||||
@@ -1698,9 +1759,9 @@ export default function MyWorkPage() {
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs font-semibold">등록된 협조요청 ({modalCoopReqs.length})</div>
|
||||
{modalCoopReqs.length === 0 && <div className="py-3 text-center text-xs text-muted-foreground">등록된 요청이 없습니다.</div>}
|
||||
{modalCoopReqs.length === 0 && <div className="py-3 text-center text-xs text-muted-foreground">등록된 요청이 없어요.</div>}
|
||||
{modalCoopReqs.map((cr, i) => (
|
||||
<div key={cr.id} className="space-y-1 rounded-md border bg-muted/30 p-2">
|
||||
<div key={cr.id} className="space-y-1 rounded-md border bg-muted/50 p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Handshake className="h-3.5 w-3.5" />
|
||||
<strong className="flex-1 text-xs">{cr.title}</strong>
|
||||
@@ -1762,7 +1823,7 @@ function RenderLogs({ title, logs, editingLogIdx, editForm, setEditForm, onStart
|
||||
{logs.length === 0 && editingLogIdx !== -2 && (
|
||||
<div className="flex flex-col items-center justify-center gap-1 py-8 text-muted-foreground">
|
||||
<FileEdit className="h-6 w-6" />
|
||||
<span className="text-xs">기록이 없습니다</span>
|
||||
<span className="text-xs">기록이 없어요</span>
|
||||
</div>
|
||||
)}
|
||||
{logs.map((log) => {
|
||||
@@ -1805,12 +1866,12 @@ function RenderLogs({ title, logs, editingLogIdx, editForm, setEditForm, onStart
|
||||
</Badge>
|
||||
)}
|
||||
{prC > 0 && (
|
||||
<Badge className="h-4 cursor-pointer gap-0.5 bg-amber-50 px-1 text-[9px] text-amber-800 hover:bg-amber-100 dark:bg-amber-900/20" onClick={(e) => { e.stopPropagation(); onOpenPr(idx); }}>
|
||||
<Badge className="h-4 cursor-pointer gap-0.5 bg-warning/10 px-1 text-[9px] text-warning hover:bg-warning/20" onClick={(e) => { e.stopPropagation(); onOpenPr(idx); }}>
|
||||
<ShoppingCart className="h-2.5 w-2.5" />{prC}
|
||||
</Badge>
|
||||
)}
|
||||
{crC > 0 && (
|
||||
<Badge className="h-4 cursor-pointer gap-0.5 bg-pink-50 px-1 text-[9px] text-pink-800 hover:bg-pink-100 dark:bg-pink-900/20" onClick={(e) => { e.stopPropagation(); onOpenCr(idx); }}>
|
||||
<Badge className="h-4 cursor-pointer gap-0.5 bg-info/10 px-1 text-[9px] text-info hover:bg-info/20" onClick={(e) => { e.stopPropagation(); onOpenCr(idx); }}>
|
||||
<Handshake className="h-2.5 w-2.5" />{crC}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -1861,7 +1922,7 @@ interface EditLogRowProps {
|
||||
function EditLogRow({ logIdx, editForm, setEditForm, onSave, onCancel, onOpenAtt, onOpenPr, onOpenCr, attCount, prCount, crCount }: EditLogRowProps) {
|
||||
const hours = calcHours(editForm.startDt, editForm.endDt);
|
||||
return (
|
||||
<div className="space-y-1.5 rounded-md border border-emerald-300 bg-emerald-50/50 p-2.5 dark:border-emerald-800 dark:bg-emerald-900/10">
|
||||
<div className="space-y-1.5 rounded-md border border-primary/30 bg-primary/5 p-2.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Input type="datetime-local" value={editForm.startDt} onChange={(e) => setEditForm((p) => ({ ...p, startDt: e.target.value }))} className="h-6 w-[155px] text-[11px]" />
|
||||
<span className="text-xs text-muted-foreground">~</span>
|
||||
@@ -1889,7 +1950,7 @@ function EditLogRow({ logIdx, editForm, setEditForm, onSave, onCancel, onOpenAtt
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="outline" size="sm" className="h-5 px-2 text-[10px]" onClick={onCancel}>취소</Button>
|
||||
<Button size="sm" className="h-5 bg-emerald-500 px-2 text-[10px] hover:bg-emerald-600" onClick={() => onSave(logIdx)}>저장</Button>
|
||||
<Button size="sm" className="h-5 bg-success px-2 text-[10px] hover:bg-success/90" onClick={() => onSave(logIdx)}>저장</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@
|
||||
* 점검항목 복사 기능 포함
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
@@ -20,56 +20,34 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
|
||||
Wrench, ClipboardCheck, Package, Copy, Info, Settings2,
|
||||
Inbox, ClipboardCheck, Package, Copy, Info, Settings2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
|
||||
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||
import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel";
|
||||
import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
|
||||
const EQUIP_TABLE = "equipment_mng";
|
||||
const INSPECTION_TABLE = "equipment_inspection_item";
|
||||
const CONSUMABLE_TABLE = "equipment_consumable";
|
||||
|
||||
const LEFT_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "equipment_code", label: "설비코드", width: "w-[110px]" },
|
||||
{ key: "equipment_name", label: "설비명", minWidth: "min-w-[130px]" },
|
||||
{ key: "equipment_type", label: "설비유형", width: "w-[90px]" },
|
||||
{ key: "manufacturer", label: "제조사", width: "w-[100px]" },
|
||||
{ key: "installation_location", label: "설치장소", width: "w-[100px]" },
|
||||
{ key: "operation_status", label: "가동상태", width: "w-[80px]" },
|
||||
const GRID_COLUMNS_CONFIG = [
|
||||
{ key: "equipment_code", label: "설비코드" },
|
||||
{ key: "equipment_name", label: "설비명" },
|
||||
{ key: "equipment_type", label: "설비유형" },
|
||||
{ key: "manufacturer", label: "제조사" },
|
||||
{ key: "installation_location", label: "설치장소" },
|
||||
{ key: "operation_status", label: "가동상태" },
|
||||
];
|
||||
|
||||
const INSPECTION_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "inspection_item", label: "점검항목", minWidth: "min-w-[120px]", editable: true },
|
||||
{ key: "inspection_cycle", label: "점검주기", width: "w-[80px]" },
|
||||
{ key: "inspection_method", label: "점검방법", width: "w-[80px]" },
|
||||
{ key: "lower_limit", label: "하한치", width: "w-[70px]", editable: true },
|
||||
{ key: "upper_limit", label: "상한치", width: "w-[70px]", editable: true },
|
||||
{ key: "unit", label: "단위", width: "w-[60px]", editable: true },
|
||||
{ key: "inspection_content", label: "점검내용", minWidth: "min-w-[150px]", editable: true },
|
||||
];
|
||||
|
||||
const CONSUMABLE_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "image_path", label: "이미지", width: "w-[50px]", renderType: "image", sortable: false, filterable: false },
|
||||
{ key: "consumable_name", label: "소모품명", minWidth: "min-w-[120px]", editable: true },
|
||||
{ key: "replacement_cycle", label: "교체주기", width: "w-[90px]", editable: true },
|
||||
{ key: "unit", label: "단위", width: "w-[60px]", editable: true },
|
||||
{ key: "specification", label: "규격", width: "w-[100px]", editable: true },
|
||||
{ key: "manufacturer", label: "제조사", width: "w-[100px]", editable: true },
|
||||
];
|
||||
|
||||
export default function EquipmentInfoPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
@@ -79,8 +57,6 @@ export default function EquipmentInfoPage() {
|
||||
const [equipLoading, setEquipLoading] = useState(false);
|
||||
const [equipCount, setEquipCount] = useState(0);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
||||
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||
const [selectedEquipId, setSelectedEquipId] = useState<string | null>(null);
|
||||
|
||||
// 우측 탭
|
||||
@@ -93,7 +69,7 @@ export default function EquipmentInfoPage() {
|
||||
// 카테고리
|
||||
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
|
||||
// 모달
|
||||
// 설비 등록/수정 모달
|
||||
const [equipModalOpen, setEquipModalOpen] = useState(false);
|
||||
const [equipEditMode, setEquipEditMode] = useState(false);
|
||||
const [equipForm, setEquipForm] = useState<Record<string, any>>({});
|
||||
@@ -103,11 +79,17 @@ export default function EquipmentInfoPage() {
|
||||
const [infoForm, setInfoForm] = useState<Record<string, any>>({});
|
||||
const [infoSaving, setInfoSaving] = useState(false);
|
||||
|
||||
// 점검항목 추가/수정 모달
|
||||
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
|
||||
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
|
||||
const [inspectionContinuous, setInspectionContinuous] = useState(false);
|
||||
const [inspectionEditMode, setInspectionEditMode] = useState(false);
|
||||
|
||||
// 소모품 추가/수정 모달
|
||||
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
|
||||
const [consumableForm, setConsumableForm] = useState<Record<string, any>>({});
|
||||
const [consumableContinuous, setConsumableContinuous] = useState(false);
|
||||
const [consumableEditMode, setConsumableEditMode] = useState(false);
|
||||
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
|
||||
|
||||
// 점검항목 복사
|
||||
@@ -122,14 +104,8 @@ export default function EquipmentInfoPage() {
|
||||
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(null);
|
||||
const [excelDetecting, setExcelDetecting] = useState(false);
|
||||
|
||||
const applyTableSettings = useCallback((settings: TableSettings) => {
|
||||
setFilterConfig(settings.filters);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = loadTableSettings("equipment-info");
|
||||
if (saved) applyTableSettings(saved);
|
||||
}, []);
|
||||
// 테이블 설정
|
||||
const ts = useTableSettings("c16-equipment-info", EQUIP_TABLE, GRID_COLUMNS_CONFIG);
|
||||
|
||||
// 카테고리 로드
|
||||
useEffect(() => {
|
||||
@@ -143,14 +119,12 @@ export default function EquipmentInfoPage() {
|
||||
}
|
||||
return result;
|
||||
};
|
||||
// equipment_mng 카테고리
|
||||
for (const col of ["equipment_type", "operation_status"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${EQUIP_TABLE}/${col}/values`);
|
||||
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
// inspection 카테고리
|
||||
for (const col of ["inspection_cycle", "inspection_method"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/${col}/values`);
|
||||
@@ -167,6 +141,17 @@ export default function EquipmentInfoPage() {
|
||||
return catOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
|
||||
const mainTableColumns = useMemo<EDataTableColumn[]>(() => {
|
||||
const cols: EDataTableColumn[] = [];
|
||||
if (ts.isVisible("equipment_code")) cols.push({ key: "equipment_code", label: "설비코드", width: "w-[110px]" });
|
||||
if (ts.isVisible("equipment_name")) cols.push({ key: "equipment_name", label: "설비명", minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" });
|
||||
if (ts.isVisible("equipment_type")) cols.push({ key: "equipment_type", label: "설비유형", width: "w-[90px]", render: (v) => v || "-" });
|
||||
if (ts.isVisible("manufacturer")) cols.push({ key: "manufacturer", label: "제조사", width: "w-[100px]", render: (v) => v || "-" });
|
||||
if (ts.isVisible("installation_location")) cols.push({ key: "installation_location", label: "설치장소", width: "w-[100px]", render: (v) => v || "-" });
|
||||
if (ts.isVisible("operation_status")) cols.push({ key: "operation_status", label: "가동상태", width: "w-[80px]", render: (v) => v || "-" });
|
||||
return cols;
|
||||
}, [ts.visibleColumns]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 설비 조회
|
||||
const fetchEquipments = useCallback(async () => {
|
||||
setEquipLoading(true);
|
||||
@@ -213,7 +198,7 @@ export default function EquipmentInfoPage() {
|
||||
// 우측: 점검항목 조회
|
||||
useEffect(() => {
|
||||
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
|
||||
const fetch = async () => {
|
||||
const fetchData = async () => {
|
||||
setInspectionLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
|
||||
@@ -221,21 +206,16 @@ export default function EquipmentInfoPage() {
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setInspections(raw.map((r: any) => ({
|
||||
...r,
|
||||
inspection_cycle: resolve("inspection_cycle", r.inspection_cycle),
|
||||
inspection_method: resolve("inspection_method", r.inspection_method),
|
||||
})));
|
||||
setInspections(res.data?.data?.data || res.data?.data?.rows || []);
|
||||
} catch { setInspections([]); } finally { setInspectionLoading(false); }
|
||||
};
|
||||
fetch();
|
||||
}, [selectedEquip?.equipment_code, catOptions]);
|
||||
fetchData();
|
||||
}, [selectedEquip?.equipment_code]);
|
||||
|
||||
// 우측: 소모품 조회
|
||||
useEffect(() => {
|
||||
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
|
||||
const fetch = async () => {
|
||||
const fetchData = async () => {
|
||||
setConsumableLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/data`, {
|
||||
@@ -246,7 +226,7 @@ export default function EquipmentInfoPage() {
|
||||
setConsumables(res.data?.data?.data || res.data?.data?.rows || []);
|
||||
} catch { setConsumables([]); } finally { setConsumableLoading(false); }
|
||||
};
|
||||
fetch();
|
||||
fetchData();
|
||||
}, [selectedEquip?.equipment_code]);
|
||||
|
||||
// 새로고침 헬퍼
|
||||
@@ -269,7 +249,7 @@ export default function EquipmentInfoPage() {
|
||||
await apiClient.put(`/table-management/tables/${EQUIP_TABLE}/edit`, { originalData: { id }, updatedData: fields });
|
||||
toast.success("수정되었습니다.");
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/add`, fields);
|
||||
await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/add`, { id: crypto.randomUUID(), ...fields });
|
||||
toast.success("등록되었습니다.");
|
||||
}
|
||||
setEquipModalOpen(false); fetchEquipments();
|
||||
@@ -289,17 +269,50 @@ export default function EquipmentInfoPage() {
|
||||
// 점검항목 추가
|
||||
const handleInspectionSave = async () => {
|
||||
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
|
||||
if (!inspectionForm.inspection_cycle) { toast.error("점검주기는 필수입니다."); return; }
|
||||
if (!inspectionForm.inspection_method) { toast.error("점검방법은 필수입니다."); return; }
|
||||
const methodLabel = resolve("inspection_method", inspectionForm.inspection_method);
|
||||
const isNumeric = methodLabel === "숫자" || inspectionForm.inspection_method === "숫자";
|
||||
if (isNumeric && !inspectionForm.unit) { toast.error("숫자 점검방법은 측정단위가 필수입니다."); return; }
|
||||
// 기준값/오차범위 → 하한치/상한치 자동 계산
|
||||
const saveData = { ...inspectionForm };
|
||||
if (isNumeric && saveData.standard_value) {
|
||||
const std = Number(saveData.standard_value) || 0;
|
||||
const tol = Number(saveData.tolerance) || 0;
|
||||
saveData.lower_limit = String(std - tol);
|
||||
saveData.upper_limit = String(std + tol);
|
||||
}
|
||||
if (!isNumeric) {
|
||||
saveData.unit = "";
|
||||
saveData.standard_value = "";
|
||||
saveData.tolerance = "";
|
||||
saveData.lower_limit = "";
|
||||
saveData.upper_limit = "";
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
|
||||
...inspectionForm, equipment_code: selectedEquip?.equipment_code,
|
||||
});
|
||||
toast.success("추가되었습니다."); setInspectionModalOpen(false); refreshRight();
|
||||
if (inspectionEditMode) {
|
||||
await apiClient.put(`/table-management/tables/${INSPECTION_TABLE}/edit`, {
|
||||
originalData: { id: saveData.id }, updatedData: { ...saveData, equipment_code: selectedEquip?.equipment_code },
|
||||
});
|
||||
toast.success("수정되었습니다.");
|
||||
setInspectionModalOpen(false);
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
|
||||
id: crypto.randomUUID(), ...saveData, equipment_code: selectedEquip?.equipment_code,
|
||||
});
|
||||
toast.success("추가되었습니다.");
|
||||
if (inspectionContinuous) {
|
||||
setInspectionForm({});
|
||||
} else {
|
||||
setInspectionModalOpen(false);
|
||||
}
|
||||
}
|
||||
refreshRight();
|
||||
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
// 소모품 추가
|
||||
// 소모품 품목 로드 (item_info에서 type 또는 division 라벨이 "소모품"인 것)
|
||||
// 소모품 품목 로드
|
||||
const loadConsumableItems = async () => {
|
||||
try {
|
||||
const flatten = (vals: any[]): any[] => {
|
||||
@@ -307,22 +320,16 @@ export default function EquipmentInfoPage() {
|
||||
for (const v of vals) { r.push(v); if (v.children?.length) r.push(...flatten(v.children)); }
|
||||
return r;
|
||||
};
|
||||
|
||||
// type과 division 카테고리 모두에서 "소모품" 코드 찾기
|
||||
const [typeRes, divRes] = await Promise.all([
|
||||
apiClient.get(`/table-categories/item_info/type/values`),
|
||||
apiClient.get(`/table-categories/item_info/division/values`),
|
||||
]);
|
||||
const consumableType = flatten(typeRes.data?.data || []).find((t: any) => t.valueLabel === "소모품");
|
||||
const consumableDiv = flatten(divRes.data?.data || []).find((t: any) => t.valueLabel === "소모품");
|
||||
|
||||
if (!consumableType && !consumableDiv) { setConsumableItemOptions([]); return; }
|
||||
|
||||
// 두 필터 결과를 합산 (중복 제거)
|
||||
const filters: any[] = [];
|
||||
if (consumableType) filters.push({ columnName: "type", operator: "equals", value: consumableType.valueCode });
|
||||
if (consumableDiv) filters.push({ columnName: "division", operator: "equals", value: consumableDiv.valueCode });
|
||||
|
||||
const results = await Promise.all(filters.map((f) =>
|
||||
apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 500,
|
||||
@@ -330,7 +337,6 @@ export default function EquipmentInfoPage() {
|
||||
autoFilter: true,
|
||||
})
|
||||
));
|
||||
|
||||
const allItems = new Map<string, any>();
|
||||
for (const res of results) {
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
@@ -344,14 +350,28 @@ export default function EquipmentInfoPage() {
|
||||
if (!consumableForm.consumable_name) { toast.error("소모품명은 필수입니다."); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/add`, {
|
||||
...consumableForm, equipment_code: selectedEquip?.equipment_code,
|
||||
});
|
||||
toast.success("추가되었습니다."); setConsumableModalOpen(false); refreshRight();
|
||||
if (consumableEditMode) {
|
||||
await apiClient.put(`/table-management/tables/${CONSUMABLE_TABLE}/edit`, {
|
||||
originalData: { id: consumableForm.id }, updatedData: { ...consumableForm, equipment_code: selectedEquip?.equipment_code },
|
||||
});
|
||||
toast.success("수정되었습니다.");
|
||||
setConsumableModalOpen(false);
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/add`, {
|
||||
id: crypto.randomUUID(), ...consumableForm, equipment_code: selectedEquip?.equipment_code,
|
||||
});
|
||||
toast.success("추가되었습니다.");
|
||||
if (consumableContinuous) {
|
||||
setConsumableForm({});
|
||||
} else {
|
||||
setConsumableModalOpen(false);
|
||||
}
|
||||
}
|
||||
refreshRight();
|
||||
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
// 점검항목 복사: 소스 설비 선택 시 점검항목 로드
|
||||
// 점검항목 복사
|
||||
const loadCopyItems = async (equipCode: string) => {
|
||||
setCopySourceEquip(equipCode);
|
||||
setCopyChecked(new Set());
|
||||
@@ -406,13 +426,22 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
<DynamicSearchFilter tableName={EQUIP_TABLE} filterId="equipment-info" onFilterChange={setSearchFilters} dataCount={equipCount}
|
||||
externalFilterConfig={filterConfig}
|
||||
{/* 브레드크럼 */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
|
||||
<span>설비관리</span>
|
||||
<span className="text-muted-foreground/50">/</span>
|
||||
<span className="text-foreground font-medium">설비정보</span>
|
||||
</div>
|
||||
|
||||
{/* 검색 바 */}
|
||||
<DynamicSearchFilter
|
||||
tableName={EQUIP_TABLE}
|
||||
filterId="c16-equipment-info"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={equipCount}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
extraActions={
|
||||
<div className="flex gap-1.5">
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
|
||||
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9" disabled={excelDetecting}
|
||||
onClick={async () => {
|
||||
setExcelDetecting(true);
|
||||
@@ -431,24 +460,39 @@ export default function EquipmentInfoPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
|
||||
<div className="flex-1 overflow-hidden border rounded-lg bg-card">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
{/* 좌측: 설비 목록 */}
|
||||
<ResizablePanel defaultSize={40} minSize={25}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
<Wrench className="w-4 h-4" /> 설비 목록 <Badge variant="secondary" className="font-normal">{equipCount}건</Badge>
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/50 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-[13px] font-bold">설비 목록</h3>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">{equipCount}건</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" onClick={openEquipRegister}><Plus className="w-3.5 h-3.5 mr-1" /> 등록</Button>
|
||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={openEquipEdit}><Pencil className="w-3.5 h-3.5 mr-1" /> 수정</Button>
|
||||
<Button variant="destructive" size="sm" disabled={!selectedEquipId} onClick={handleEquipDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제</Button>
|
||||
<div className="h-4 w-px bg-border" />
|
||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={handleEquipDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10"><Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제</Button>
|
||||
<div className="h-4 w-px bg-border" />
|
||||
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)}>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DataGrid gridId="equip-left" columns={LEFT_COLUMNS} data={equipments} loading={equipLoading}
|
||||
selectedId={selectedEquipId} onSelect={setSelectedEquipId} onRowDoubleClick={() => openEquipEdit()}
|
||||
emptyMessage="등록된 설비가 없습니다" />
|
||||
<EDataTable
|
||||
columns={mainTableColumns}
|
||||
data={ts.groupData(equipments)}
|
||||
loading={equipLoading}
|
||||
emptyMessage="등록된 설비가 없어요"
|
||||
selectedId={selectedEquipId}
|
||||
onSelect={(id) => setSelectedEquipId(id)}
|
||||
onRowDoubleClick={() => openEquipEdit()}
|
||||
showPagination={true}
|
||||
draggableColumns={false}
|
||||
columnOrderKey="c16-equipment-info-main"
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
@@ -457,7 +501,7 @@ export default function EquipmentInfoPage() {
|
||||
{/* 우측: 탭 */}
|
||||
<ResizablePanel defaultSize={60} minSize={30}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between p-2 border-b bg-muted/10 shrink-0">
|
||||
<div className="flex items-center justify-between p-2 border-b bg-muted/50 shrink-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{([["info", "기본정보", Info], ["inspection", "점검항목", ClipboardCheck], ["consumable", "소모품", Package]] as const).map(([tab, label, Icon]) => (
|
||||
<button key={tab} onClick={() => setRightTab(tab)}
|
||||
@@ -473,7 +517,7 @@ export default function EquipmentInfoPage() {
|
||||
<div className="flex gap-1.5">
|
||||
{rightTab === "inspection" && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionModalOpen(true); }}>
|
||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionEditMode(false); setInspectionModalOpen(true); }}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> 추가
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setCopySourceEquip(""); setCopyItems([]); setCopyChecked(new Set()); setCopyModalOpen(true); }}>
|
||||
@@ -482,7 +526,7 @@ export default function EquipmentInfoPage() {
|
||||
</>
|
||||
)}
|
||||
{rightTab === "consumable" && (
|
||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); loadConsumableItems(); setConsumableModalOpen(true); }}>
|
||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); setConsumableEditMode(false); loadConsumableItems(); setConsumableModalOpen(true); }}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> 추가
|
||||
</Button>
|
||||
)}
|
||||
@@ -490,7 +534,13 @@ export default function EquipmentInfoPage() {
|
||||
</div>
|
||||
|
||||
{!selectedEquipId ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">좌측에서 설비를 선택하세요</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 m-3 border-2 border-dashed rounded-lg text-center">
|
||||
<Inbox className="w-12 h-12 text-muted-foreground/40" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-muted-foreground">설비를 선택해주세요</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">좌측에서 설비를 선택하면 상세 정보가 표시돼요</p>
|
||||
</div>
|
||||
</div>
|
||||
) : rightTab === "info" ? (
|
||||
<div className="p-4 overflow-auto">
|
||||
<div className="flex justify-end mb-3">
|
||||
@@ -525,7 +575,7 @@ export default function EquipmentInfoPage() {
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">도입일자</Label>
|
||||
<FormDatePicker value={infoForm.introduction_date || ""} onChange={(v) => setInfoForm((p) => ({ ...p, introduction_date: v }))} placeholder="도입일자" />
|
||||
<Input type="date" value={infoForm.introduction_date || ""} onChange={(e) => setInfoForm((p) => ({ ...p, introduction_date: e.target.value }))} className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">가동상태</Label>
|
||||
@@ -543,13 +593,92 @@ export default function EquipmentInfoPage() {
|
||||
</div>
|
||||
</div>
|
||||
) : rightTab === "inspection" ? (
|
||||
<DataGrid gridId="equip-inspection" columns={INSPECTION_COLUMNS} data={inspections} loading={inspectionLoading}
|
||||
showRowNumber={false} tableName={INSPECTION_TABLE} emptyMessage="점검항목이 없습니다"
|
||||
onCellEdit={() => refreshRight()} />
|
||||
<div className="flex-1 overflow-auto">
|
||||
{inspectionLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : inspections.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<ClipboardCheck className="w-8 h-8 mb-2 opacity-40" />
|
||||
<p className="text-sm">점검항목이 없어요</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검항목</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검주기</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검방법</TableHead>
|
||||
<TableHead className="w-[70px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">하한치</TableHead>
|
||||
<TableHead className="w-[70px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상한치</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
||||
<TableHead className="min-w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검내용</TableHead>
|
||||
</TableRow>
|
||||
</thead>
|
||||
<TableBody>
|
||||
{inspections.map((item) => (
|
||||
<TableRow key={item.id} className="cursor-pointer hover:bg-primary/5" onDoubleClick={() => {
|
||||
const std = item.standard_value || "";
|
||||
const tol = item.tolerance || "";
|
||||
setInspectionForm({ ...item, standard_value: std, tolerance: tol });
|
||||
setInspectionEditMode(true);
|
||||
setInspectionModalOpen(true);
|
||||
}}>
|
||||
<TableCell className="text-sm">{item.inspection_item || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
|
||||
<TableCell className="text-[13px]">{resolve("inspection_method", item.inspection_method)}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.lower_limit || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.upper_limit || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.inspection_content || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<DataGrid gridId="equip-consumable" columns={CONSUMABLE_COLUMNS} data={consumables} loading={consumableLoading}
|
||||
showRowNumber={false} tableName={CONSUMABLE_TABLE} emptyMessage="소모품이 없습니다"
|
||||
onCellEdit={() => refreshRight()} />
|
||||
<div className="flex-1 overflow-auto">
|
||||
{consumableLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : consumables.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Package className="w-8 h-8 mb-2 opacity-40" />
|
||||
<p className="text-sm">소모품이 없어요</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">소모품명</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">교체주기</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">제조사</TableHead>
|
||||
</TableRow>
|
||||
</thead>
|
||||
<TableBody>
|
||||
{consumables.map((item) => (
|
||||
<TableRow key={item.id} className="cursor-pointer hover:bg-primary/5" onDoubleClick={() => {
|
||||
setConsumableForm({ ...item });
|
||||
setConsumableEditMode(true);
|
||||
loadConsumableItems();
|
||||
setConsumableModalOpen(true);
|
||||
}}>
|
||||
<TableCell className="text-sm">{item.consumable_name || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.replacement_cycle || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.specification || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.manufacturer || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
@@ -557,67 +686,121 @@ export default function EquipmentInfoPage() {
|
||||
</div>
|
||||
|
||||
{/* 설비 등록/수정 모달 */}
|
||||
<FullscreenDialog open={equipModalOpen} onOpenChange={setEquipModalOpen}
|
||||
title={equipEditMode ? "설비 수정" : "설비 등록"} description={equipEditMode ? "설비 정보를 수정합니다." : "새로운 설비를 등록합니다."}
|
||||
defaultMaxWidth="max-w-2xl"
|
||||
footer={<><Button variant="outline" onClick={() => setEquipModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleEquipSave} disabled={saving}>{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장</Button></>}>
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
<div className="space-y-1.5"><Label className="text-sm">설비코드</Label>
|
||||
<Input value={equipForm.equipment_code || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_code: e.target.value }))} placeholder="설비코드" className="h-9" disabled={equipEditMode} /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">설비명 <span className="text-destructive">*</span></Label>
|
||||
<Input value={equipForm.equipment_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_name: e.target.value }))} placeholder="설비명" className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">설비유형</Label>
|
||||
{catSelect("equipment_type", equipForm.equipment_type, (v) => setEquipForm((p) => ({ ...p, equipment_type: v })), "설비유형")}</div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">가동상태</Label>
|
||||
{catSelect("operation_status", equipForm.operation_status, (v) => setEquipForm((p) => ({ ...p, operation_status: v })), "가동상태")}</div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">설치장소</Label>
|
||||
<Input value={equipForm.installation_location || ""} onChange={(e) => setEquipForm((p) => ({ ...p, installation_location: e.target.value }))} placeholder="설치장소" className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">제조사</Label>
|
||||
<Input value={equipForm.manufacturer || ""} onChange={(e) => setEquipForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">모델명</Label>
|
||||
<Input value={equipForm.model_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, model_name: e.target.value }))} placeholder="모델명" className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">도입일자</Label>
|
||||
<FormDatePicker value={equipForm.introduction_date || ""} onChange={(v) => setEquipForm((p) => ({ ...p, introduction_date: v }))} placeholder="도입일자" /></div>
|
||||
<div className="space-y-1.5 col-span-2"><Label className="text-sm">비고</Label>
|
||||
<Input value={equipForm.remarks || ""} onChange={(e) => setEquipForm((p) => ({ ...p, remarks: e.target.value }))} placeholder="비고" className="h-9" /></div>
|
||||
<div className="space-y-1.5 col-span-2"><Label className="text-sm">이미지</Label>
|
||||
<ImageUpload value={equipForm.image_path} onChange={(v) => setEquipForm((p) => ({ ...p, image_path: v }))}
|
||||
tableName={EQUIP_TABLE} recordId={equipForm.id} columnName="image_path" /></div>
|
||||
</div>
|
||||
</FullscreenDialog>
|
||||
<Dialog open={equipModalOpen} onOpenChange={setEquipModalOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{equipEditMode ? "설비 수정" : "설비 등록"}</DialogTitle>
|
||||
<DialogDescription>{equipEditMode ? "설비 정보를 수정합니다." : "새로운 설비를 등록합니다."}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
<div className="space-y-1.5"><Label className="text-sm">설비코드</Label>
|
||||
<Input value={equipForm.equipment_code || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_code: e.target.value }))} placeholder="설비코드" className="h-9" disabled={equipEditMode} /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">설비명 <span className="text-destructive">*</span></Label>
|
||||
<Input value={equipForm.equipment_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_name: e.target.value }))} placeholder="설비명" className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">설비유형</Label>
|
||||
{catSelect("equipment_type", equipForm.equipment_type, (v) => setEquipForm((p) => ({ ...p, equipment_type: v })), "설비유형")}</div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">가동상태</Label>
|
||||
{catSelect("operation_status", equipForm.operation_status, (v) => setEquipForm((p) => ({ ...p, operation_status: v })), "가동상태")}</div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">설치장소</Label>
|
||||
<Input value={equipForm.installation_location || ""} onChange={(e) => setEquipForm((p) => ({ ...p, installation_location: e.target.value }))} placeholder="설치장소" className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">제조사</Label>
|
||||
<Input value={equipForm.manufacturer || ""} onChange={(e) => setEquipForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">모델명</Label>
|
||||
<Input value={equipForm.model_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, model_name: e.target.value }))} placeholder="모델명" className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">도입일자</Label>
|
||||
<Input type="date" value={equipForm.introduction_date || ""} onChange={(e) => setEquipForm((p) => ({ ...p, introduction_date: e.target.value }))} className="h-9" /></div>
|
||||
<div className="space-y-1.5 col-span-2"><Label className="text-sm">비고</Label>
|
||||
<Input value={equipForm.remarks || ""} onChange={(e) => setEquipForm((p) => ({ ...p, remarks: e.target.value }))} placeholder="비고" className="h-9" /></div>
|
||||
<div className="space-y-1.5 col-span-2"><Label className="text-sm">이미지</Label>
|
||||
<ImageUpload value={equipForm.image_path} onChange={(v) => setEquipForm((p) => ({ ...p, image_path: v }))}
|
||||
tableName={EQUIP_TABLE} recordId={equipForm.id} columnName="image_path" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEquipModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleEquipSave} disabled={saving}>{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 점검항목 추가 모달 */}
|
||||
<Dialog open={inspectionModalOpen} onOpenChange={setInspectionModalOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader><DialogTitle>점검항목 추가</DialogTitle><DialogDescription>{selectedEquip?.equipment_name}에 점검항목을 추가합니다.</DialogDescription></DialogHeader>
|
||||
<DialogHeader><DialogTitle>{inspectionEditMode ? "점검항목 수정" : "점검항목 추가"}</DialogTitle><DialogDescription>{selectedEquip?.equipment_name}에 점검항목을 {inspectionEditMode ? "수정" : "추가"}합니다.</DialogDescription></DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-1.5"><Label className="text-sm">점검항목 <span className="text-destructive">*</span></Label>
|
||||
<Input value={inspectionForm.inspection_item || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_item: e.target.value }))} placeholder="점검항목" className="h-9" /></div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5"><Label className="text-sm">점검주기</Label>
|
||||
<div className="space-y-1.5"><Label className="text-sm">점검항목명 <span className="text-destructive">*</span></Label>
|
||||
<Input value={inspectionForm.inspection_item || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_item: e.target.value }))} placeholder="예: 온도점검, 진동점검" className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">점검주기 <span className="text-destructive">*</span></Label>
|
||||
{catSelect("inspection_cycle", inspectionForm.inspection_cycle, (v) => setInspectionForm((p) => ({ ...p, inspection_cycle: v })), "점검주기")}</div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">점검방법</Label>
|
||||
{catSelect("inspection_method", inspectionForm.inspection_method, (v) => setInspectionForm((p) => ({ ...p, inspection_method: v })), "점검방법")}</div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">하한치</Label>
|
||||
<Input value={inspectionForm.lower_limit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, lower_limit: e.target.value }))} placeholder="하한치" className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">상한치</Label>
|
||||
<Input value={inspectionForm.upper_limit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, upper_limit: e.target.value }))} placeholder="상한치" className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">단위</Label>
|
||||
<Input value={inspectionForm.unit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" /></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5"><Label className="text-sm">점검방법 <span className="text-destructive">*</span></Label>
|
||||
{catSelect("inspection_method", inspectionForm.inspection_method, (v) => {
|
||||
const label = resolve("inspection_method", v);
|
||||
const isNum = label === "숫자" || v === "숫자";
|
||||
if (!isNum) {
|
||||
setInspectionForm((p) => ({ ...p, inspection_method: v, unit: "", standard_value: "", tolerance: "", lower_limit: "", upper_limit: "" }));
|
||||
} else {
|
||||
setInspectionForm((p) => ({ ...p, inspection_method: v }));
|
||||
}
|
||||
}, "점검방법")}</div>
|
||||
{(() => {
|
||||
const methodLabel = resolve("inspection_method", inspectionForm.inspection_method);
|
||||
const isNumeric = methodLabel === "숫자" || inspectionForm.inspection_method === "숫자";
|
||||
if (!isNumeric) return null;
|
||||
return (
|
||||
<div className="space-y-1.5"><Label className="text-sm">측정 단위 <span className="text-destructive">*</span></Label>
|
||||
<Input value={inspectionForm.unit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, unit: e.target.value }))} placeholder="예: ℃, mm, V" className="h-9" /></div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{(() => {
|
||||
const methodLabel = resolve("inspection_method", inspectionForm.inspection_method);
|
||||
const isNumeric = methodLabel === "숫자" || inspectionForm.inspection_method === "숫자";
|
||||
if (!isNumeric) return null;
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5"><Label className="text-sm">기준값</Label>
|
||||
<Input value={inspectionForm.standard_value || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, standard_value: e.target.value }))} placeholder="기준값 입력" className="h-9" type="number" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">±오차범위</Label>
|
||||
<Input value={inspectionForm.tolerance || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, tolerance: e.target.value }))} placeholder="허용 오차범위" className="h-9" type="number" /></div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="space-y-1.5"><Label className="text-sm">점검내용</Label>
|
||||
<Input value={inspectionForm.inspection_content || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_content: e.target.value }))} placeholder="점검내용" className="h-9" /></div>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm min-h-[70px] resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
value={inspectionForm.inspection_content || ""}
|
||||
onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_content: e.target.value }))}
|
||||
placeholder="점검 항목 및 내용 입력"
|
||||
/></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">체크리스트 (선택사항)</Label>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm min-h-[70px] resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
value={inspectionForm.checklist || ""}
|
||||
onChange={(e) => setInspectionForm((p) => ({ ...p, checklist: e.target.value }))}
|
||||
placeholder="점검 체크리스트 입력 (줄바꿈으로 구분)"
|
||||
/></div>
|
||||
</div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setInspectionModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleInspectionSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> 저장</Button></DialogFooter>
|
||||
<DialogFooter className="flex items-center justify-between sm:justify-between">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={inspectionContinuous} onCheckedChange={(c) => setInspectionContinuous(!!c)} />
|
||||
저장 후 계속 입력
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setInspectionModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleInspectionSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> 저장</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 소모품 추가 모달 */}
|
||||
<Dialog open={consumableModalOpen} onOpenChange={setConsumableModalOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader><DialogTitle>소모품 추가</DialogTitle><DialogDescription>{selectedEquip?.equipment_name}에 소모품을 추가합니다.</DialogDescription></DialogHeader>
|
||||
<DialogHeader><DialogTitle>{consumableEditMode ? "소모품 수정" : "소모품 추가"}</DialogTitle><DialogDescription>{selectedEquip?.equipment_name}에 소모품을 {consumableEditMode ? "수정" : "추가"}합니다.</DialogDescription></DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
<div className="space-y-1.5 col-span-2"><Label className="text-sm">소모품명 <span className="text-destructive">*</span></Label>
|
||||
{consumableItemOptions.length > 0 ? (
|
||||
@@ -659,17 +842,25 @@ export default function EquipmentInfoPage() {
|
||||
<ImageUpload value={consumableForm.image_path} onChange={(v) => setConsumableForm((p) => ({ ...p, image_path: v }))}
|
||||
tableName={CONSUMABLE_TABLE} columnName="image_path" /></div>
|
||||
</div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setConsumableModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleConsumableSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> 저장</Button></DialogFooter>
|
||||
<DialogFooter className="flex items-center justify-between sm:justify-between">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={consumableContinuous} onCheckedChange={(c) => setConsumableContinuous(!!c)} />
|
||||
저장 후 계속 입력
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setConsumableModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleConsumableSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> 저장</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 점검항목 복사 모달 */}
|
||||
<Dialog open={copyModalOpen} onOpenChange={setCopyModalOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[70vh]">
|
||||
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader><DialogTitle>점검항목 복사</DialogTitle>
|
||||
<DialogDescription>다른 설비의 점검항목을 선택하여 {selectedEquip?.equipment_name}에 복사합니다.</DialogDescription></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 flex-1 overflow-y-auto">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">소스 설비 선택</Label>
|
||||
<Select value={copySourceEquip} onValueChange={(v) => loadCopyItems(v)}>
|
||||
@@ -685,31 +876,31 @@ export default function EquipmentInfoPage() {
|
||||
{copyLoading ? (
|
||||
<div className="flex items-center justify-center py-8"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||
) : copyItems.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8 text-sm">{copySourceEquip ? "점검항목이 없습니다" : "설비를 선택하세요"}</div>
|
||||
<div className="text-center text-muted-foreground py-8 text-sm">{copySourceEquip ? "점검항목이 없어요" : "설비를 선택해주세요"}</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<input type="checkbox" checked={copyItems.length > 0 && copyChecked.size === copyItems.length}
|
||||
onChange={(e) => { if (e.target.checked) setCopyChecked(new Set(copyItems.map((i) => i.id))); else setCopyChecked(new Set()); }} />
|
||||
</TableHead>
|
||||
<TableHead>점검항목</TableHead><TableHead className="w-[80px]">점검주기</TableHead>
|
||||
<TableHead className="w-[80px]">점검방법</TableHead><TableHead className="w-[70px]">하한</TableHead>
|
||||
<TableHead className="w-[70px]">상한</TableHead><TableHead className="w-[60px]">단위</TableHead>
|
||||
<TableHead>점검항목</TableHead><TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검주기</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검방법</TableHead><TableHead className="w-[70px]">하한</TableHead>
|
||||
<TableHead className="w-[70px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상한</TableHead><TableHead className="w-[60px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</thead>
|
||||
<TableBody>
|
||||
{copyItems.map((item) => (
|
||||
<TableRow key={item.id} className={cn("cursor-pointer", copyChecked.has(item.id) && "bg-primary/5")}
|
||||
onClick={() => setCopyChecked((prev) => { const n = new Set(prev); if (n.has(item.id)) n.delete(item.id); else n.add(item.id); return n; })}>
|
||||
<TableCell className="text-center"><input type="checkbox" checked={copyChecked.has(item.id)} readOnly /></TableCell>
|
||||
<TableCell className="text-sm">{item.inspection_item}</TableCell>
|
||||
<TableCell className="text-xs">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
|
||||
<TableCell className="text-xs">{resolve("inspection_method", item.inspection_method)}</TableCell>
|
||||
<TableCell className="text-xs">{item.lower_limit || "-"}</TableCell>
|
||||
<TableCell className="text-xs">{item.upper_limit || "-"}</TableCell>
|
||||
<TableCell className="text-xs">{item.unit || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
|
||||
<TableCell className="text-[13px]">{resolve("inspection_method", item.inspection_method)}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.lower_limit || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.upper_limit || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -738,12 +929,14 @@ export default function EquipmentInfoPage() {
|
||||
config={excelChainConfig} onSuccess={() => { fetchEquipments(); refreshRight(); }} />
|
||||
)}
|
||||
|
||||
{/* 테이블 설정 */}
|
||||
<TableSettingsModal
|
||||
open={tableSettingsOpen}
|
||||
onOpenChange={setTableSettingsOpen}
|
||||
tableName={EQUIP_TABLE}
|
||||
settingsId="equipment-info"
|
||||
onSave={applyTableSettings}
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Cpu, Settings2, Search, Inbox,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
|
||||
/* ───── 테이블명 ───── */
|
||||
const DATATYPE_TABLE = "plc_data_type";
|
||||
|
||||
const DATATYPE_COLUMNS = [
|
||||
{ key: "equipment_code", label: "설비코드" },
|
||||
{ key: "data_type", label: "데이터타입" },
|
||||
{ key: "unit", label: "단위" },
|
||||
{ key: "tag_address", label: "태그주소" },
|
||||
{ key: "collection_interval", label: "수집주기" },
|
||||
{ key: "lower_limit", label: "하한값" },
|
||||
{ key: "upper_limit", label: "상한값" },
|
||||
{ key: "is_active", label: "사용여부" },
|
||||
];
|
||||
const COLLECTION_TABLE = "plc_collection_config";
|
||||
const EQUIPMENT_TABLE = "equipment_mng";
|
||||
|
||||
/* ───── Cron 한글 변환 ───── */
|
||||
const cronToKorean = (cron: string): string => {
|
||||
if (!cron) return "";
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length < 5) return cron;
|
||||
const [min, hour] = parts;
|
||||
if (min === "*" && hour === "*") return "매 분마다";
|
||||
if (min !== "*" && hour === "*") return `매시 ${min}분마다`;
|
||||
if (min === "0" && hour !== "*") return `매일 ${hour}시 정각`;
|
||||
if (min === "*/5") return "5분마다";
|
||||
if (min === "*/10") return "10분마다";
|
||||
if (min === "*/30") return "30분마다";
|
||||
return cron;
|
||||
};
|
||||
|
||||
/* ───── 카테고리 flatten ───── */
|
||||
const flattenCategories = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode, label: v.valueLabel });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export default function PlcSettingsPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
const ts = useTableSettings("c16-plc-settings", DATATYPE_TABLE, DATATYPE_COLUMNS);
|
||||
|
||||
const [activeTab, setActiveTab] = useState("datatype");
|
||||
|
||||
/* ───── PLC 데이터타입 ───── */
|
||||
const [datatypes, setDatatypes] = useState<any[]>([]);
|
||||
const [dtLoading, setDtLoading] = useState(false);
|
||||
const [dtCount, setDtCount] = useState(0);
|
||||
const [dtChecked, setDtChecked] = useState<string[]>([]);
|
||||
const [dtModalOpen, setDtModalOpen] = useState(false);
|
||||
const [dtEditMode, setDtEditMode] = useState(false);
|
||||
const [dtForm, setDtForm] = useState<Record<string, any>>({});
|
||||
const [dtSaving, setDtSaving] = useState(false);
|
||||
const [dtKeyword, setDtKeyword] = useState("");
|
||||
|
||||
/* ───── 수집 설정 ───── */
|
||||
const [configs, setConfigs] = useState<any[]>([]);
|
||||
const [cfgLoading, setCfgLoading] = useState(false);
|
||||
const [cfgCount, setCfgCount] = useState(0);
|
||||
const [cfgChecked, setCfgChecked] = useState<string[]>([]);
|
||||
const [cfgModalOpen, setCfgModalOpen] = useState(false);
|
||||
const [cfgEditMode, setCfgEditMode] = useState(false);
|
||||
const [cfgForm, setCfgForm] = useState<Record<string, any>>({});
|
||||
const [cfgSaving, setCfgSaving] = useState(false);
|
||||
const [cfgKeyword, setCfgKeyword] = useState("");
|
||||
|
||||
/* ───── FK + 카테고리 옵션 ───── */
|
||||
const [equipOptions, setEquipOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [collectionTypeOptions, setCollectionTypeOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const eqRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, { page: 1, size: 500, autoFilter: true });
|
||||
const eqs = eqRes.data?.data?.data || eqRes.data?.data?.rows || [];
|
||||
setEquipOptions(eqs.map((r: any) => ({ code: r.equipment_code, label: `${r.equipment_code} - ${r.equipment_name || ""}` })));
|
||||
} catch { /* skip */ }
|
||||
try {
|
||||
const catRes = await apiClient.get(`/table-categories/${COLLECTION_TABLE}/collection_type/values`);
|
||||
if (catRes.data?.data?.length > 0) {
|
||||
setCollectionTypeOptions(flattenCategories(catRes.data.data));
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
const fetchDatatypes = useCallback(async (keyword?: string) => {
|
||||
setDtLoading(true);
|
||||
try {
|
||||
const kw = keyword !== undefined ? keyword : dtKeyword;
|
||||
const filters: any[] = [];
|
||||
if (kw.trim()) filters.push({ columnName: "equipment_code", operator: "contains", value: kw.trim() });
|
||||
const res = await apiClient.post(`/table-management/tables/${DATATYPE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setDatatypes(rows);
|
||||
setDtCount(rows.length);
|
||||
} catch { toast.error("PLC 데이터타입 조회에 실패했어요"); }
|
||||
finally { setDtLoading(false); }
|
||||
}, [dtKeyword]);
|
||||
|
||||
const fetchConfigs = useCallback(async (keyword?: string) => {
|
||||
setCfgLoading(true);
|
||||
try {
|
||||
const kw = keyword !== undefined ? keyword : cfgKeyword;
|
||||
const filters: any[] = [];
|
||||
if (kw.trim()) filters.push({ columnName: "config_name", operator: "contains", value: kw.trim() });
|
||||
const res = await apiClient.post(`/table-management/tables/${COLLECTION_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setConfigs(rows);
|
||||
setCfgCount(rows.length);
|
||||
} catch { toast.error("수집 설정 조회에 실패했어요"); }
|
||||
finally { setCfgLoading(false); }
|
||||
}, [cfgKeyword]);
|
||||
|
||||
useEffect(() => { fetchDatatypes(); fetchConfigs(); }, []);
|
||||
|
||||
/* ═══════════════════ 데이터타입 CRUD ═══════════════════ */
|
||||
const openDtCreate = () => { setDtForm({}); setDtEditMode(false); setDtModalOpen(true); };
|
||||
const openDtEdit = (row: any) => { setDtForm({ ...row }); setDtEditMode(true); setDtModalOpen(true); };
|
||||
const saveDt = async () => {
|
||||
if (!dtForm.equipment_code) { toast.error("설비코드는 필수 입력이에요"); return; }
|
||||
setDtSaving(true);
|
||||
try {
|
||||
if (dtEditMode) {
|
||||
await apiClient.put(`/table-management/tables/${DATATYPE_TABLE}/edit`, {
|
||||
originalData: { id: dtForm.id }, updatedData: dtForm,
|
||||
});
|
||||
toast.success("PLC 데이터타입을 수정했어요");
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${DATATYPE_TABLE}/add`, dtForm);
|
||||
toast.success("PLC 데이터타입을 등록했어요");
|
||||
}
|
||||
setDtModalOpen(false);
|
||||
fetchDatatypes();
|
||||
} catch { toast.error("저장에 실패했어요"); }
|
||||
finally { setDtSaving(false); }
|
||||
};
|
||||
const deleteDt = async () => {
|
||||
if (dtChecked.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
|
||||
const ok = await confirm("PLC 데이터타입 삭제", { description: `선택한 ${dtChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.` });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${DATATYPE_TABLE}/delete`, {
|
||||
data: dtChecked.map(id => ({ id })),
|
||||
});
|
||||
toast.success(`${dtChecked.length}건을 삭제했어요`);
|
||||
setDtChecked([]);
|
||||
fetchDatatypes();
|
||||
} catch { toast.error("삭제에 실패했어요"); }
|
||||
};
|
||||
|
||||
/* ═══════════════════ 수집 설정 CRUD ═══════════════════ */
|
||||
const openCfgCreate = () => { setCfgForm({}); setCfgEditMode(false); setCfgModalOpen(true); };
|
||||
const openCfgEdit = (row: any) => { setCfgForm({ ...row }); setCfgEditMode(true); setCfgModalOpen(true); };
|
||||
const saveCfg = async () => {
|
||||
if (!cfgForm.config_name) { toast.error("설정명은 필수 입력이에요"); return; }
|
||||
setCfgSaving(true);
|
||||
try {
|
||||
if (cfgEditMode) {
|
||||
await apiClient.put(`/table-management/tables/${COLLECTION_TABLE}/edit`, {
|
||||
originalData: { id: cfgForm.id }, updatedData: cfgForm,
|
||||
});
|
||||
toast.success("수집 설정을 수정했어요");
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${COLLECTION_TABLE}/add`, cfgForm);
|
||||
toast.success("수집 설정을 등록했어요");
|
||||
}
|
||||
setCfgModalOpen(false);
|
||||
fetchConfigs();
|
||||
} catch { toast.error("저장에 실패했어요"); }
|
||||
finally { setCfgSaving(false); }
|
||||
};
|
||||
const deleteCfg = async () => {
|
||||
if (cfgChecked.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
|
||||
const ok = await confirm("수집 설정 삭제", { description: `선택한 ${cfgChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.` });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${COLLECTION_TABLE}/delete`, {
|
||||
data: cfgChecked.map(id => ({ id })),
|
||||
});
|
||||
toast.success(`${cfgChecked.length}건을 삭제했어요`);
|
||||
setCfgChecked([]);
|
||||
fetchConfigs();
|
||||
} catch { toast.error("삭제에 실패했어요"); }
|
||||
};
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<div className="border-b px-3">
|
||||
<TabsList className="bg-transparent h-auto p-0 gap-0">
|
||||
<TabsTrigger
|
||||
value="datatype"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-3 text-sm font-medium"
|
||||
>
|
||||
<Cpu className="w-4 h-4 mr-2" />
|
||||
PLC 데이터타입
|
||||
<Badge variant="secondary" className="ml-2 bg-primary/10 text-primary text-xs">{dtCount}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="collection"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-3 text-sm font-medium"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 mr-2" />
|
||||
수집 설정
|
||||
<Badge variant="secondary" className="ml-2 bg-primary/10 text-primary text-xs">{cfgCount}</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* ──── PLC 데이터타입 탭 ──── */}
|
||||
<TabsContent value="datatype" className="p-3 mt-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-8 pl-8 w-56 text-sm"
|
||||
placeholder="설비코드 검색..."
|
||||
value={dtKeyword}
|
||||
onChange={(e) => setDtKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && fetchDatatypes(dtKeyword)}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" className="h-8" onClick={() => fetchDatatypes(dtKeyword)}>
|
||||
<Search className="w-3.5 h-3.5 mr-1" />검색
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-8" onClick={() => { setDtKeyword(""); fetchDatatypes(""); }}>
|
||||
초기화
|
||||
</Button>
|
||||
<Badge variant="secondary" className="bg-primary/10 text-primary">{dtCount}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={openDtCreate}><Plus className="w-4 h-4 mr-1" />등록</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => {
|
||||
const sel = datatypes.find(r => dtChecked.includes(r.id));
|
||||
if (sel) openDtEdit(sel); else toast.error("수정할 항목을 선택해주세요");
|
||||
}}><Pencil className="w-4 h-4 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" onClick={deleteDt}><Trash2 className="w-4 h-4 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<EDataTable
|
||||
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
align: col.key === "is_active" ? "center" : undefined,
|
||||
render: col.key === "is_active"
|
||||
? (val: any) => <Badge variant={val ? "default" : "secondary"} className="text-xs">{val ? "사용" : "미사용"}</Badge>
|
||||
: undefined,
|
||||
}))}
|
||||
data={ts.groupData(datatypes)}
|
||||
loading={dtLoading}
|
||||
emptyMessage="등록된 PLC 데이터타입이 없어요"
|
||||
showCheckbox
|
||||
checkedIds={dtChecked}
|
||||
onCheckedChange={setDtChecked}
|
||||
onRowDoubleClick={(row) => openDtEdit(row)}
|
||||
showPagination={false}
|
||||
draggableColumns={false}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ──── 수집 설정 탭 ──── */}
|
||||
<TabsContent value="collection" className="p-3 mt-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-8 pl-8 w-56 text-sm"
|
||||
placeholder="설정명 검색..."
|
||||
value={cfgKeyword}
|
||||
onChange={(e) => setCfgKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && fetchConfigs(cfgKeyword)}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" className="h-8" onClick={() => fetchConfigs(cfgKeyword)}>
|
||||
<Search className="w-3.5 h-3.5 mr-1" />검색
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-8" onClick={() => { setCfgKeyword(""); fetchConfigs(""); }}>
|
||||
초기화
|
||||
</Button>
|
||||
<Badge variant="secondary" className="bg-primary/10 text-primary">{cfgCount}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={openCfgCreate}><Plus className="w-4 h-4 mr-1" />등록</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => {
|
||||
const sel = configs.find(r => cfgChecked.includes(r.id));
|
||||
if (sel) openCfgEdit(sel); else toast.error("수정할 항목을 선택해주세요");
|
||||
}}><Pencil className="w-4 h-4 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" onClick={deleteCfg}><Trash2 className="w-4 h-4 mr-1" />삭제</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<EDataTable
|
||||
columns={[
|
||||
{ key: "config_name", label: "설정명" },
|
||||
{ key: "source_connection_id", label: "소스연결ID", width: "w-[110px]" },
|
||||
{ key: "source_table", label: "소스테이블", width: "w-[120px]" },
|
||||
{ key: "target_table", label: "대상테이블", width: "w-[120px]" },
|
||||
{ key: "collection_type", label: "수집유형", width: "w-[90px]" },
|
||||
{ key: "schedule_cron", label: "스케줄(Cron)", width: "w-[120px]", render: (val: any) => <span className="font-mono text-[13px]">{val}</span> },
|
||||
{ key: "is_active", label: "사용여부", width: "w-[80px]", align: "center" as const, render: (val: any) => <Badge variant={val ? "default" : "secondary"} className="text-xs">{val ? "사용" : "미사용"}</Badge> },
|
||||
] as EDataTableColumn[]}
|
||||
data={configs}
|
||||
loading={cfgLoading}
|
||||
emptyMessage="등록된 수집 설정이 없어요"
|
||||
showCheckbox
|
||||
checkedIds={cfgChecked}
|
||||
onCheckedChange={setCfgChecked}
|
||||
onRowDoubleClick={(row) => openCfgEdit(row)}
|
||||
showPagination={false}
|
||||
draggableColumns={false}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════ PLC 데이터타입 모달 ═══════════════════ */}
|
||||
<Dialog open={dtModalOpen} onOpenChange={setDtModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dtEditMode ? "PLC 데이터타입 수정" : "PLC 데이터타입 등록"}</DialogTitle>
|
||||
<DialogDescription>PLC 데이터타입 정보를 입력해주세요</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">설비코드 <span className="text-destructive">*</span></Label>
|
||||
<Select value={dtForm.equipment_code || ""} onValueChange={(v) => setDtForm(p => ({ ...p, equipment_code: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="설비를 선택해주세요" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{equipOptions.map(o => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">데이터타입</Label>
|
||||
<Input className="h-9" value={dtForm.data_type || ""} onChange={(e) => setDtForm(p => ({ ...p, data_type: e.target.value }))} placeholder="예: 온도, 압력" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">단위</Label>
|
||||
<Input className="h-9" value={dtForm.unit || ""} onChange={(e) => setDtForm(p => ({ ...p, unit: e.target.value }))} placeholder="예: ℃, bar" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">태그주소</Label>
|
||||
<Input className="h-9" value={dtForm.tag_address || ""} onChange={(e) => setDtForm(p => ({ ...p, tag_address: e.target.value }))} placeholder="예: D100" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">수집주기</Label>
|
||||
<Input className="h-9" value={dtForm.collection_interval || ""} onChange={(e) => setDtForm(p => ({ ...p, collection_interval: e.target.value }))} placeholder="예: 1000ms" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">하한값</Label>
|
||||
<Input className="h-9" type="number" value={dtForm.lower_limit ?? ""} onChange={(e) => setDtForm(p => ({ ...p, lower_limit: e.target.value }))} placeholder="하한값" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">상한값</Label>
|
||||
<Input className="h-9" type="number" value={dtForm.upper_limit ?? ""} onChange={(e) => setDtForm(p => ({ ...p, upper_limit: e.target.value }))} placeholder="상한값" />
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox checked={dtForm.is_active ?? true} onCheckedChange={(v) => setDtForm(p => ({ ...p, is_active: !!v }))} />
|
||||
<Label className="text-sm">사용</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDtModalOpen(false)}>취소</Button>
|
||||
<Button onClick={saveDt} disabled={dtSaving}>
|
||||
{dtSaving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
|
||||
저장해요
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══════════════════ 수집 설정 모달 ═══════════════════ */}
|
||||
<Dialog open={cfgModalOpen} onOpenChange={setCfgModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{cfgEditMode ? "수집 설정 수정" : "수집 설정 등록"}</DialogTitle>
|
||||
<DialogDescription>수집 설정 정보를 입력해주세요</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">설정명 <span className="text-destructive">*</span></Label>
|
||||
<Input className="h-9" value={cfgForm.config_name || ""} onChange={(e) => setCfgForm(p => ({ ...p, config_name: e.target.value }))} placeholder="설정명을 입력해주세요" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">소스연결ID</Label>
|
||||
<Input className="h-9" value={cfgForm.source_connection_id || ""} onChange={(e) => setCfgForm(p => ({ ...p, source_connection_id: e.target.value }))} placeholder="소스연결ID" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">수집유형</Label>
|
||||
{collectionTypeOptions.length > 0 ? (
|
||||
<Select value={cfgForm.collection_type || ""} onValueChange={(v) => setCfgForm(p => ({ ...p, collection_type: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="선택해주세요" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{collectionTypeOptions.map(o => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input className="h-9" value={cfgForm.collection_type || ""} onChange={(e) => setCfgForm(p => ({ ...p, collection_type: e.target.value }))} placeholder="수집유형" />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">소스테이블</Label>
|
||||
<Input className="h-9" value={cfgForm.source_table || ""} onChange={(e) => setCfgForm(p => ({ ...p, source_table: e.target.value }))} placeholder="소스테이블명" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">대상테이블</Label>
|
||||
<Input className="h-9" value={cfgForm.target_table || ""} onChange={(e) => setCfgForm(p => ({ ...p, target_table: e.target.value }))} placeholder="대상테이블명" />
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">스케줄 (Cron)</Label>
|
||||
<Input className="h-9 font-mono text-sm" value={cfgForm.schedule_cron || ""} onChange={(e) => setCfgForm(p => ({ ...p, schedule_cron: e.target.value }))} placeholder="예: */5 * * * * (5분마다)" />
|
||||
{cfgForm.schedule_cron && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{cronToKorean(cfgForm.schedule_cron)}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox checked={cfgForm.is_active ?? true} onCheckedChange={(v) => setCfgForm(p => ({ ...p, is_active: !!v }))} />
|
||||
<Label className="text-sm">사용</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCfgModalOpen(false)}>취소</Button>
|
||||
<Button onClick={saveCfg} disabled={cfgSaving}>
|
||||
{cfgSaving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
|
||||
저장해요
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,905 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import {
|
||||
Truck,
|
||||
DollarSign,
|
||||
FileText,
|
||||
MapPin,
|
||||
Car,
|
||||
Plus,
|
||||
Trash2,
|
||||
Download,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Inbox,
|
||||
Loader2,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
|
||||
// ========== 타입 & 상수 ==========
|
||||
type TabKey = "carrier" | "cost" | "contract" | "route" | "vehicle";
|
||||
|
||||
interface TabColumnDef {
|
||||
key: string;
|
||||
label: string;
|
||||
width?: string;
|
||||
align?: "left" | "center" | "right";
|
||||
formatNumber?: boolean;
|
||||
}
|
||||
|
||||
interface FormFieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "text" | "number" | "select" | "smartselect" | "date";
|
||||
required?: boolean;
|
||||
referenceKey?: "carrier" | "route";
|
||||
categoryKey?: string;
|
||||
options?: { value: string; label: string }[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface TabConfig {
|
||||
key: TabKey;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
tableName: string;
|
||||
columns: TabColumnDef[];
|
||||
formFields: FormFieldDef[];
|
||||
defaultSortColumn: string;
|
||||
}
|
||||
|
||||
const TAB_CONFIGS: TabConfig[] = [
|
||||
{
|
||||
key: "carrier",
|
||||
label: "운송업체",
|
||||
icon: <Truck className="h-3.5 w-3.5" />,
|
||||
tableName: "carrier_mng",
|
||||
defaultSortColumn: "carrier_code",
|
||||
columns: [
|
||||
{ key: "carrier_code", label: "업체코드", width: "120px" },
|
||||
{ key: "carrier_name", label: "업체명", width: "160px" },
|
||||
{ key: "carrier_type", label: "업체유형", width: "100px" },
|
||||
{ key: "contact_person", label: "담당자", width: "100px" },
|
||||
{ key: "contact_phone", label: "연락처", width: "130px" },
|
||||
{ key: "email", label: "이메일", width: "180px" },
|
||||
{ key: "address", label: "주소", width: "220px" },
|
||||
{ key: "rating", label: "등급", width: "70px", align: "center" },
|
||||
{ key: "status", label: "상태", width: "80px", align: "center" },
|
||||
],
|
||||
formFields: [
|
||||
{ key: "carrier_code", label: "업체코드", type: "text", required: true, placeholder: "업체코드를 입력해주세요" },
|
||||
{ key: "carrier_name", label: "업체명", type: "text", required: true, placeholder: "업체명을 입력해주세요" },
|
||||
{ key: "carrier_type", label: "업체유형", type: "select", required: true, categoryKey: "carrier_mng:carrier_type", placeholder: "업체유형을 선택해주세요" },
|
||||
{ key: "contact_person", label: "담당자", type: "text", placeholder: "담당자명" },
|
||||
{ key: "contact_phone", label: "연락처", type: "text", placeholder: "예: 02-1234-5678" },
|
||||
{ key: "email", label: "이메일", type: "text", placeholder: "email@example.com" },
|
||||
{ key: "address", label: "주소", type: "text", placeholder: "주소를 입력해주세요" },
|
||||
{ key: "rating", label: "등급", type: "select", categoryKey: "carrier_mng:rating", placeholder: "등급을 선택해주세요" },
|
||||
{ key: "status", label: "상태", type: "select", categoryKey: "carrier_mng:status", placeholder: "상태를 선택해주세요" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "cost",
|
||||
label: "물류비",
|
||||
icon: <DollarSign className="h-3.5 w-3.5" />,
|
||||
tableName: "logistics_cost_mng",
|
||||
defaultSortColumn: "carrier_code",
|
||||
columns: [
|
||||
{ key: "carrier_code", label: "운송업체", width: "120px" },
|
||||
{ key: "route_code", label: "구간코드", width: "120px" },
|
||||
{ key: "base_fee", label: "기본요금", width: "110px", align: "right", formatNumber: true },
|
||||
{ key: "unit", label: "단위", width: "70px", align: "center" },
|
||||
{ key: "unit_fee", label: "단가", width: "110px", align: "right", formatNumber: true },
|
||||
{ key: "min_weight", label: "최소중량", width: "100px", align: "right", formatNumber: true },
|
||||
{ key: "max_weight", label: "최대중량", width: "100px", align: "right", formatNumber: true },
|
||||
{ key: "delivery_days", label: "배송일수", width: "80px", align: "center" },
|
||||
],
|
||||
formFields: [
|
||||
{ key: "carrier_code", label: "운송업체", type: "smartselect", required: true, referenceKey: "carrier" },
|
||||
{ key: "route_code", label: "배송구간", type: "smartselect", required: true, referenceKey: "route" },
|
||||
{ key: "base_fee", label: "기본요금", type: "number", placeholder: "0" },
|
||||
{ key: "unit", label: "단위", type: "text", placeholder: "kg, 건 등" },
|
||||
{ key: "unit_fee", label: "단가", type: "number", placeholder: "0" },
|
||||
{ key: "min_weight", label: "최소중량", type: "number", placeholder: "0" },
|
||||
{ key: "max_weight", label: "최대중량", type: "number", placeholder: "0" },
|
||||
{ key: "delivery_days", label: "배송일수", type: "number", placeholder: "0" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "contract",
|
||||
label: "계약서",
|
||||
icon: <FileText className="h-3.5 w-3.5" />,
|
||||
tableName: "carrier_contract_mng",
|
||||
defaultSortColumn: "contract_no",
|
||||
columns: [
|
||||
{ key: "contract_no", label: "계약번호", width: "130px" },
|
||||
{ key: "carrier_code", label: "운송업체", width: "120px" },
|
||||
{ key: "contract_start_date", label: "시작일", width: "110px" },
|
||||
{ key: "contract_end_date", label: "종료일", width: "110px" },
|
||||
{ key: "contract_amount", label: "계약금액", width: "130px", align: "right", formatNumber: true },
|
||||
{ key: "contact_person", label: "담당자", width: "100px" },
|
||||
{ key: "status", label: "상태", width: "80px", align: "center" },
|
||||
],
|
||||
formFields: [
|
||||
{ key: "contract_no", label: "계약번호", type: "text", required: true, placeholder: "계약번호를 입력해주세요" },
|
||||
{ key: "carrier_code", label: "운송업체", type: "smartselect", required: true, referenceKey: "carrier" },
|
||||
{ key: "contract_start_date", label: "시작일", type: "date", required: true },
|
||||
{ key: "contract_end_date", label: "종료일", type: "date", required: true },
|
||||
{ key: "contract_amount", label: "계약금액", type: "number", placeholder: "0" },
|
||||
{ key: "contact_person", label: "담당자", type: "text", placeholder: "담당자명을 입력해주세요" },
|
||||
{ key: "status", label: "상태", type: "select", categoryKey: "carrier_contract_mng:status", placeholder: "상태를 선택해주세요" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "route",
|
||||
label: "배송구간",
|
||||
icon: <MapPin className="h-3.5 w-3.5" />,
|
||||
tableName: "delivery_route_mng",
|
||||
defaultSortColumn: "route_code",
|
||||
columns: [
|
||||
{ key: "route_code", label: "구간코드", width: "120px" },
|
||||
{ key: "route_name", label: "구간명", width: "160px" },
|
||||
{ key: "departure", label: "출발지", width: "120px" },
|
||||
{ key: "destination", label: "도착지", width: "120px" },
|
||||
{ key: "distance_km", label: "거리(km)", width: "100px", align: "right", formatNumber: true },
|
||||
{ key: "avg_time_hours", label: "평균시간(h)", width: "100px", align: "right" },
|
||||
{ key: "route_type", label: "구간유형", width: "100px" },
|
||||
{ key: "status", label: "상태", width: "80px", align: "center" },
|
||||
],
|
||||
formFields: [
|
||||
{ key: "route_code", label: "구간코드", type: "text", required: true, placeholder: "구간코드를 입력해주세요" },
|
||||
{ key: "departure", label: "출발지", type: "text", required: true, placeholder: "출발지" },
|
||||
{ key: "destination", label: "도착지", type: "text", required: true, placeholder: "도착지" },
|
||||
{ key: "distance_km", label: "거리(km)", type: "number", placeholder: "0" },
|
||||
{ key: "avg_time_hours", label: "평균시간(h)", type: "number", placeholder: "0" },
|
||||
{ key: "route_type", label: "구간유형", type: "select", categoryKey: "delivery_route_mng:route_type", placeholder: "유형을 선택해주세요" },
|
||||
{ key: "status", label: "상태", type: "select", categoryKey: "delivery_route_mng:status", placeholder: "상태를 선택해주세요" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "vehicle",
|
||||
label: "차량",
|
||||
icon: <Car className="h-3.5 w-3.5" />,
|
||||
tableName: "carrier_vehicle_mng",
|
||||
defaultSortColumn: "vehicle_code",
|
||||
columns: [
|
||||
{ key: "vehicle_code", label: "차량코드", width: "120px" },
|
||||
{ key: "vehicle_number", label: "차량번호", width: "120px" },
|
||||
{ key: "vehicle_type", label: "차량유형", width: "100px" },
|
||||
{ key: "carrier_code", label: "운송업체", width: "120px" },
|
||||
{ key: "load_capacity_kg", label: "적재용량(kg)", width: "120px", align: "right", formatNumber: true },
|
||||
{ key: "driver_name", label: "운전자", width: "100px" },
|
||||
{ key: "last_maintenance_date", label: "최종정비일", width: "110px" },
|
||||
{ key: "status", label: "상태", width: "80px", align: "center" },
|
||||
],
|
||||
formFields: [
|
||||
{ key: "vehicle_code", label: "차량코드", type: "text", required: true, placeholder: "차량코드를 입력해주세요" },
|
||||
{ key: "vehicle_number", label: "차량번호", type: "text", required: true, placeholder: "12가 3456" },
|
||||
{ key: "vehicle_type", label: "차량유형", type: "select", required: true, categoryKey: "carrier_vehicle_mng:vehicle_type", placeholder: "차량유형을 선택해주세요" },
|
||||
{ key: "carrier_code", label: "운송업체", type: "smartselect", required: true, referenceKey: "carrier" },
|
||||
{ key: "load_capacity_kg", label: "적재용량(kg)", type: "number", placeholder: "0" },
|
||||
{ key: "driver_name", label: "운전자", type: "text", placeholder: "운전자명" },
|
||||
{ key: "last_maintenance_date", label: "최종정비일", type: "date" },
|
||||
{ key: "status", label: "상태", type: "select", categoryKey: "carrier_vehicle_mng:status", placeholder: "상태를 선택해주세요" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 카테고리 계층 평탄화
|
||||
function flattenCategories(items: any[]): { value: string; label: string }[] {
|
||||
const result: { value: string; label: string }[] = [];
|
||||
function walk(arr: any[]) {
|
||||
for (const item of arr) {
|
||||
if (item.value || item.name) {
|
||||
result.push({
|
||||
value: item.value || item.name,
|
||||
label: item.label || item.name || item.value,
|
||||
});
|
||||
}
|
||||
if (item.children?.length) walk(item.children);
|
||||
}
|
||||
}
|
||||
walk(items);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 채번 대상 필드 매핑: tableName → 코드 필드 key
|
||||
const NUMBERING_FIELD_MAP: Record<string, string> = {
|
||||
carrier_mng: "carrier_code",
|
||||
delivery_route_mng: "route_code",
|
||||
carrier_vehicle_mng: "vehicle_code",
|
||||
};
|
||||
|
||||
// ========== 메인 컴포넌트 ==========
|
||||
export default function LogisticsInfoPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
// 탭 상태
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("carrier");
|
||||
|
||||
// 검색 필터
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 탭별 독립 상태
|
||||
const [tabData, setTabData] = useState<Record<TabKey, any[]>>({
|
||||
carrier: [], cost: [], contract: [], route: [], vehicle: [],
|
||||
});
|
||||
const [tabLoading, setTabLoading] = useState<Record<TabKey, boolean>>({
|
||||
carrier: false, cost: false, contract: false, route: false, vehicle: false,
|
||||
});
|
||||
const [tabChecked, setTabChecked] = useState<Record<TabKey, string[]>>({
|
||||
carrier: [], cost: [], contract: [], route: [], vehicle: [],
|
||||
});
|
||||
|
||||
// FK 참조 데이터 (캐싱)
|
||||
const [carrierOptions, setCarrierOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [routeOptions, setRouteOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
// 카테고리 옵션 캐시
|
||||
const [categoryOptions, setCategoryOptions] = useState<Record<string, { value: string; label: string }[]>>({});
|
||||
const loadedCategories = useRef(new Set<string>());
|
||||
|
||||
// 모달 상태
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
|
||||
// 채번 시스템
|
||||
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
|
||||
const [previewCode, setPreviewCode] = useState<string | null>(null);
|
||||
|
||||
// 테이블 설정 (탭별)
|
||||
const tsCarrier = useTableSettings("c16-logistics-carrier", TAB_CONFIGS[0].tableName, TAB_CONFIGS[0].columns);
|
||||
const tsCost = useTableSettings("c16-logistics-cost", TAB_CONFIGS[1].tableName, TAB_CONFIGS[1].columns);
|
||||
const tsContract = useTableSettings("c16-logistics-contract", TAB_CONFIGS[2].tableName, TAB_CONFIGS[2].columns);
|
||||
const tsRoute = useTableSettings("c16-logistics-route", TAB_CONFIGS[3].tableName, TAB_CONFIGS[3].columns);
|
||||
const tsVehicle = useTableSettings("c16-logistics-vehicle", TAB_CONFIGS[4].tableName, TAB_CONFIGS[4].columns);
|
||||
const tsMap: Record<TabKey, typeof tsCarrier> = { carrier: tsCarrier, cost: tsCost, contract: tsContract, route: tsRoute, vehicle: tsVehicle };
|
||||
const activeTs = tsMap[activeTab];
|
||||
|
||||
const activeConfig = useMemo(
|
||||
() => TAB_CONFIGS.find((c) => c.key === activeTab)!,
|
||||
[activeTab]
|
||||
);
|
||||
|
||||
// 컬럼 가시성 헬퍼
|
||||
const getVisibleColumns = (tabKey: TabKey) => tsMap[tabKey].visibleColumns;
|
||||
|
||||
// 클라이언트 사이드 필터링
|
||||
const filteredData = useMemo(() => {
|
||||
const data = tabData[activeTab];
|
||||
if (searchFilters.length === 0) return data;
|
||||
return data.filter((row) =>
|
||||
searchFilters.every((f) => {
|
||||
if (!f.value) return true;
|
||||
const kw = f.value.toLowerCase();
|
||||
if (f.columnName) {
|
||||
return String(row[f.columnName] ?? "").toLowerCase().includes(kw);
|
||||
}
|
||||
return Object.values(row).some((v) => String(v ?? "").toLowerCase().includes(kw));
|
||||
})
|
||||
);
|
||||
}, [tabData, activeTab, searchFilters]);
|
||||
|
||||
// FK 참조 데이터 로드
|
||||
const loadReferences = useCallback(async () => {
|
||||
try {
|
||||
const [carrierRes, routeRes] = await Promise.all([
|
||||
apiClient.post("/table-management/tables/carrier_mng/data", {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
sort: { columnName: "carrier_code", order: "asc" },
|
||||
}),
|
||||
apiClient.post("/table-management/tables/delivery_route_mng/data", {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
sort: { columnName: "route_code", order: "asc" },
|
||||
}),
|
||||
]);
|
||||
const carriers = carrierRes.data?.data?.data || carrierRes.data?.data?.rows || [];
|
||||
setCarrierOptions(
|
||||
carriers.map((r: any) => ({
|
||||
code: r.carrier_code || "",
|
||||
label: `${r.carrier_code} - ${r.carrier_name || ""}`,
|
||||
}))
|
||||
);
|
||||
const routes = routeRes.data?.data?.data || routeRes.data?.data?.rows || [];
|
||||
setRouteOptions(
|
||||
routes.map((r: any) => ({
|
||||
code: r.route_code || "",
|
||||
label: `${r.route_code} - ${r.route_name || ""}`,
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
// FK 참조 로드 실패 시 무시
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadReferences();
|
||||
}, [loadReferences]);
|
||||
|
||||
// 카테고리 옵션 로드
|
||||
const loadCategoryOptions = useCallback(async (tableColumn: string) => {
|
||||
if (loadedCategories.current.has(tableColumn)) return;
|
||||
loadedCategories.current.add(tableColumn);
|
||||
const [tableName, columnName] = tableColumn.split(":");
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
||||
const data = res.data?.data || [];
|
||||
setCategoryOptions((prev) => ({
|
||||
...prev,
|
||||
[tableColumn]: data.length > 0 ? flattenCategories(data) : [],
|
||||
}));
|
||||
} catch {
|
||||
setCategoryOptions((prev) => ({ ...prev, [tableColumn]: [] }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 활성 탭의 카테고리 로드
|
||||
useEffect(() => {
|
||||
const config = TAB_CONFIGS.find((c) => c.key === activeTab);
|
||||
if (!config) return;
|
||||
config.formFields.forEach((f) => {
|
||||
if (f.categoryKey) loadCategoryOptions(f.categoryKey);
|
||||
});
|
||||
}, [activeTab, loadCategoryOptions]);
|
||||
|
||||
// 데이터 조회
|
||||
const fetchTabData = useCallback(async (tab: TabKey) => {
|
||||
const config = TAB_CONFIGS.find((c) => c.key === tab);
|
||||
if (!config) return;
|
||||
setTabLoading((prev) => ({ ...prev, [tab]: true }));
|
||||
try {
|
||||
const res = await apiClient.post(
|
||||
`/table-management/tables/${config.tableName}/data`,
|
||||
{
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
sort: { columnName: config.defaultSortColumn, order: "asc" },
|
||||
}
|
||||
);
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setTabData((prev) => ({ ...prev, [tab]: rows }));
|
||||
} catch {
|
||||
toast.error("데이터를 불러오는 데 실패했어요.");
|
||||
setTabData((prev) => ({ ...prev, [tab]: [] }));
|
||||
} finally {
|
||||
setTabLoading((prev) => ({ ...prev, [tab]: false }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 초기 데이터 로드 (탭 전환 시)
|
||||
useEffect(() => {
|
||||
fetchTabData(activeTab);
|
||||
}, [activeTab, fetchTabData]);
|
||||
|
||||
// 탭 변경
|
||||
const handleTabChange = useCallback((tab: string) => {
|
||||
setActiveTab(tab as TabKey);
|
||||
setSearchFilters([]);
|
||||
}, []);
|
||||
|
||||
// 등록 모달 열기
|
||||
const handleOpenAdd = useCallback(async () => {
|
||||
setEditMode(false);
|
||||
setEditId(null);
|
||||
setFormData({});
|
||||
setPreviewCode(null);
|
||||
setNumberingRuleId(null);
|
||||
setFormOpen(true);
|
||||
|
||||
// 현재 탭의 채번 규칙 조회
|
||||
const config = TAB_CONFIGS.find((c) => c.key === activeTab);
|
||||
if (!config) return;
|
||||
const codeField = NUMBERING_FIELD_MAP[config.tableName];
|
||||
if (!codeField) return; // 채번 대상이 아닌 탭
|
||||
|
||||
try {
|
||||
const ruleRes = await apiClient.get(
|
||||
`/numbering-rules/by-column/${config.tableName}/${codeField}`
|
||||
);
|
||||
const ruleData = ruleRes.data;
|
||||
if (ruleData?.success && ruleData?.data?.ruleId) {
|
||||
const ruleId = ruleData.data.ruleId;
|
||||
setNumberingRuleId(ruleId);
|
||||
const previewRes = await previewNumberingCode(ruleId);
|
||||
if (previewRes.success && previewRes.data?.generatedCode) {
|
||||
setPreviewCode(previewRes.data.generatedCode);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 채번 규칙 없으면 무시 — 사용자가 직접 입력
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
// 수정 모달 열기
|
||||
const handleOpenEdit = useCallback((row: any) => {
|
||||
setEditMode(true);
|
||||
setEditId(row.id ? String(row.id) : null);
|
||||
setFormData({ ...row });
|
||||
setFormOpen(true);
|
||||
}, []);
|
||||
|
||||
// 저장
|
||||
const handleSave = useCallback(async () => {
|
||||
const config = TAB_CONFIGS.find((c) => c.key === activeTab);
|
||||
if (!config) return;
|
||||
|
||||
// 필수값 검증 (등록 모드에서 채번 대상 코드 필드는 자동 할당이므로 스킵)
|
||||
const numberingCodeField = NUMBERING_FIELD_MAP[config.tableName];
|
||||
for (const field of config.formFields) {
|
||||
if (!editMode && numberingRuleId && field.key === numberingCodeField) continue;
|
||||
if (field.required && !formData[field.key]?.toString().trim()) {
|
||||
toast.error(`${field.label}은(는) 필수 입력이에요.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 배송구간: 출발지→도착지 로 구간명 자동 생성
|
||||
const saveData = { ...formData };
|
||||
if (activeTab === "route" && saveData.departure && saveData.destination) {
|
||||
saveData.route_name = `${saveData.departure}→${saveData.destination}`;
|
||||
}
|
||||
|
||||
if (editMode && editId) {
|
||||
await apiClient.put(`/table-management/tables/${config.tableName}/edit`, {
|
||||
originalData: { id: editId },
|
||||
updatedData: saveData,
|
||||
});
|
||||
toast.success("수정이 완료되었어요.");
|
||||
} else {
|
||||
// 채번 규칙이 있으면 allocate로 실제 코드 할당
|
||||
const codeField = NUMBERING_FIELD_MAP[config.tableName];
|
||||
if (codeField && numberingRuleId) {
|
||||
const allocRes = await allocateNumberingCode(numberingRuleId);
|
||||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||||
saveData[codeField] = allocRes.data.generatedCode;
|
||||
} else {
|
||||
toast.error("채번 코드 할당에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await apiClient.post(
|
||||
`/table-management/tables/${config.tableName}/add`,
|
||||
{ id: crypto.randomUUID(), ...saveData }
|
||||
);
|
||||
toast.success("등록이 완료되었어요.");
|
||||
}
|
||||
setFormOpen(false);
|
||||
fetchTabData(activeTab);
|
||||
// FK 참조 테이블 변경 시 캐시 갱신
|
||||
if (activeTab === "carrier" || activeTab === "route") {
|
||||
loadReferences();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || "저장에 실패했어요.");
|
||||
}
|
||||
}, [activeTab, editMode, editId, formData, fetchTabData, loadReferences, numberingRuleId]);
|
||||
|
||||
// 삭제
|
||||
const handleDelete = useCallback(async () => {
|
||||
const config = TAB_CONFIGS.find((c) => c.key === activeTab);
|
||||
if (!config) return;
|
||||
const ids = tabChecked[activeTab];
|
||||
if (ids.length === 0) {
|
||||
toast.error("삭제할 항목을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
const ok = await confirm(`선택한 ${ids.length}건을 삭제할까요?`, {
|
||||
description: "삭제된 데이터는 복구할 수 없어요.",
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(
|
||||
`/table-management/tables/${config.tableName}/delete`,
|
||||
{ data: ids.map((id) => ({ id })) }
|
||||
);
|
||||
toast.success(`${ids.length}건이 삭제되었어요.`);
|
||||
setTabChecked((prev) => ({ ...prev, [activeTab]: [] }));
|
||||
fetchTabData(activeTab);
|
||||
if (activeTab === "carrier" || activeTab === "route") {
|
||||
loadReferences();
|
||||
}
|
||||
} catch {
|
||||
toast.error("삭제에 실패했어요.");
|
||||
}
|
||||
}, [activeTab, tabChecked, confirm, fetchTabData, loadReferences]);
|
||||
|
||||
// 엑셀 다운로드 (필터된 데이터 기준)
|
||||
const handleExcelDownload = useCallback(async () => {
|
||||
const config = TAB_CONFIGS.find((c) => c.key === activeTab);
|
||||
if (!config) return;
|
||||
if (filteredData.length === 0) {
|
||||
toast.error("다운로드할 데이터가 없어요.");
|
||||
return;
|
||||
}
|
||||
const exportData = filteredData.map((row) => {
|
||||
const obj: Record<string, any> = {};
|
||||
config.columns.forEach((col) => {
|
||||
obj[col.label] = row[col.key] ?? "";
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
await exportToExcel(exportData, `${config.label}.xlsx`, config.label);
|
||||
toast.success("엑셀 다운로드가 완료되었어요.");
|
||||
}, [activeTab, filteredData]);
|
||||
|
||||
// 폼 필드 변경
|
||||
const updateFormField = useCallback((key: string, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
// 행 체크 토글
|
||||
const toggleRowCheck = useCallback((tabKey: TabKey, rowId: string) => {
|
||||
setTabChecked((prev) => {
|
||||
const ids = prev[tabKey];
|
||||
return {
|
||||
...prev,
|
||||
[tabKey]: ids.includes(rowId) ? ids.filter((x) => x !== rowId) : [...ids, rowId],
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 전체 체크 토글
|
||||
const toggleAllCheck = useCallback((tabKey: TabKey, checked: boolean) => {
|
||||
setTabChecked((prev) => ({
|
||||
...prev,
|
||||
[tabKey]: checked ? tabData[tabKey].map((r: any) => String(r.id)) : [],
|
||||
}));
|
||||
}, [tabData]);
|
||||
|
||||
// 폼 필드 렌더
|
||||
const renderFormField = useCallback(
|
||||
(field: FormFieldDef) => {
|
||||
const value = formData[field.key] ?? "";
|
||||
// 현재 탭의 채번 대상 코드 필드인지 확인
|
||||
const numberingCodeField = NUMBERING_FIELD_MAP[activeConfig.tableName];
|
||||
const isNumberingTarget = !editMode && numberingRuleId && field.key === numberingCodeField;
|
||||
// 수정 모드에서 코드/번호 필드는 읽기전용
|
||||
const isCodeField =
|
||||
editMode &&
|
||||
field.type === "text" &&
|
||||
(field.key.endsWith("_code") || field.key.endsWith("_no"));
|
||||
|
||||
switch (field.type) {
|
||||
case "text":
|
||||
// 등록 모드 + 채번 대상 필드: readOnly로 미리보기 코드 표시
|
||||
if (isNumberingTarget) {
|
||||
return (
|
||||
<Input
|
||||
value={previewCode || ""}
|
||||
readOnly
|
||||
placeholder="채번 조회 중..."
|
||||
className="h-9 text-sm bg-muted text-muted-foreground"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => updateFormField(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
readOnly={isCodeField}
|
||||
className={cn(
|
||||
"h-9 text-sm",
|
||||
isCodeField && "bg-muted text-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
case "number":
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => updateFormField(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
);
|
||||
case "select": {
|
||||
const opts =
|
||||
field.options ||
|
||||
(field.categoryKey ? categoryOptions[field.categoryKey] : []) ||
|
||||
[];
|
||||
return (
|
||||
<Select
|
||||
value={String(value)}
|
||||
onValueChange={(v) => updateFormField(field.key, v)}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder={field.placeholder || "선택해주세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{opts.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
case "smartselect": {
|
||||
// SmartSelect 대신 Select로 직접 구현
|
||||
const opts =
|
||||
field.referenceKey === "carrier" ? carrierOptions : routeOptions;
|
||||
return (
|
||||
<Select
|
||||
value={String(value)}
|
||||
onValueChange={(v) => updateFormField(field.key, v)}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder={field.placeholder || "선택해주세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{opts.map((o) => (
|
||||
<SelectItem key={o.code} value={o.code}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
case "date":
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
value={value ? String(value).split("T")[0] : ""}
|
||||
onChange={(e) => updateFormField(field.key, e.target.value)}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[formData, editMode, carrierOptions, routeOptions, categoryOptions, updateFormField, activeConfig, numberingRuleId, previewCode]
|
||||
);
|
||||
|
||||
// ========== 렌더링 ==========
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col gap-3 p-3">
|
||||
{/* 검색 필터 바 */}
|
||||
<DynamicSearchFilter
|
||||
tableName={activeConfig.tableName}
|
||||
filterId="c16-logistics-info"
|
||||
onFilterChange={setSearchFilters}
|
||||
externalFilterConfig={activeTs.filterConfig}
|
||||
dataCount={filteredData.length}
|
||||
/>
|
||||
|
||||
{/* 탭 + 콘텐츠 영역 */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border bg-card"
|
||||
>
|
||||
<TabsList className="h-auto w-full shrink-0 justify-start gap-0 rounded-none border-b bg-muted/30 p-0">
|
||||
{TAB_CONFIGS.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.key}
|
||||
value={tab.key}
|
||||
className="flex items-center gap-1.5 rounded-none border-b-2 border-transparent px-4 py-2.5 text-sm font-medium text-muted-foreground data-[state=active]:border-primary data-[state=active]:font-semibold data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-1 h-5 min-w-[22px] justify-center px-1.5 font-mono text-[10px]"
|
||||
>
|
||||
{tabData[tab.key]?.length || 0}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{TAB_CONFIGS.map((tab) => {
|
||||
const displayData = tab.key === activeTab ? filteredData : tabData[tab.key];
|
||||
const isAllChecked =
|
||||
tabData[tab.key].length > 0 &&
|
||||
tabData[tab.key].every((r: any) => tabChecked[tab.key].includes(String(r.id)));
|
||||
|
||||
return (
|
||||
<TabsContent
|
||||
key={tab.key}
|
||||
value={tab.key}
|
||||
className="m-0 flex flex-1 flex-col overflow-hidden data-[state=inactive]:hidden"
|
||||
>
|
||||
{/* 액션 바 */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-bold">{tab.label} 목록</h2>
|
||||
<Badge className="bg-primary/10 font-mono text-[11px] text-primary">
|
||||
{tab.key === activeTab ? displayData.length : tabData[tab.key]?.length || 0}건
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" className="h-8 text-xs" onClick={handleOpenAdd}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
등록
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
disabled={tabChecked[tab.key].length !== 1}
|
||||
onClick={() => {
|
||||
const row = tabData[tab.key].find(
|
||||
(r: any) => String(r.id) === tabChecked[tab.key][0]
|
||||
);
|
||||
if (row) handleOpenEdit(row);
|
||||
}}
|
||||
>
|
||||
<Pencil className="mr-1 h-3.5 w-3.5" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs text-destructive hover:bg-destructive/10"
|
||||
disabled={tabChecked[tab.key].length === 0}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
삭제
|
||||
</Button>
|
||||
<div className="mx-1 h-5 w-px bg-border" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={handleExcelDownload}
|
||||
>
|
||||
<Download className="mr-1 h-3.5 w-3.5" />
|
||||
엑셀
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => fetchTabData(tab.key)}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
tabLoading[tab.key] && "animate-spin"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => activeTs.setOpen(true)}
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 영역 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<EDataTable
|
||||
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
align: col.align,
|
||||
formatNumber: col.formatNumber,
|
||||
truncate: true,
|
||||
}))}
|
||||
data={tsMap[tab.key].groupData(displayData)}
|
||||
rowKey={(row: any) => String(row.id)}
|
||||
loading={tabLoading[tab.key]}
|
||||
emptyMessage={`등록된 ${tab.label} 정보가 없어요`}
|
||||
showCheckbox
|
||||
checkedIds={tabChecked[tab.key]}
|
||||
onCheckedChange={(ids) => setTabChecked((prev) => ({ ...prev, [tab.key]: ids }))}
|
||||
onRowDoubleClick={(row) => handleOpenEdit(row)}
|
||||
showPagination={false}
|
||||
draggableColumns={false}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
||||
<DialogContent className="flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[680px]">
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4">
|
||||
<DialogTitle>
|
||||
{activeConfig.label} {editMode ? "수정" : "등록"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editMode
|
||||
? `${activeConfig.label} 정보를 수정해주세요.`
|
||||
: `새 ${activeConfig.label} 정보를 입력해주세요.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{activeConfig.formFields.map((field) => (
|
||||
<div key={field.key} className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">
|
||||
{field.label}
|
||||
{field.required && (
|
||||
<span className="ml-0.5 text-destructive">*</span>
|
||||
)}
|
||||
</Label>
|
||||
{renderFormField(field)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 border-t">
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-3">
|
||||
<Button variant="outline" onClick={() => setFormOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
{editMode ? "수정" : "등록"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 테이블 설정 모달 */}
|
||||
<TableSettingsModal
|
||||
open={activeTs.open}
|
||||
onOpenChange={activeTs.setOpen}
|
||||
tableName={activeTs.tableName}
|
||||
settingsId={activeTs.settingsId}
|
||||
defaultVisibleKeys={activeTs.defaultVisibleKeys}
|
||||
onSave={activeTs.applySettings}
|
||||
/>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,738 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 재고현황 — 하드코딩 페이지 (Type B 마스터-디테일)
|
||||
*
|
||||
* 좌측: 재고 목록 (inventory_stock, item_info JOIN)
|
||||
* 우측: 선택 품목의 재고 이동 이력 (inventory_history)
|
||||
*
|
||||
* ★ 재고 직접 등록/삭제 불가 — 입출고를 통해서만 변동, 조정만 가능
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import {
|
||||
Package,
|
||||
Loader2,
|
||||
Download,
|
||||
ClipboardEdit,
|
||||
History,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import { toast } from "sonner";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
|
||||
const STOCK_TABLE = "inventory_stock";
|
||||
|
||||
const STOCK_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "warehouse_code", label: "창고" },
|
||||
{ key: "location_code", label: "위치" },
|
||||
{ key: "current_qty", label: "현재수량", align: "right" as const },
|
||||
{ key: "safety_qty", label: "안전재고", align: "right" as const },
|
||||
{ key: "unit", label: "단위" },
|
||||
{ key: "status", label: "상태" },
|
||||
];
|
||||
const HISTORY_TABLE = "inventory_history";
|
||||
|
||||
const getStatusVariant = (
|
||||
status: string
|
||||
): "default" | "secondary" | "outline" | "destructive" => {
|
||||
switch (status) {
|
||||
case "정상":
|
||||
return "default";
|
||||
case "부족":
|
||||
return "destructive";
|
||||
case "과잉":
|
||||
return "secondary";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
const getHistoryTypeVariant = (
|
||||
type: string
|
||||
): "default" | "secondary" | "outline" | "destructive" => {
|
||||
switch (type) {
|
||||
case "입고":
|
||||
return "default";
|
||||
case "출고":
|
||||
return "secondary";
|
||||
case "조정":
|
||||
return "outline";
|
||||
case "입고취소":
|
||||
case "이동":
|
||||
return "destructive";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
export default function InventoryStatusPage() {
|
||||
const { user } = useAuth();
|
||||
const ts = useTableSettings("c16-inventory", STOCK_TABLE, STOCK_COLUMNS);
|
||||
|
||||
// 좌측: 재고 목록
|
||||
const [stockItems, setStockItems] = useState<any[]>([]);
|
||||
const [stockLoading, setStockLoading] = useState(false);
|
||||
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
|
||||
|
||||
// 검색 필터
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 우측: 이동 이력
|
||||
const [historyItems, setHistoryItems] = useState<any[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
|
||||
// 조정 모달
|
||||
const [adjustModalOpen, setAdjustModalOpen] = useState(false);
|
||||
const [adjustForm, setAdjustForm] = useState<{
|
||||
adjust_type: string;
|
||||
adjust_qty: string;
|
||||
reason: string;
|
||||
}>({ adjust_type: "증가", adjust_qty: "", reason: "" });
|
||||
const [adjustSaving, setAdjustSaving] = useState(false);
|
||||
|
||||
// 카테고리 옵션
|
||||
const [categoryOptions, setCategoryOptions] = useState<
|
||||
Record<string, { code: string; label: string }[]>
|
||||
>({});
|
||||
|
||||
// 카테고리 로드
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
const flatten = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode, label: v.valueLabel });
|
||||
if (v.children?.length) result.push(...flatten(v.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
for (const col of ["status", "unit"]) {
|
||||
try {
|
||||
const res = await apiClient.get(
|
||||
`/table-categories/${STOCK_TABLE}/${col}/values`
|
||||
);
|
||||
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
setCategoryOptions(optMap);
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
// 재고 목록 조회
|
||||
const fetchStock = useCallback(async () => {
|
||||
setStockLoading(true);
|
||||
try {
|
||||
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||
const [stockRes, itemRes, whRes] = await Promise.all([
|
||||
apiClient.post(`/table-management/tables/${STOCK_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
sort: { columnName: "item_code", order: "asc" },
|
||||
}),
|
||||
apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 500, autoFilter: true }),
|
||||
apiClient.post(`/table-management/tables/warehouse_info/data`, { page: 1, size: 500, autoFilter: true }),
|
||||
]);
|
||||
const raw = stockRes.data?.data?.data || stockRes.data?.data?.rows || [];
|
||||
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||
const warehouses = whRes.data?.data?.data || whRes.data?.data?.rows || [];
|
||||
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.unit || "" }]));
|
||||
const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code]));
|
||||
const resolve = (col: string, code: string) => {
|
||||
if (!code) return "";
|
||||
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
const data = raw.map((r: any) => {
|
||||
const itemInfo = itemMap.get(r.item_code) as any;
|
||||
return {
|
||||
...r,
|
||||
item_name: itemInfo?.name || "",
|
||||
unit: itemInfo?.unit || resolve("unit", r.unit),
|
||||
warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "",
|
||||
status: resolve("status", r.status),
|
||||
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
|
||||
};
|
||||
});
|
||||
setStockItems(data);
|
||||
} catch {
|
||||
toast.error("재고 목록을 불러오지 못했어요");
|
||||
} finally {
|
||||
setStockLoading(false);
|
||||
}
|
||||
}, [categoryOptions, searchFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStock();
|
||||
}, [fetchStock]);
|
||||
|
||||
// 선택된 재고
|
||||
const selectedStock = stockItems.find((s) => s.id === selectedStockId);
|
||||
|
||||
// 이력 조회
|
||||
const fetchHistory = useCallback(async () => {
|
||||
if (!selectedStock?.item_code) {
|
||||
setHistoryItems([]);
|
||||
return;
|
||||
}
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const historyFilters: any[] = [
|
||||
{
|
||||
columnName: "item_code",
|
||||
operator: "equals",
|
||||
value: selectedStock.item_code,
|
||||
},
|
||||
];
|
||||
if (selectedStock.warehouse_code) {
|
||||
historyFilters.push({
|
||||
columnName: "warehouse_code",
|
||||
operator: "equals",
|
||||
value: selectedStock.warehouse_code,
|
||||
});
|
||||
}
|
||||
const res = await apiClient.post(
|
||||
`/table-management/tables/${HISTORY_TABLE}/data`,
|
||||
{
|
||||
page: 1,
|
||||
size: 500,
|
||||
dataFilter: { enabled: true, filters: historyFilters },
|
||||
autoFilter: true,
|
||||
sort: { columnName: "transaction_date", order: "desc" },
|
||||
}
|
||||
);
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setHistoryItems(raw);
|
||||
} catch {
|
||||
toast.error("재고 이력을 불러오지 못했어요");
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
}, [selectedStock?.item_code, selectedStock?.warehouse_code]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistory();
|
||||
}, [fetchHistory]);
|
||||
|
||||
// 재고 조정 저장
|
||||
const handleAdjustSave = async () => {
|
||||
if (!selectedStock) return;
|
||||
const qty = Number(adjustForm.adjust_qty);
|
||||
if (!qty || qty <= 0) {
|
||||
toast.error("조정 수량을 입력해주세요");
|
||||
return;
|
||||
}
|
||||
if (!adjustForm.reason.trim()) {
|
||||
toast.error("조정 사유를 입력해주세요");
|
||||
return;
|
||||
}
|
||||
setAdjustSaving(true);
|
||||
try {
|
||||
const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty;
|
||||
const afterQty = Number(selectedStock.current_qty || 0) + changeQty;
|
||||
|
||||
await apiClient.post(
|
||||
`/table-management/tables/${HISTORY_TABLE}/add`,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
item_code: selectedStock.item_code,
|
||||
warehouse_code: selectedStock.warehouse_code || "",
|
||||
location_code: selectedStock.location_code || "",
|
||||
transaction_type: "조정",
|
||||
transaction_date: new Date().toISOString(),
|
||||
quantity: String(changeQty),
|
||||
balance_qty: String(afterQty),
|
||||
remark: adjustForm.reason.trim(),
|
||||
}
|
||||
);
|
||||
|
||||
await apiClient.put(
|
||||
`/table-management/tables/${STOCK_TABLE}/edit`,
|
||||
{
|
||||
originalData: { id: selectedStock.id },
|
||||
updatedData: { current_qty: afterQty },
|
||||
}
|
||||
);
|
||||
|
||||
toast.success("재고가 조정되었어요");
|
||||
setAdjustModalOpen(false);
|
||||
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" });
|
||||
fetchStock();
|
||||
} catch {
|
||||
toast.error("재고 조정에 실패했어요");
|
||||
} finally {
|
||||
setAdjustSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// EDataTable 컬럼 정의
|
||||
const stockColumns: EDataTableColumn[] = ts.visibleColumns.map((col) => {
|
||||
const base: EDataTableColumn = { key: col.key, label: col.label, align: col.align };
|
||||
if (col.key === "current_qty") {
|
||||
return {
|
||||
...base,
|
||||
align: "right" as const,
|
||||
render: (val: any, row: any) => (
|
||||
<span className="font-mono">
|
||||
<span className={cn(row._isLow && "text-destructive font-bold")}>
|
||||
{Number(row.current_qty || 0).toLocaleString()}
|
||||
</span>
|
||||
{row._isLow && (
|
||||
<AlertTriangle className="inline h-3 w-3 text-destructive ml-1" />
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
};
|
||||
}
|
||||
if (col.key === "safety_qty") {
|
||||
return {
|
||||
...base,
|
||||
align: "right" as const,
|
||||
formatNumber: true,
|
||||
};
|
||||
}
|
||||
if (col.key === "status") {
|
||||
return {
|
||||
...base,
|
||||
render: (val: any) => (
|
||||
<Badge variant={getStatusVariant(val)} className="text-[10px]">
|
||||
{val}
|
||||
</Badge>
|
||||
),
|
||||
};
|
||||
}
|
||||
return base;
|
||||
});
|
||||
|
||||
// 엑셀 내보내기
|
||||
const handleExcelExport = () => {
|
||||
if (stockItems.length === 0) {
|
||||
toast.error("내보낼 데이터가 없어요");
|
||||
return;
|
||||
}
|
||||
exportToExcel(
|
||||
stockItems.map((r) => ({
|
||||
품목코드: r.item_code,
|
||||
품명: r.item_name,
|
||||
창고: r.warehouse_name || r.warehouse_code,
|
||||
위치: r.location_code,
|
||||
현재수량: r.current_qty,
|
||||
안전재고: r.safety_qty,
|
||||
단위: r.unit,
|
||||
상태: r.status,
|
||||
})),
|
||||
"재고현황"
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3 p-3">
|
||||
{/* 검색 바 */}
|
||||
<DynamicSearchFilter
|
||||
tableName={STOCK_TABLE}
|
||||
filterId="c16-inventory"
|
||||
onFilterChange={setSearchFilters}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
dataCount={stockItems.length}
|
||||
extraActions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1 text-xs"
|
||||
onClick={handleExcelExport}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
엑셀
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 마스터-디테일 패널 */}
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="flex-1 rounded-lg border bg-card"
|
||||
>
|
||||
{/* 좌측: 재고 목록 */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-bold">재고 목록</span>
|
||||
<Badge variant="default" className="rounded-full text-[11px]">
|
||||
{stockItems.length}건
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EDataTable
|
||||
columns={stockColumns}
|
||||
data={ts.groupData(stockItems)}
|
||||
rowKey={(row) => row.id}
|
||||
loading={stockLoading}
|
||||
emptyMessage="등록된 재고가 없어요"
|
||||
selectedId={selectedStockId}
|
||||
onSelect={(id) => setSelectedStockId(id)}
|
||||
showRowNumber
|
||||
showPagination={false}
|
||||
draggableColumns={false}
|
||||
columnOrderKey="c16-inventory"
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 우측: 상세 이력 */}
|
||||
<ResizablePanel defaultSize={45} minSize={25}>
|
||||
<div className="flex flex-col h-full">
|
||||
{!selectedStock ? (
|
||||
<div className="flex flex-col items-center justify-center flex-1 m-5 border-2 border-dashed rounded-lg border-border">
|
||||
<Package className="h-12 w-12 text-muted-foreground/40 mb-4" />
|
||||
<p className="text-sm font-semibold text-muted-foreground">
|
||||
품목을 선택해주세요
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
좌측에서 품목을 선택하면 재고 이력이 표시돼요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 패널 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-[13px] font-bold">
|
||||
{selectedStock.item_name || selectedStock.item_code}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="rounded-full text-[11px] font-mono"
|
||||
>
|
||||
{selectedStock.item_code}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">현재:</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-bold font-mono",
|
||||
selectedStock._isLow
|
||||
? "text-destructive"
|
||||
: "text-foreground"
|
||||
)}
|
||||
>
|
||||
{Number(selectedStock.current_qty || 0).toLocaleString()}
|
||||
</span>
|
||||
{selectedStock._isLow && (
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-destructive" />
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs"
|
||||
onClick={() => {
|
||||
setAdjustForm({
|
||||
adjust_type: "증가",
|
||||
adjust_qty: "",
|
||||
reason: "",
|
||||
});
|
||||
setAdjustModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<ClipboardEdit className="h-3.5 w-3.5" />
|
||||
재고 조정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 재고 요약 카드 */}
|
||||
<div className="grid grid-cols-4 gap-2 px-4 py-3 border-b">
|
||||
<div className="flex flex-col items-center p-2 rounded-md bg-muted/50">
|
||||
<span className="text-[10px] text-muted-foreground">현재수량</span>
|
||||
<span className="text-sm font-bold font-mono">
|
||||
{Number(selectedStock.current_qty || 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-2 rounded-md bg-muted/50">
|
||||
<span className="text-[10px] text-muted-foreground">안전재고</span>
|
||||
<span className="text-sm font-bold font-mono">
|
||||
{Number(selectedStock.safety_qty || 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-2 rounded-md bg-muted/50">
|
||||
<span className="text-[10px] text-muted-foreground">창고</span>
|
||||
<span className="text-sm font-bold truncate max-w-full">
|
||||
{selectedStock.warehouse_name || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-2 rounded-md bg-muted/50">
|
||||
<span className="text-[10px] text-muted-foreground">상태</span>
|
||||
<Badge
|
||||
variant={getStatusVariant(selectedStock.status)}
|
||||
className="text-[10px] mt-0.5"
|
||||
>
|
||||
{selectedStock.status || "-"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 이력 서브헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground">
|
||||
재고 이동 이력
|
||||
</span>
|
||||
<Badge variant="secondary" className="rounded-full text-[10px]">
|
||||
{historyItems.length}건
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs"
|
||||
onClick={fetchHistory}
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 이력 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{historyLoading ? (
|
||||
<div className="flex items-center justify-center h-20">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : historyItems.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-20 text-xs text-muted-foreground">
|
||||
재고 이동 이력이 없어요
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-8 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">#</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">일자</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">유형</TableHead>
|
||||
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">변동수량</TableHead>
|
||||
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">이후수량</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">참조번호</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">사유</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">처리자</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{historyItems.map((h, idx) => (
|
||||
<TableRow key={h.id || idx} className="text-xs">
|
||||
<TableCell className="text-center text-muted-foreground">
|
||||
{idx + 1}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono">
|
||||
{h.transaction_date ? String(h.transaction_date).slice(0, 10) : h.history_date || ""}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={getHistoryTypeVariant(h.transaction_type || h.history_type)}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{h.transaction_type || h.history_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
"text-right font-mono",
|
||||
Number(h.quantity ?? h.change_qty) > 0
|
||||
? "text-primary"
|
||||
: "text-destructive"
|
||||
)}
|
||||
>
|
||||
{Number(h.quantity ?? h.change_qty) > 0 ? "+" : ""}
|
||||
{Number(h.quantity ?? h.change_qty ?? 0).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{Number(h.balance_qty ?? h.after_qty ?? 0).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono truncate max-w-[120px]">
|
||||
{h.reference_number || h.reference_no || ""}
|
||||
</TableCell>
|
||||
<TableCell className="truncate max-w-[150px]">
|
||||
{h.remark || h.reason || ""}
|
||||
</TableCell>
|
||||
<TableCell>{h.writer || h.created_by || ""}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
{/* 테이블 설정 모달 */}
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
|
||||
{/* 재고 조정 Dialog */}
|
||||
<Dialog open={adjustModalOpen} onOpenChange={setAdjustModalOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>재고 조정</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedStock
|
||||
? `${selectedStock.item_name || selectedStock.item_code} — 현재 수량: ${Number(selectedStock.current_qty || 0).toLocaleString()}`
|
||||
: ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
조정 유형
|
||||
</Label>
|
||||
<Select
|
||||
value={adjustForm.adjust_type}
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="조정 유형 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="증가">증가 (입고 보정)</SelectItem>
|
||||
<SelectItem value="감소">감소 (출고 보정)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
조정 수량
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="수량을 입력해주세요"
|
||||
value={adjustForm.adjust_qty}
|
||||
onChange={(e) =>
|
||||
setAdjustForm((prev) => ({
|
||||
...prev,
|
||||
adjust_qty: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{adjustForm.adjust_qty && selectedStock && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
조정 후 수량:{" "}
|
||||
<span className="font-mono font-bold">
|
||||
{(
|
||||
Number(selectedStock.current_qty || 0) +
|
||||
(adjustForm.adjust_type === "증가" ? 1 : -1) *
|
||||
Number(adjustForm.adjust_qty || 0)
|
||||
).toLocaleString()}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
조정 사유 *
|
||||
</Label>
|
||||
<Textarea
|
||||
placeholder="조정 사유를 입력해주세요"
|
||||
rows={3}
|
||||
value={adjustForm.reason}
|
||||
onChange={(e) =>
|
||||
setAdjustForm((prev) => ({
|
||||
...prev,
|
||||
reason: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setAdjustModalOpen(false)}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleAdjustSave} disabled={adjustSaving}>
|
||||
{adjustSaving && (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-1" />
|
||||
)}
|
||||
조정하기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
// Card 제거 — rounded-lg border bg-card 패턴 사용
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Inbox,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@@ -39,6 +41,17 @@ import {
|
||||
type MaterialData,
|
||||
type WarehouseData,
|
||||
} from "@/lib/api/materialStatus";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "plan_no", label: "계획번호" },
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", label: "품목명" },
|
||||
{ key: "plan_qty", label: "수량" },
|
||||
{ key: "plan_date", label: "일자" },
|
||||
{ key: "status", label: "상태" },
|
||||
];
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
const y = date.getFullYear();
|
||||
@@ -60,16 +73,17 @@ const getStatusLabel = (status: string) => {
|
||||
|
||||
const getStatusStyle = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
planned: "bg-amber-100 text-amber-700 border-amber-200",
|
||||
pending: "bg-amber-100 text-amber-700 border-amber-200",
|
||||
in_progress: "bg-blue-100 text-blue-700 border-blue-200",
|
||||
completed: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||
cancelled: "bg-gray-100 text-gray-500 border-gray-200",
|
||||
planned: "bg-secondary text-secondary-foreground border-border",
|
||||
pending: "bg-secondary text-secondary-foreground border-border",
|
||||
in_progress: "bg-primary/10 text-primary border-primary/20",
|
||||
completed: "bg-accent text-accent-foreground border-accent/50",
|
||||
cancelled: "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
return map[status] || "bg-gray-100 text-gray-500 border-gray-200";
|
||||
return map[status] || "bg-muted text-muted-foreground border-border";
|
||||
};
|
||||
|
||||
export default function MaterialStatusPage() {
|
||||
const ts = useTableSettings("c16-material-status", "work_instruction", GRID_COLUMNS);
|
||||
const today = new Date();
|
||||
const monthAgo = new Date(today);
|
||||
monthAgo.setMonth(today.getMonth() - 1);
|
||||
@@ -193,33 +207,19 @@ export default function MaterialStatusPage() {
|
||||
}, [materials, materialSearch, showShortageOnly]);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col gap-4 bg-muted/30 p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="h-7 w-7 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">자재현황</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
작업지시 대비 원자재 재고 현황
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col gap-3 p-3">
|
||||
{/* 검색 영역 */}
|
||||
<Card className="shrink-0">
|
||||
<CardContent className="flex flex-wrap items-end gap-3 p-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">기간</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="shrink-0 flex flex-wrap items-end gap-3 rounded-lg border bg-card p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">기간</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
type="date"
|
||||
className="h-9 w-[140px]"
|
||||
value={searchDateFrom}
|
||||
onChange={(e) => setSearchDateFrom(e.target.value)}
|
||||
/>
|
||||
<span className="text-muted-foreground">~</span>
|
||||
<span className="text-muted-foreground/50 text-xs">~</span>
|
||||
<Input
|
||||
type="date"
|
||||
className="h-9 w-[140px]"
|
||||
@@ -229,8 +229,8 @@ export default function MaterialStatusPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">품목코드</Label>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">품목코드</span>
|
||||
<Input
|
||||
placeholder="품목코드"
|
||||
className="h-9 w-[140px]"
|
||||
@@ -239,8 +239,8 @@ export default function MaterialStatusPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">품목명</Label>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">품목명</span>
|
||||
<Input
|
||||
placeholder="품목명"
|
||||
className="h-9 w-[140px]"
|
||||
@@ -251,14 +251,14 @@ export default function MaterialStatusPage() {
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9"
|
||||
onClick={handleResetSearch}
|
||||
>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
<RotateCcw className="mr-1 h-4 w-4" />
|
||||
초기화
|
||||
</Button>
|
||||
<Button
|
||||
@@ -268,24 +268,23 @@ export default function MaterialStatusPage() {
|
||||
disabled={workOrdersLoading}
|
||||
>
|
||||
{workOrdersLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
<Search className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
검색
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 (좌우 분할) */}
|
||||
<div className="flex-1 overflow-hidden rounded-lg border bg-background shadow-sm">
|
||||
<div className="flex-1 overflow-hidden rounded-lg border bg-card">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
{/* 왼쪽: 작업지시 리스트 */}
|
||||
<ResizablePanel defaultSize={35} minSize={25}>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 패널 헤더 */}
|
||||
<div className="flex items-center justify-between border-b bg-muted/10 p-3 shrink-0">
|
||||
<div className="flex items-center justify-between border-b bg-muted/30 px-3 py-2.5 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={isAllChecked}
|
||||
@@ -295,9 +294,9 @@ export default function MaterialStatusPage() {
|
||||
<span className="text-sm font-semibold">작업지시 리스트</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{workOrders.length}
|
||||
</Badge>
|
||||
<span className="rounded-full border border-primary/15 bg-primary/8 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
|
||||
{workOrders.length}건
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8"
|
||||
@@ -311,6 +310,9 @@ export default function MaterialStatusPage() {
|
||||
)}
|
||||
자재조회
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -320,14 +322,14 @@ export default function MaterialStatusPage() {
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Loader2 className="mb-3 h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
작업지시를 조회하고 있습니다...
|
||||
작업지시를 조회하고 있어요...
|
||||
</p>
|
||||
</div>
|
||||
) : workOrders.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<ClipboardList className="mb-3 h-10 w-10 text-muted-foreground/30" />
|
||||
<div className="m-3 flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border py-12 text-center">
|
||||
<Inbox className="h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
작업지시가 없습니다
|
||||
조회된 작업지시가 없어요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -335,10 +337,10 @@ export default function MaterialStatusPage() {
|
||||
<div
|
||||
key={wo.id}
|
||||
className={cn(
|
||||
"flex gap-3 rounded-lg border-2 p-3 transition-all cursor-pointer",
|
||||
"hover:border-primary hover:shadow-md hover:-translate-y-0.5",
|
||||
"flex gap-3 rounded-lg border p-3 transition-all cursor-pointer",
|
||||
"hover:border-primary/50 hover:shadow-sm",
|
||||
selectedWoId === wo.id
|
||||
? "border-primary bg-primary/5 shadow-md"
|
||||
? "border-primary bg-primary/5 shadow-sm"
|
||||
: "border-border"
|
||||
)}
|
||||
onClick={() => handleSelectWo(wo.id)}
|
||||
@@ -405,21 +407,21 @@ export default function MaterialStatusPage() {
|
||||
<ResizablePanel defaultSize={65} minSize={35}>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 패널 헤더 */}
|
||||
<div className="flex items-center gap-2 border-b bg-muted/10 p-3 shrink-0">
|
||||
<div className="flex items-center gap-2 border-b bg-muted/30 px-3 py-2.5 shrink-0">
|
||||
<Factory className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">원자재 재고 현황</span>
|
||||
</div>
|
||||
|
||||
{/* 필터 */}
|
||||
<div className="flex flex-wrap items-center gap-3 border-b bg-muted/5 px-4 py-3 shrink-0">
|
||||
<div className="flex flex-wrap items-center gap-3 border-b bg-muted/30 px-4 py-2.5 shrink-0">
|
||||
<Input
|
||||
placeholder="원자재 검색"
|
||||
className="h-9 min-w-[150px] flex-1"
|
||||
className="h-8 min-w-[150px] flex-1 text-xs"
|
||||
value={materialSearch}
|
||||
onChange={(e) => setMaterialSearch(e.target.value)}
|
||||
/>
|
||||
<Select value={warehouse} onValueChange={setWarehouse}>
|
||||
<SelectTrigger className="h-9 w-[200px]">
|
||||
<SelectTrigger className="h-8 w-[180px] text-xs">
|
||||
<SelectValue placeholder="전체 창고" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -437,14 +439,14 @@ export default function MaterialStatusPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm font-medium">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium">
|
||||
<Checkbox
|
||||
checked={showShortageOnly}
|
||||
onCheckedChange={(c) => setShowShortageOnly(c as boolean)}
|
||||
/>
|
||||
<span>부족한 것만 보기</span>
|
||||
</label>
|
||||
<span className="ml-auto text-sm font-semibold text-muted-foreground">
|
||||
<span className="ml-auto rounded-full border border-primary/15 bg-primary/8 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
|
||||
{filteredMaterials.length}개 품목
|
||||
</span>
|
||||
</div>
|
||||
@@ -455,21 +457,21 @@ export default function MaterialStatusPage() {
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Loader2 className="mb-3 h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
자재현황을 조회하고 있습니다...
|
||||
자재현황을 조회하고 있어요...
|
||||
</p>
|
||||
</div>
|
||||
) : materials.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Package className="mb-3 h-10 w-10 text-muted-foreground/30" />
|
||||
<div className="m-3 flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border py-12 text-center">
|
||||
<Inbox className="h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
작업지시를 선택하고 자재조회 버튼을 클릭해주세요
|
||||
</p>
|
||||
</div>
|
||||
) : filteredMaterials.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Package className="mb-3 h-10 w-10 text-muted-foreground/30" />
|
||||
<div className="m-3 flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border py-12 text-center">
|
||||
<Package className="h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
조회된 원자재가 없습니다
|
||||
조회된 원자재가 없어요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -488,10 +490,10 @@ export default function MaterialStatusPage() {
|
||||
<div
|
||||
key={material.code}
|
||||
className={cn(
|
||||
"rounded-lg border-2 p-3 transition-all hover:shadow-md hover:-translate-y-0.5",
|
||||
"rounded-lg border p-3 transition-all hover:shadow-sm",
|
||||
isShortage
|
||||
? "border-destructive/40 bg-destructive/2"
|
||||
: "border-emerald-300/50 bg-emerald-50/20"
|
||||
? "border-destructive/30 bg-destructive/5"
|
||||
: "border-primary/15 bg-primary/5"
|
||||
)}
|
||||
>
|
||||
{/* 메인 정보 라인 */}
|
||||
@@ -508,7 +510,7 @@ export default function MaterialStatusPage() {
|
||||
<span className="text-xs text-muted-foreground">
|
||||
필요:
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-blue-600">
|
||||
<span className="text-xs font-semibold text-primary">
|
||||
{material.required.toLocaleString()}
|
||||
{material.unit}
|
||||
</span>
|
||||
@@ -540,7 +542,7 @@ export default function MaterialStatusPage() {
|
||||
"text-xs font-semibold",
|
||||
isShortage
|
||||
? "text-destructive"
|
||||
: "text-emerald-600"
|
||||
: "text-primary"
|
||||
)}
|
||||
>
|
||||
{Math.abs(shortage).toLocaleString()}
|
||||
@@ -556,7 +558,7 @@ export default function MaterialStatusPage() {
|
||||
부족
|
||||
</span>
|
||||
) : (
|
||||
<span className="ml-auto inline-flex items-center gap-1 rounded-md border border-emerald-500 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-semibold text-emerald-600">
|
||||
<span className="ml-auto inline-flex items-center gap-1 rounded-md border border-primary bg-primary/10 px-2 py-0.5 text-[11px] font-semibold text-primary">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
충분
|
||||
</span>
|
||||
@@ -592,6 +594,15 @@ export default function MaterialStatusPage() {
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,848 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 회사관리 — Type D 탭 멀티뷰 (2탭)
|
||||
*
|
||||
* Tab 1: 회사정보 (company_mng 단일 레코드 폼)
|
||||
* Tab 2: 부서관리 (dept_info 트리 + user_info 목록)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
Building2, Users, Pencil, Save, Loader2, Plus, Trash2,
|
||||
Upload, X, Image as ImageIcon, ChevronRight, FolderOpen, Folder,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import * as departmentAPI from "@/lib/api/department";
|
||||
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { formatField, validateField, validateForm } from "@/lib/utils/validation";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
|
||||
const COMPANY_TABLE = "company_mng";
|
||||
const DEPT_TABLE = "dept_info";
|
||||
const USER_TABLE = "user_info";
|
||||
|
||||
/* ── 트리 노드 타입 ── */
|
||||
interface DeptNode {
|
||||
dept_code: string;
|
||||
dept_name: string;
|
||||
parent_dept_code: string | null;
|
||||
status?: string;
|
||||
children: DeptNode[];
|
||||
}
|
||||
|
||||
export default function CompanyPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
/* ===================== Tab 1: 회사정보 ===================== */
|
||||
const [companyData, setCompanyData] = useState<Record<string, any>>({});
|
||||
const [companyForm, setCompanyForm] = useState<Record<string, any>>({});
|
||||
const [companyLoading, setCompanyLoading] = useState(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 이미지 업로드 refs
|
||||
const imageRef = useRef<HTMLInputElement>(null);
|
||||
const logoRef = useRef<HTMLInputElement>(null);
|
||||
const sealRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 이미지 미리보기
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
const [sealPreview, setSealPreview] = useState<string | null>(null);
|
||||
|
||||
const fetchCompany = useCallback(async () => {
|
||||
setCompanyLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${COMPANY_TABLE}/data`, {
|
||||
page: 1, size: 1, autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
if (rows.length > 0) {
|
||||
setCompanyData(rows[0]);
|
||||
setCompanyForm(rows[0]);
|
||||
if (rows[0].company_image) setImagePreview(rows[0].company_image);
|
||||
if (rows[0].company_logo) setLogoPreview(rows[0].company_logo);
|
||||
if (rows[0].company_seal) setSealPreview(rows[0].company_seal);
|
||||
}
|
||||
} catch {
|
||||
toast.error("회사 정보를 불러오는데 실패했어요.");
|
||||
} finally {
|
||||
setCompanyLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchCompany(); }, [fetchCompany]);
|
||||
|
||||
const handleImageUpload = (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
field: string,
|
||||
setPreview: React.Dispatch<React.SetStateAction<string | null>>,
|
||||
) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result as string;
|
||||
setPreview(result);
|
||||
setCompanyForm((prev) => ({ ...prev, [field]: result }));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleCompanySave = async () => {
|
||||
if (!companyForm.company_name) {
|
||||
toast.error("회사명은 필수예요.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const { id, created_at, updated_at, writer, created_date, updated_date, regdate, company_code, ...updatedData } = companyForm;
|
||||
|
||||
if (companyData.company_code) {
|
||||
await apiClient.put(`/table-management/tables/${COMPANY_TABLE}/edit`, {
|
||||
originalData: { company_code: companyData.company_code },
|
||||
updatedData,
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${COMPANY_TABLE}/add`, { company_code, ...updatedData });
|
||||
}
|
||||
toast.success("회사 정보가 저장되었어요.");
|
||||
setEditMode(false);
|
||||
fetchCompany();
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || "저장에 실패했어요.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setCompanyForm(companyData);
|
||||
setImagePreview(companyData.company_image || null);
|
||||
setLogoPreview(companyData.company_logo || null);
|
||||
setSealPreview(companyData.company_seal || null);
|
||||
setEditMode(false);
|
||||
};
|
||||
|
||||
/* ===================== Tab 2: 부서관리 ===================== */
|
||||
const [depts, setDepts] = useState<any[]>([]);
|
||||
const [deptTree, setDeptTree] = useState<DeptNode[]>([]);
|
||||
const [deptLoading, setDeptLoading] = useState(false);
|
||||
const [selectedDeptCode, setSelectedDeptCode] = useState<string | null>(null);
|
||||
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set());
|
||||
|
||||
// 사원
|
||||
const [members, setMembers] = useState<any[]>([]);
|
||||
const [memberLoading, setMemberLoading] = useState(false);
|
||||
|
||||
// 부서 모달
|
||||
const [deptModalOpen, setDeptModalOpen] = useState(false);
|
||||
const [deptEditMode, setDeptEditMode] = useState(false);
|
||||
const [deptForm, setDeptForm] = useState<Record<string, any>>({});
|
||||
const [deptSaving, setDeptSaving] = useState(false);
|
||||
|
||||
// 채번
|
||||
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
|
||||
const [previewCode, setPreviewCode] = useState<string | null>(null);
|
||||
|
||||
// 사원 모달
|
||||
const [userModalOpen, setUserModalOpen] = useState(false);
|
||||
const [userEditMode, setUserEditMode] = useState(false);
|
||||
const [userForm, setUserForm] = useState<Record<string, any>>({});
|
||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 트리 구성
|
||||
const buildTree = (flatDepts: any[]): DeptNode[] => {
|
||||
const map: Record<string, DeptNode> = {};
|
||||
const roots: DeptNode[] = [];
|
||||
flatDepts.forEach((d) => {
|
||||
map[d.dept_code] = { ...d, children: [] };
|
||||
});
|
||||
flatDepts.forEach((d) => {
|
||||
const node = map[d.dept_code];
|
||||
if (d.parent_dept_code && map[d.parent_dept_code]) {
|
||||
map[d.parent_dept_code].children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
});
|
||||
return roots;
|
||||
};
|
||||
|
||||
const fetchDepts = useCallback(async () => {
|
||||
setDeptLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
});
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setDepts(raw);
|
||||
setDeptTree(buildTree(raw));
|
||||
// 전부 펼치기
|
||||
setExpandedDepts(new Set(raw.map((d: any) => d.dept_code)));
|
||||
} catch {
|
||||
toast.error("부서 목록을 불러오는데 실패했어요.");
|
||||
} finally {
|
||||
setDeptLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchDepts(); }, [fetchDepts]);
|
||||
|
||||
const selectedDept = depts.find((d) => d.dept_code === selectedDeptCode);
|
||||
|
||||
// 사원 조회
|
||||
const fetchMembers = useCallback(async () => {
|
||||
if (!selectedDeptCode) { setMembers([]); return; }
|
||||
setMemberLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
setMembers(res.data?.data?.data || res.data?.data?.rows || []);
|
||||
} catch { setMembers([]); } finally { setMemberLoading(false); }
|
||||
}, [selectedDeptCode]);
|
||||
|
||||
useEffect(() => { fetchMembers(); }, [fetchMembers]);
|
||||
|
||||
// 트리 토글
|
||||
const toggleExpand = (code: string) => {
|
||||
setExpandedDepts((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(code)) next.delete(code); else next.add(code);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 부서 등록
|
||||
const openDeptRegister = async () => {
|
||||
setDeptForm({});
|
||||
setDeptEditMode(false);
|
||||
setPreviewCode(null);
|
||||
setNumberingRuleId(null);
|
||||
setDeptModalOpen(true);
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/dept_info/dept_code`);
|
||||
const ruleData = ruleRes.data;
|
||||
if (ruleData?.success && ruleData?.data?.ruleId) {
|
||||
const ruleId = ruleData.data.ruleId;
|
||||
setNumberingRuleId(ruleId);
|
||||
const previewRes = await previewNumberingCode(ruleId);
|
||||
if (previewRes.success && previewRes.data?.generatedCode) {
|
||||
setPreviewCode(previewRes.data.generatedCode);
|
||||
}
|
||||
}
|
||||
} catch { /* 채번 규칙 없으면 무시 */ }
|
||||
};
|
||||
|
||||
const openDeptEdit = () => {
|
||||
if (!selectedDept) return;
|
||||
setDeptForm({ ...selectedDept });
|
||||
setDeptEditMode(true);
|
||||
setDeptModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeptSave = async () => {
|
||||
if (!deptForm.dept_name) { toast.error("부서명은 필수예요."); return; }
|
||||
const parentCode = (deptForm.parent_dept_code && deptForm.parent_dept_code !== "none") ? deptForm.parent_dept_code : null;
|
||||
setDeptSaving(true);
|
||||
try {
|
||||
if (deptEditMode && deptForm.dept_code) {
|
||||
const response = await departmentAPI.updateDepartment(deptForm.dept_code, {
|
||||
dept_name: deptForm.dept_name,
|
||||
parent_dept_code: parentCode,
|
||||
});
|
||||
if (!response.success) { toast.error((response as any).error || "수정에 실패했어요."); return; }
|
||||
toast.success("수정되었어요.");
|
||||
} else {
|
||||
const companyCode = user?.companyCode || "";
|
||||
let allocatedCode: string | undefined;
|
||||
if (numberingRuleId) {
|
||||
const allocRes = await allocateNumberingCode(numberingRuleId);
|
||||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||||
allocatedCode = allocRes.data.generatedCode;
|
||||
} else { toast.error("채번 코드 할당에 실패했어요."); return; }
|
||||
}
|
||||
const response = await departmentAPI.createDepartment(companyCode, {
|
||||
dept_name: deptForm.dept_name,
|
||||
parent_dept_code: parentCode,
|
||||
dept_code: allocatedCode,
|
||||
});
|
||||
if (!response.success) { toast.error((response as any).error || "등록에 실패했어요."); return; }
|
||||
toast.success("등록되었어요.");
|
||||
}
|
||||
setDeptModalOpen(false);
|
||||
fetchDepts();
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || "저장에 실패했어요.");
|
||||
} finally {
|
||||
setDeptSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeptDelete = async () => {
|
||||
if (!selectedDeptCode) return;
|
||||
const ok = await confirm("부서를 삭제할까요?", {
|
||||
description: "해당 부서에 소속된 사원 정보는 유지돼요.",
|
||||
variant: "destructive", confirmText: "삭제",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
const response = await departmentAPI.deleteDepartment(selectedDeptCode);
|
||||
if (!response.success) { toast.error((response as any).error || "삭제에 실패했어요."); return; }
|
||||
toast.success((response as any).message || "삭제되었어요.");
|
||||
setSelectedDeptCode(null);
|
||||
fetchDepts();
|
||||
} catch { toast.error("삭제에 실패했어요."); }
|
||||
};
|
||||
|
||||
// 사원 추가/수정
|
||||
const openUserModal = (editData?: any) => {
|
||||
if (editData) {
|
||||
setUserEditMode(true);
|
||||
setUserForm({ ...editData, user_password: "" });
|
||||
} else {
|
||||
setUserEditMode(false);
|
||||
setUserForm({ dept_code: selectedDeptCode || "", user_password: "" });
|
||||
}
|
||||
setFormErrors({});
|
||||
setUserModalOpen(true);
|
||||
};
|
||||
|
||||
const handleUserFormChange = (field: string, value: string) => {
|
||||
const formatted = formatField(field, value);
|
||||
setUserForm((prev) => ({ ...prev, [field]: formatted }));
|
||||
const error = validateField(field, formatted);
|
||||
setFormErrors((prev) => { const n = { ...prev }; if (error) n[field] = error; else delete n[field]; return n; });
|
||||
};
|
||||
|
||||
const handleUserSave = async () => {
|
||||
if (!userForm.user_id) { toast.error("사용자 ID는 필수예요."); return; }
|
||||
if (!userForm.user_name) { toast.error("사용자 이름은 필수예요."); return; }
|
||||
if (!userForm.dept_code) { toast.error("부서는 필수예요."); return; }
|
||||
const errors = validateForm(userForm, ["cell_phone", "email"]);
|
||||
setFormErrors(errors);
|
||||
if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); return; }
|
||||
setDeptSaving(true);
|
||||
try {
|
||||
const password = userForm.user_password || (!userEditMode ? "qlalfqjsgh11" : undefined);
|
||||
await apiClient.post("/admin/users/with-dept", {
|
||||
userInfo: {
|
||||
user_id: userForm.user_id,
|
||||
user_name: userForm.user_name,
|
||||
user_name_eng: userForm.user_name_eng || undefined,
|
||||
user_password: password || undefined,
|
||||
email: userEditMode ? (userForm.email || null) : (userForm.email || undefined),
|
||||
tel: userForm.tel || undefined,
|
||||
cell_phone: userEditMode ? (userForm.cell_phone || null) : (userForm.cell_phone || undefined),
|
||||
sabun: userEditMode ? (userForm.sabun || null) : (userForm.sabun || undefined),
|
||||
position_name: userForm.position_name || undefined,
|
||||
dept_code: userForm.dept_code || undefined,
|
||||
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined,
|
||||
status: userForm.status || "active",
|
||||
},
|
||||
mainDept: userForm.dept_code ? {
|
||||
dept_code: userForm.dept_code,
|
||||
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name,
|
||||
position_name: userForm.position_name || undefined,
|
||||
} : undefined,
|
||||
isUpdate: userEditMode,
|
||||
});
|
||||
toast.success(userEditMode ? "사원 정보가 수정되었어요." : "사원이 추가되었어요.");
|
||||
setUserModalOpen(false);
|
||||
fetchMembers();
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || "저장에 실패했어요.");
|
||||
} finally {
|
||||
setDeptSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// EDataTable 컬럼 정의 (사원 목록)
|
||||
const companyMemberColumns: EDataTableColumn[] = [
|
||||
{ key: "sabun", label: "사번", width: "w-[80px]", render: (val: any) => <span className="text-[13px]">{val || "-"}</span> },
|
||||
{ key: "user_name", label: "이름", width: "w-[90px]" },
|
||||
{ key: "user_id", label: "사용자ID", width: "w-[100px]" },
|
||||
{ key: "position_name", label: "직급", width: "w-[80px]", render: (val: any) => <span>{val || "-"}</span> },
|
||||
{ key: "cell_phone", label: "휴대폰", width: "w-[120px]", render: (val: any) => <span>{val || "-"}</span> },
|
||||
{ key: "email", label: "이메일" },
|
||||
];
|
||||
|
||||
/* ── 트리 렌더 ── */
|
||||
const renderTree = (nodes: DeptNode[], depth = 0) => {
|
||||
return nodes.map((node) => {
|
||||
const isExpanded = expandedDepts.has(node.dept_code);
|
||||
const isSelected = selectedDeptCode === node.dept_code;
|
||||
const hasChildren = node.children.length > 0;
|
||||
return (
|
||||
<div key={node.dept_code}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-2 cursor-pointer text-sm transition-colors hover:bg-accent",
|
||||
isSelected && "bg-primary/10 text-primary font-semibold border-l-2 border-primary",
|
||||
!isSelected && "border-l-2 border-transparent",
|
||||
)}
|
||||
style={{ paddingLeft: `${12 + depth * 20}px` }}
|
||||
onClick={() => setSelectedDeptCode(isSelected ? null : node.dept_code)}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
className="p-0.5 rounded hover:bg-accent"
|
||||
onClick={(e) => { e.stopPropagation(); toggleExpand(node.dept_code); }}
|
||||
>
|
||||
<ChevronRight className={cn("w-3.5 h-3.5 transition-transform", isExpanded && "rotate-90")} />
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4.5" />
|
||||
)}
|
||||
{isExpanded && hasChildren
|
||||
? <FolderOpen className="w-4 h-4 text-muted-foreground" />
|
||||
: <Folder className="w-4 h-4 text-muted-foreground" />
|
||||
}
|
||||
<span className="truncate">{node.dept_name}</span>
|
||||
{node.status === "inactive" && <Badge variant="outline" className="text-[10px] px-1 py-0">비활성</Badge>}
|
||||
</div>
|
||||
{isExpanded && hasChildren && renderTree(node.children, depth + 1)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/* ── 이미지 업로드 박스 ── */
|
||||
const ImageUploadBox = ({
|
||||
label, preview, inputRef, field, setPreview,
|
||||
}: {
|
||||
label: string;
|
||||
preview: string | null;
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
field: string;
|
||||
setPreview: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
}) => (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-sm font-medium">{label}</Label>
|
||||
<div className="relative w-40 h-40 rounded-lg border-2 border-dashed border-border bg-muted/50 flex items-center justify-center overflow-hidden group">
|
||||
{preview ? (
|
||||
<>
|
||||
<img src={preview} alt={label} className="w-full h-full object-contain" />
|
||||
{editMode && (
|
||||
<div className="absolute inset-0 bg-foreground/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||
<Button size="sm" variant="secondary" className="h-7 text-xs" onClick={() => inputRef.current?.click()}>
|
||||
<Upload className="w-3 h-3 mr-1" /> 변경
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={() => {
|
||||
setPreview(null);
|
||||
setCompanyForm((prev) => ({ ...prev, [field]: null }));
|
||||
}}>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="flex flex-col items-center gap-1.5 text-muted-foreground"
|
||||
onClick={() => editMode && inputRef.current?.click()}
|
||||
disabled={!editMode}
|
||||
>
|
||||
<ImageIcon className="w-8 h-8" />
|
||||
<span className="text-xs">{editMode ? "이미지 업로드" : "이미지 없음"}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleImageUpload(e, field, setPreview)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)] flex flex-col overflow-hidden">
|
||||
{/* 탭 컨테이너 */}
|
||||
<Tabs defaultValue="company" className="flex flex-col h-full gap-0 min-h-0">
|
||||
{/* 탭 헤더 — border-b 스타일 */}
|
||||
<div className="shrink-0 border-b bg-background px-4">
|
||||
<TabsList className="h-12 bg-transparent gap-1">
|
||||
<TabsTrigger
|
||||
value="company"
|
||||
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4 gap-1.5"
|
||||
>
|
||||
<Building2 className="w-4 h-4" /> 회사정보
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="department"
|
||||
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4 gap-1.5"
|
||||
>
|
||||
<Users className="w-4 h-4" /> 부서관리
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* ===================== Tab 1: 회사정보 ===================== */}
|
||||
<TabsContent value="company" className="flex-1 overflow-auto mt-0 p-4">
|
||||
<div className="border rounded-lg bg-card">
|
||||
{/* 패널 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b bg-muted/30">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
<Building2 className="w-4 h-4 text-muted-foreground" />
|
||||
<span>회사 기본정보</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{editMode ? (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={cancelEdit}>취소</Button>
|
||||
<Button size="sm" onClick={handleCompanySave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Save className="w-3.5 h-3.5 mr-1" />}
|
||||
저장해요
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="sm" onClick={() => setEditMode(true)}>
|
||||
<Pencil className="w-3.5 h-3.5 mr-1" /> 수정
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{companyLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 기본 정보 섹션 제목 */}
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
기본 정보
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 그리드 (2열) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">회사코드</Label>
|
||||
<Input value={companyForm.company_code || ""} className="h-9 bg-muted/50" disabled readOnly />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
회사명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={companyForm.company_name || ""}
|
||||
onChange={(e) => setCompanyForm((p) => ({ ...p, company_name: e.target.value }))}
|
||||
placeholder="회사명을 입력해주세요"
|
||||
className="h-9" disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">사업자등록번호</Label>
|
||||
<Input
|
||||
value={companyForm.business_registration_number || ""}
|
||||
onChange={(e) => setCompanyForm((p) => ({ ...p, business_registration_number: e.target.value }))}
|
||||
placeholder="000-00-00000"
|
||||
className="h-9" disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">대표자명</Label>
|
||||
<Input
|
||||
value={companyForm.representative_name || ""}
|
||||
onChange={(e) => setCompanyForm((p) => ({ ...p, representative_name: e.target.value }))}
|
||||
placeholder="대표자명"
|
||||
className="h-9" disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">대표전화</Label>
|
||||
<Input
|
||||
value={companyForm.representative_phone || ""}
|
||||
onChange={(e) => setCompanyForm((p) => ({ ...p, representative_phone: e.target.value }))}
|
||||
placeholder="02-0000-0000"
|
||||
className="h-9" disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">팩스</Label>
|
||||
<Input
|
||||
value={companyForm.fax || ""}
|
||||
onChange={(e) => setCompanyForm((p) => ({ ...p, fax: e.target.value }))}
|
||||
placeholder="02-0000-0001"
|
||||
className="h-9" disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">이메일</Label>
|
||||
<Input
|
||||
value={companyForm.email || ""}
|
||||
onChange={(e) => setCompanyForm((p) => ({ ...p, email: e.target.value }))}
|
||||
placeholder="example@company.com"
|
||||
className="h-9" disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">홈페이지</Label>
|
||||
<Input
|
||||
value={companyForm.website || ""}
|
||||
onChange={(e) => setCompanyForm((p) => ({ ...p, website: e.target.value }))}
|
||||
placeholder="https://www.company.com"
|
||||
className="h-9" disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">주소</Label>
|
||||
<Input
|
||||
value={companyForm.address || ""}
|
||||
onChange={(e) => setCompanyForm((p) => ({ ...p, address: e.target.value }))}
|
||||
placeholder="회사 주소를 입력해주세요"
|
||||
className="h-9" disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 이미지 섹션 */}
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
이미지 관리
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
<ImageUploadBox label="회사 이미지" preview={imagePreview} inputRef={imageRef} field="company_image" setPreview={setImagePreview} />
|
||||
<ImageUploadBox label="회사 로고" preview={logoPreview} inputRef={logoRef} field="company_logo" setPreview={setLogoPreview} />
|
||||
<ImageUploadBox label="직인" preview={sealPreview} inputRef={sealRef} field="company_seal" setPreview={setSealPreview} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ===================== Tab 2: 부서관리 ===================== */}
|
||||
<TabsContent value="department" className="flex-1 overflow-hidden mt-0">
|
||||
<div className="h-full overflow-hidden border rounded-none bg-card">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
{/* 좌측: 부서 트리 */}
|
||||
<ResizablePanel defaultSize={30} minSize={20}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
<Building2 className="w-4 h-4 text-muted-foreground" />
|
||||
<span>부서</span>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{depts.length}건</Badge>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" className="h-8" onClick={openDeptRegister}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> 등록
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-8" disabled={!selectedDeptCode} onClick={openDeptEdit}>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" className="h-8" disabled={!selectedDeptCode} onClick={handleDeptDelete}>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{deptLoading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : deptTree.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
|
||||
<Building2 className="w-8 h-8 mb-2" />
|
||||
<span className="text-sm">등록된 부서가 없어요</span>
|
||||
</div>
|
||||
) : (
|
||||
renderTree(deptTree)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 우측: 사원 목록 */}
|
||||
<ResizablePanel defaultSize={70} minSize={40}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
<Users className="w-4 h-4 text-muted-foreground" />
|
||||
<span>{selectedDept ? "부서 인원" : "부서를 선택해주세요"}</span>
|
||||
{selectedDept && <Badge variant="outline" className="font-mono text-xs">{selectedDept.dept_name}</Badge>}
|
||||
{members.length > 0 && <Badge variant="secondary" className="font-mono text-xs">{members.length}명</Badge>}
|
||||
</div>
|
||||
{selectedDeptCode && (
|
||||
<Button variant="outline" size="sm" onClick={() => openUserModal()}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> 사원 추가
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{selectedDeptCode ? (
|
||||
<EDataTable
|
||||
columns={companyMemberColumns}
|
||||
data={members}
|
||||
rowKey={(row) => row.user_id || row.id}
|
||||
loading={memberLoading}
|
||||
emptyMessage="소속 사원이 없어요"
|
||||
emptyIcon={<Users className="w-8 h-8 mb-2" />}
|
||||
onRowDoubleClick={(row) => openUserModal(row)}
|
||||
showPagination={false}
|
||||
draggableColumns={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
|
||||
<Users className="w-10 h-10 mb-3" />
|
||||
<span className="text-sm">좌측에서 부서를 선택해주세요</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* ── 부서 등록/수정 모달 ── */}
|
||||
<Dialog open={deptModalOpen} onOpenChange={setDeptModalOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{deptEditMode ? "부서 수정" : "부서 등록"}</DialogTitle>
|
||||
<DialogDescription>{deptEditMode ? "부서 정보를 수정해요." : "새로운 부서를 등록해요."}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">부서코드</Label>
|
||||
<Input
|
||||
value={deptEditMode ? (deptForm.dept_code || "") : (previewCode || "")}
|
||||
placeholder={deptEditMode ? "" : (numberingRuleId ? "채번 조회 중..." : "자동 생성됩니다")}
|
||||
className="h-9" disabled readOnly
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">부서명 <span className="text-destructive">*</span></Label>
|
||||
<Input
|
||||
value={deptForm.dept_name || ""}
|
||||
onChange={(e) => setDeptForm((p) => ({ ...p, dept_name: e.target.value }))}
|
||||
placeholder="부서명" className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">상위부서</Label>
|
||||
<Select value={deptForm.parent_dept_code || ""} onValueChange={(v) => setDeptForm((p) => ({ ...p, parent_dept_code: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="상위부서 선택 (선택사항)" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
{depts.filter((d) => d.dept_code !== deptForm.dept_code).map((d) => (
|
||||
<SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name} ({d.dept_code})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeptModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleDeptSave} disabled={deptSaving}>
|
||||
{deptSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장해요
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── 사원 추가/수정 모달 ── */}
|
||||
<Dialog open={userModalOpen} onOpenChange={setUserModalOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{userEditMode ? "사원 정보 수정" : "사원 추가"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{userEditMode
|
||||
? `${userForm.user_name} (${userForm.user_id}) 사원 정보를 수정해요.`
|
||||
: selectedDept ? `${selectedDept.dept_name} 부서에 사원을 추가해요.` : "사원을 추가해요."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">사용자 ID <span className="text-destructive">*</span></Label>
|
||||
<Input value={userForm.user_id || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_id: e.target.value }))}
|
||||
placeholder="사용자 ID" className="h-9" disabled={userEditMode} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">이름 <span className="text-destructive">*</span></Label>
|
||||
<Input value={userForm.user_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_name: e.target.value }))}
|
||||
placeholder="이름" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">사번</Label>
|
||||
<Input value={userForm.sabun || ""} onChange={(e) => setUserForm((p) => ({ ...p, sabun: e.target.value }))}
|
||||
placeholder="사번" className="h-9" autoComplete="off" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">비밀번호</Label>
|
||||
<Input value={userForm.user_password || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_password: e.target.value }))}
|
||||
placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" autoComplete="new-password" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">직급</Label>
|
||||
<Input value={userForm.position_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, position_name: e.target.value }))}
|
||||
placeholder="직급" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">부서 <span className="text-destructive">*</span></Label>
|
||||
<Select value={userForm.dept_code || ""} onValueChange={(v) => setUserForm((p) => ({ ...p, dept_code: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="부서 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{depts.map((d) => <SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">휴대폰</Label>
|
||||
<Input value={userForm.cell_phone || ""} onChange={(e) => handleUserFormChange("cell_phone", e.target.value)}
|
||||
placeholder="010-0000-0000" className={cn("h-9", formErrors.cell_phone && "border-destructive")} />
|
||||
{formErrors.cell_phone && <p className="text-xs text-destructive">{formErrors.cell_phone}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">이메일</Label>
|
||||
<Input value={userForm.email || ""} onChange={(e) => handleUserFormChange("email", e.target.value)}
|
||||
placeholder="example@email.com" className={cn("h-9", formErrors.email && "border-destructive")} />
|
||||
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">입사일</Label>
|
||||
<Input type="date" value={userForm.regdate || ""} onChange={(e) => setUserForm((p) => ({ ...p, regdate: e.target.value }))} className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">퇴사일</Label>
|
||||
<Input type="date" value={userForm.end_date || ""} onChange={(e) => setUserForm((p) => ({ ...p, end_date: e.target.value }))} className="h-9" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setUserModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleUserSave} disabled={deptSaving}>
|
||||
{deptSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장해요
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,11 +17,14 @@ import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
|
||||
Building2, Users, Settings2,
|
||||
Users, Settings2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
@@ -29,46 +32,34 @@ import * as departmentAPI from "@/lib/api/department";
|
||||
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
|
||||
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||
import { formatField, validateField, validateForm } from "@/lib/utils/validation";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
|
||||
const DEPT_TABLE = "dept_info";
|
||||
const USER_TABLE = "user_info";
|
||||
|
||||
const LEFT_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "dept_code", label: "부서코드", width: "w-[120px]" },
|
||||
{ key: "dept_name", label: "부서명", minWidth: "min-w-[150px]" },
|
||||
{ key: "parent_dept_code", label: "상위부서", width: "w-[100px]" },
|
||||
{ key: "status", label: "상태", width: "w-[70px]" },
|
||||
];
|
||||
|
||||
const RIGHT_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "sabun", label: "사번", width: "w-[80px]" },
|
||||
{ key: "user_name", label: "이름", width: "w-[90px]" },
|
||||
{ key: "user_id", label: "사용자ID", width: "w-[100px]" },
|
||||
{ key: "position_name", label: "직급", width: "w-[80px]" },
|
||||
{ key: "cell_phone", label: "휴대폰", width: "w-[120px]" },
|
||||
{ key: "email", label: "이메일", minWidth: "min-w-[150px]" },
|
||||
const DEPT_COLUMNS = [
|
||||
{ key: "parent_dept_code", label: "상위부서" },
|
||||
{ key: "status", label: "상태" },
|
||||
];
|
||||
|
||||
export default function DepartmentPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
// 검색 필터 (DynamicSearchFilter)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 좌측: 부서
|
||||
const [depts, setDepts] = useState<any[]>([]);
|
||||
const [deptLoading, setDeptLoading] = useState(false);
|
||||
const [deptCount, setDeptCount] = useState(0);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [selectedDeptId, setSelectedDeptId] = useState<string | null>(null);
|
||||
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
||||
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||
|
||||
// 우측: 사원
|
||||
const [members, setMembers] = useState<any[]>([]);
|
||||
@@ -90,17 +81,14 @@ export default function DepartmentPage() {
|
||||
const [userForm, setUserForm] = useState<Record<string, any>>({});
|
||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 사원 탭 (재직중/퇴사)
|
||||
const [memberTab, setMemberTab] = useState<"active" | "resigned">("active");
|
||||
|
||||
// 엑셀
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
|
||||
const applyTableSettings = useCallback((settings: TableSettings) => {
|
||||
setFilterConfig(settings.filters);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = loadTableSettings("department");
|
||||
if (saved) applyTableSettings(saved);
|
||||
}, []);
|
||||
// 테이블 설정
|
||||
const ts = useTableSettings("c16-department", DEPT_TABLE, DEPT_COLUMNS);
|
||||
|
||||
// 부서 조회
|
||||
const fetchDepts = useCallback(async () => {
|
||||
@@ -244,7 +232,7 @@ export default function DepartmentPage() {
|
||||
} catch { toast.error("삭제에 실패했습니다."); }
|
||||
};
|
||||
|
||||
// 사원 추가
|
||||
// 사원 추가/수정
|
||||
const openUserModal = (editData?: any) => {
|
||||
if (editData) {
|
||||
setUserEditMode(true);
|
||||
@@ -319,88 +307,250 @@ export default function DepartmentPage() {
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
|
||||
// 퇴사일 기반 재직/퇴사 분리
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const activeMembers = members.filter((m) => !m.end_date || m.end_date.substring(0, 10) >= today);
|
||||
const resignedMembers = members.filter((m) => m.end_date && m.end_date.substring(0, 10) < today);
|
||||
|
||||
const isColVisible = (key: string) => ts.isVisible(key);
|
||||
|
||||
// EDataTable 컬럼 정의 (부서 목록)
|
||||
const deptColumns: EDataTableColumn[] = [
|
||||
{ key: "dept_code", label: "부서코드", width: "w-[120px]" },
|
||||
{ key: "dept_name", label: "부서명", minWidth: "min-w-[140px]" },
|
||||
...(isColVisible("parent_dept_code")
|
||||
? [{
|
||||
key: "parent_dept_code",
|
||||
label: "상위부서",
|
||||
width: "w-[110px]",
|
||||
render: (val: any) => <span className="text-[13px] text-muted-foreground">{val || "\u2014"}</span>,
|
||||
}]
|
||||
: []),
|
||||
...(isColVisible("status")
|
||||
? [{
|
||||
key: "status",
|
||||
label: "상태",
|
||||
width: "w-[70px]",
|
||||
render: (val: any) =>
|
||||
val ? (
|
||||
<Badge
|
||||
variant={val === "active" ? "default" : "outline"}
|
||||
className="text-[10px] px-1.5 py-0 h-5"
|
||||
>
|
||||
{val === "active" ? "활성" : (val || "\u2014")}
|
||||
</Badge>
|
||||
) : null,
|
||||
}]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
{/* 검색 */}
|
||||
<div className="flex h-full flex-col gap-3 p-4">
|
||||
{/* 검색 필터 바 */}
|
||||
<DynamicSearchFilter
|
||||
tableName={DEPT_TABLE}
|
||||
filterId="department"
|
||||
filterId="c16-department"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={deptCount}
|
||||
externalFilterConfig={filterConfig}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
extraActions={
|
||||
<div className="flex gap-1.5">
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
|
||||
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
|
||||
엑셀 업로드
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" /> 엑셀 업로드
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={() => void handleExcelDownload()}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 분할 패널 */}
|
||||
{/* 마스터-디테일 분할 패널 */}
|
||||
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
{/* 좌측: 부서 */}
|
||||
{/* 좌측: 부서 목록 */}
|
||||
<ResizablePanel defaultSize={40} minSize={25}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
<Building2 className="w-4 h-4" /> 부서
|
||||
<Badge variant="secondary" className="font-normal">{deptCount}건</Badge>
|
||||
{/* 패널 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-[13px] font-bold">부서 목록</span>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/[0.08] px-2 py-0.5 rounded-full">
|
||||
{deptCount}건
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" onClick={openDeptRegister}><Plus className="w-3.5 h-3.5 mr-1" /> 등록</Button>
|
||||
<Button variant="outline" size="sm" disabled={!selectedDeptCode} onClick={openDeptEdit}><Pencil className="w-3.5 h-3.5 mr-1" /> 수정</Button>
|
||||
<Button variant="destructive" size="sm" disabled={!selectedDeptCode} onClick={handleDeptDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제</Button>
|
||||
<Button size="sm" onClick={() => void openDeptRegister()}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> 등록
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={!selectedDeptCode} onClick={openDeptEdit}>
|
||||
<Pencil className="w-3.5 h-3.5 mr-1" /> 수정
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" disabled={!selectedDeptCode} onClick={() => void handleDeptDelete()}>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => ts.setOpen(true)}>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DataGrid
|
||||
gridId="dept-left"
|
||||
columns={LEFT_COLUMNS}
|
||||
data={depts}
|
||||
|
||||
{/* 부서 테이블 */}
|
||||
<EDataTable
|
||||
columns={deptColumns}
|
||||
data={ts.groupData(depts)}
|
||||
rowKey={(row) => row.id}
|
||||
loading={deptLoading}
|
||||
emptyMessage="등록된 부서가 없어요"
|
||||
selectedId={selectedDeptId}
|
||||
onSelect={(id) => {
|
||||
setSelectedDeptId((prev) => (prev === id ? null : id));
|
||||
}}
|
||||
onSelect={(id) => setSelectedDeptId(id)}
|
||||
onRowDoubleClick={() => openDeptEdit()}
|
||||
emptyMessage="등록된 부서가 없습니다"
|
||||
showRowNumber
|
||||
showPagination={false}
|
||||
draggableColumns={false}
|
||||
columnOrderKey="c16-department"
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 우측: 사원 */}
|
||||
{/* 우측: 사원 목록 */}
|
||||
<ResizablePanel defaultSize={60} minSize={30}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
<Users className="w-4 h-4" />
|
||||
{selectedDept ? "부서 인원" : "전체 사원"}
|
||||
{selectedDept && <Badge variant="outline" className="font-normal">{selectedDept.dept_name}</Badge>}
|
||||
{members.length > 0 && <Badge variant="secondary" className="font-normal">{members.length}명</Badge>}
|
||||
{!selectedDeptId ? (
|
||||
/* 빈 상태 */
|
||||
<div className="flex-1 flex items-center justify-center p-5">
|
||||
<div className="flex flex-col items-center justify-center text-center border-2 border-dashed border-border rounded-lg px-10 py-16">
|
||||
<Users className="w-12 h-12 text-muted-foreground/40 mb-4" />
|
||||
<div className="text-sm font-semibold text-muted-foreground mb-1.5">부서를 선택해주세요</div>
|
||||
<div className="text-xs text-muted-foreground">좌측에서 부서를 선택하면 소속 사원 목록이 표시돼요</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => openUserModal()}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> 사원 추가
|
||||
</Button>
|
||||
</div>
|
||||
<DataGrid
|
||||
gridId="dept-right"
|
||||
columns={RIGHT_COLUMNS}
|
||||
data={members}
|
||||
loading={memberLoading}
|
||||
showRowNumber={false}
|
||||
tableName={USER_TABLE}
|
||||
emptyMessage={selectedDeptCode ? "소속 사원이 없습니다" : "등록된 사원이 없습니다"}
|
||||
onRowDoubleClick={(row) => openUserModal(row)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* 디테일 헤더 */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b bg-muted shrink-0">
|
||||
<span className="text-[13px] font-bold">{selectedDept?.dept_name || "-"}</span>
|
||||
<span className="font-mono text-[11px] font-semibold text-primary bg-primary/[0.08] px-2 py-0.5 rounded-full">
|
||||
{selectedDept?.dept_code || "-"}
|
||||
</span>
|
||||
<div className="ml-auto">
|
||||
<Button variant="outline" size="sm" onClick={() => openUserModal()}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||
사원 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 재직/퇴사 탭 */}
|
||||
<div className="flex border-b border-border px-4 shrink-0 bg-muted">
|
||||
<button
|
||||
onClick={() => setMemberTab("active")}
|
||||
className={cn("px-4 py-2.5 text-xs font-semibold border-b-2 transition-colors",
|
||||
memberTab === "active" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
재직중
|
||||
{activeMembers.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 text-[10px] px-1.5 py-0">{activeMembers.length}</Badge>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMemberTab("resigned")}
|
||||
className={cn("px-4 py-2.5 text-xs font-semibold border-b-2 transition-colors",
|
||||
memberTab === "resigned" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
퇴사
|
||||
{resignedMembers.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 text-[10px] px-1.5 py-0">{resignedMembers.length}</Badge>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{memberLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" />
|
||||
</div>
|
||||
) : memberTab === "active" ? (
|
||||
activeMembers.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">재직중인 사원이 없어요</div>
|
||||
) : (
|
||||
<Table noWrapper>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center px-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">사번</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">이름</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">사용자ID</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">직급</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">휴대폰</TableHead>
|
||||
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground">이메일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{activeMembers.map((member, idx) => (
|
||||
<TableRow
|
||||
key={member.id || member.user_id}
|
||||
className="cursor-pointer select-none hover:bg-muted/50"
|
||||
onDoubleClick={() => openUserModal(member)}
|
||||
>
|
||||
<TableCell className="text-center text-[13px] text-muted-foreground px-2">{idx + 1}</TableCell>
|
||||
<TableCell className="font-mono text-[13px] text-muted-foreground">{member.sabun || "—"}</TableCell>
|
||||
<TableCell className="text-sm font-medium">{member.user_name}</TableCell>
|
||||
<TableCell className="text-[13px] text-muted-foreground">{member.user_id}</TableCell>
|
||||
<TableCell className="text-[13px]">{member.position_name || "—"}</TableCell>
|
||||
<TableCell className="text-[13px]">{member.cell_phone || "—"}</TableCell>
|
||||
<TableCell className="text-[13px]">{member.email || "—"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
) : (
|
||||
resignedMembers.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">퇴사한 사원이 없어요</div>
|
||||
) : (
|
||||
<Table noWrapper>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center px-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">사번</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">이름</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">사용자ID</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">직급</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">휴대폰</TableHead>
|
||||
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground">이메일</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">퇴사일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{resignedMembers.map((member, idx) => (
|
||||
<TableRow
|
||||
key={member.id || member.user_id}
|
||||
className="cursor-pointer select-none hover:bg-muted/50"
|
||||
onDoubleClick={() => openUserModal(member)}
|
||||
>
|
||||
<TableCell className="text-center text-[13px] text-muted-foreground px-2">{idx + 1}</TableCell>
|
||||
<TableCell className="font-mono text-[13px] text-muted-foreground">{member.sabun || "—"}</TableCell>
|
||||
<TableCell className="text-sm font-medium">{member.user_name}</TableCell>
|
||||
<TableCell className="text-[13px] text-muted-foreground">{member.user_id}</TableCell>
|
||||
<TableCell className="text-[13px]">{member.position_name || "—"}</TableCell>
|
||||
<TableCell className="text-[13px]">{member.cell_phone || "—"}</TableCell>
|
||||
<TableCell className="text-[13px]">{member.email || "—"}</TableCell>
|
||||
<TableCell className="text-[13px] text-muted-foreground">{member.end_date ? member.end_date.substring(0, 10) : "—"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
@@ -413,22 +563,37 @@ export default function DepartmentPage() {
|
||||
<DialogTitle>{deptEditMode ? "부서 수정" : "부서 등록"}</DialogTitle>
|
||||
<DialogDescription>{deptEditMode ? "부서 정보를 수정합니다." : "새로운 부서를 등록합니다."}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">부서코드</Label>
|
||||
<Input value={deptEditMode ? (deptForm.dept_code || "") : (previewCode || "")}
|
||||
placeholder={deptEditMode ? "" : (numberingRuleId ? "채번 조회 중..." : "자동 생성됩니다")}
|
||||
className="h-9" disabled readOnly />
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">부서코드</span>
|
||||
<Input
|
||||
value={deptEditMode ? (deptForm.dept_code || "") : (previewCode || "")}
|
||||
placeholder={deptEditMode ? "" : (numberingRuleId ? "채번 조회 중..." : "자동 생성돼요")}
|
||||
className="h-9"
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">부서명 <span className="text-destructive">*</span></Label>
|
||||
<Input value={deptForm.dept_name || ""} onChange={(e) => setDeptForm((p) => ({ ...p, dept_name: e.target.value }))}
|
||||
placeholder="부서명" className="h-9" />
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
부서명 <span className="text-destructive">*</span>
|
||||
</span>
|
||||
<Input
|
||||
value={deptForm.dept_name || ""}
|
||||
onChange={(e) => setDeptForm((p) => ({ ...p, dept_name: e.target.value }))}
|
||||
placeholder="부서명을 입력해 주세요"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">상위부서</Label>
|
||||
<Select value={deptForm.parent_dept_code || ""} onValueChange={(v) => setDeptForm((p) => ({ ...p, parent_dept_code: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="상위부서 선택 (선택사항)" /></SelectTrigger>
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">상위부서</span>
|
||||
<Select
|
||||
value={deptForm.parent_dept_code || ""}
|
||||
onValueChange={(v) => setDeptForm((p) => ({ ...p, parent_dept_code: v }))}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="상위부서 선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
{depts.filter((d) => d.dept_code !== deptForm.dept_code).map((d) => (
|
||||
@@ -440,85 +605,153 @@ export default function DepartmentPage() {
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeptModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleDeptSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||
<Button onClick={() => void handleDeptSave()} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 사원 추가 모달 */}
|
||||
{/* 사원 추가/수정 모달 */}
|
||||
<Dialog open={userModalOpen} onOpenChange={setUserModalOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{userEditMode ? "사원 정보 수정" : "사원 추가"}</DialogTitle>
|
||||
<DialogDescription>{userEditMode ? `${userForm.user_name} (${userForm.user_id}) 사원 정보를 수정합니다.` : selectedDept ? `${selectedDept.dept_name} 부서에 사원을 추가합니다.` : "사원을 추가합니다."}</DialogDescription>
|
||||
<DialogDescription>
|
||||
{userEditMode
|
||||
? `${userForm.user_name} (${userForm.user_id}) 사원 정보를 수정합니다.`
|
||||
: selectedDept ? `${selectedDept.dept_name} 부서에 사원을 추가합니다.` : "사원을 추가합니다."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">사용자 ID <span className="text-destructive">*</span></Label>
|
||||
<Input value={userForm.user_id || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_id: e.target.value }))}
|
||||
placeholder="사용자 ID" className="h-9" disabled={userEditMode} />
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
사용자 ID <span className="text-destructive">*</span>
|
||||
</span>
|
||||
<Input
|
||||
value={userForm.user_id || ""}
|
||||
onChange={(e) => setUserForm((p) => ({ ...p, user_id: e.target.value }))}
|
||||
placeholder="사용자 ID를 입력해 주세요"
|
||||
className="h-9"
|
||||
disabled={userEditMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">이름 <span className="text-destructive">*</span></Label>
|
||||
<Input value={userForm.user_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_name: e.target.value }))}
|
||||
placeholder="이름" className="h-9" />
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
이름 <span className="text-destructive">*</span>
|
||||
</span>
|
||||
<Input
|
||||
value={userForm.user_name || ""}
|
||||
onChange={(e) => setUserForm((p) => ({ ...p, user_name: e.target.value }))}
|
||||
placeholder="이름을 입력해 주세요"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">사번</Label>
|
||||
<Input value={userForm.sabun || ""} onChange={(e) => setUserForm((p) => ({ ...p, sabun: e.target.value }))}
|
||||
placeholder="사번" className="h-9" autoComplete="off" />
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">사번</span>
|
||||
<Input
|
||||
value={userForm.sabun || ""}
|
||||
onChange={(e) => setUserForm((p) => ({ ...p, sabun: e.target.value }))}
|
||||
placeholder="사번"
|
||||
className="h-9"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">비밀번호</Label>
|
||||
<Input value={userForm.user_password || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_password: e.target.value }))}
|
||||
placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" autoComplete="new-password" />
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">비밀번호</span>
|
||||
<Input
|
||||
value={userForm.user_password || ""}
|
||||
onChange={(e) => setUserForm((p) => ({ ...p, user_password: e.target.value }))}
|
||||
placeholder={userEditMode ? "변경 시에만 입력해 주세요" : "미입력 시 기본값이 설정돼요"}
|
||||
className="h-9"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">직급</Label>
|
||||
<Input value={userForm.position_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, position_name: e.target.value }))}
|
||||
placeholder="직급" className="h-9" />
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">직급</span>
|
||||
<Input
|
||||
value={userForm.position_name || ""}
|
||||
onChange={(e) => setUserForm((p) => ({ ...p, position_name: e.target.value }))}
|
||||
placeholder="직급"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">부서 <span className="text-destructive">*</span></Label>
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
부서 <span className="text-destructive">*</span>
|
||||
</span>
|
||||
<Select value={userForm.dept_code || ""} onValueChange={(v) => setUserForm((p) => ({ ...p, dept_code: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="부서 선택" /></SelectTrigger>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="부서를 선택해 주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{depts.map((d) => <SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name}</SelectItem>)}
|
||||
{depts.map((d) => (
|
||||
<SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">휴대폰</Label>
|
||||
<Input value={userForm.cell_phone || ""} onChange={(e) => handleUserFormChange("cell_phone", e.target.value)}
|
||||
placeholder="010-0000-0000" className={cn("h-9", formErrors.cell_phone && "border-destructive")} />
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">휴대폰</span>
|
||||
<Input
|
||||
value={userForm.cell_phone || ""}
|
||||
onChange={(e) => handleUserFormChange("cell_phone", e.target.value)}
|
||||
placeholder="010-0000-0000"
|
||||
className={cn("h-9", formErrors.cell_phone && "border-destructive")}
|
||||
/>
|
||||
{formErrors.cell_phone && <p className="text-xs text-destructive">{formErrors.cell_phone}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">이메일</Label>
|
||||
<Input value={userForm.email || ""} onChange={(e) => handleUserFormChange("email", e.target.value)}
|
||||
placeholder="example@email.com" className={cn("h-9", formErrors.email && "border-destructive")} />
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">이메일</span>
|
||||
<Input
|
||||
value={userForm.email || ""}
|
||||
onChange={(e) => handleUserFormChange("email", e.target.value)}
|
||||
placeholder="example@email.com"
|
||||
className={cn("h-9", formErrors.email && "border-destructive")}
|
||||
/>
|
||||
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">입사일</Label>
|
||||
<FormDatePicker value={userForm.regdate || ""} onChange={(v) => setUserForm((p) => ({ ...p, regdate: v }))} placeholder="입사일" />
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">입사일</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={userForm.regdate ? userForm.regdate.substring(0, 10) : ""}
|
||||
onChange={(e) => setUserForm((p) => ({ ...p, regdate: e.target.value }))}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">퇴사일</Label>
|
||||
<FormDatePicker value={userForm.end_date || ""} onChange={(v) => setUserForm((p) => ({ ...p, end_date: v }))} placeholder="퇴사일" />
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">퇴사일</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={userForm.end_date ? userForm.end_date.substring(0, 10) : ""}
|
||||
onChange={(e) => setUserForm((p) => ({ ...p, end_date: e.target.value }))}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setUserModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleUserSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||
<Button onClick={() => void handleUserSave()} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 테이블 설정 모달 */}
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
|
||||
{/* 엑셀 업로드 */}
|
||||
<ExcelUploadModal
|
||||
open={excelUploadOpen}
|
||||
@@ -529,14 +762,6 @@ export default function DepartmentPage() {
|
||||
/>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
<TableSettingsModal
|
||||
open={tableSettingsOpen}
|
||||
onOpenChange={setTableSettingsOpen}
|
||||
tableName={DEPT_TABLE}
|
||||
settingsId="department"
|
||||
onSave={applyTableSettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,43 +3,108 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Plus, Trash2, RotateCcw, Save, Search, Loader2, FileSpreadsheet, Download,
|
||||
Package, Pencil, Copy,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
|
||||
Pencil, Copy, Settings2, Check, ChevronsUpDown,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const TABLE_COLUMNS = [
|
||||
{ key: "item_number", label: "품목코드", width: "w-[120px]" },
|
||||
{ key: "item_name", label: "품명", width: "min-w-[150px]" },
|
||||
{ key: "division", label: "관리품목", width: "w-[100px]" },
|
||||
{ key: "type", label: "품목구분", width: "w-[100px]" },
|
||||
{ key: "size", label: "규격", width: "w-[100px]" },
|
||||
{ key: "unit", label: "단위", width: "w-[80px]" },
|
||||
{ key: "material", label: "재질", width: "w-[100px]" },
|
||||
{ key: "status", label: "상태", width: "w-[80px]" },
|
||||
{ key: "selling_price", label: "판매가격", width: "w-[100px]" },
|
||||
{ key: "standard_price", label: "기준단가", width: "w-[100px]" },
|
||||
{ key: "weight", label: "중량", width: "w-[80px]" },
|
||||
{ key: "inventory_unit", label: "재고단위", width: "w-[80px]" },
|
||||
{ key: "user_type01", label: "대분류", width: "w-[100px]" },
|
||||
{ key: "user_type02", label: "중분류", width: "w-[100px]" },
|
||||
// 검색 가능한 카테고리 콤보박스
|
||||
function CategoryCombobox({ options, value, onChange, placeholder }: {
|
||||
options: { code: string; label: string }[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const selected = options.find((o) => o.code === value);
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" aria-expanded={open} className="h-9 w-full justify-between font-normal">
|
||||
<span className="truncate">{selected?.label || <span className="text-muted-foreground">{placeholder}</span>}</span>
|
||||
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="검색..." className="h-8" />
|
||||
<CommandList>
|
||||
<CommandEmpty>검색 결과가 없어요</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{options.map((opt) => (
|
||||
<CommandItem key={opt.code} value={opt.label} onSelect={() => { onChange(opt.code); setOpen(false); }}>
|
||||
<Check className={cn("mr-2 h-3.5 w-3.5", value === opt.code ? "opacity-100" : "opacity-0")} />
|
||||
{opt.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const TABLE_NAME = "item_info";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "item_number", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "image", label: "이미지", type: "image" },
|
||||
{ key: "division", label: "관리품목" },
|
||||
{ key: "type", label: "품목구분" },
|
||||
{ key: "size", label: "규격" },
|
||||
{ key: "unit", label: "단위" },
|
||||
{ key: "material", label: "재질" },
|
||||
{ key: "status", label: "상태" },
|
||||
{ key: "selling_price", label: "판매가격", align: "right" as const, formatNumber: true },
|
||||
{ key: "standard_price", label: "기준단가", align: "right" as const, formatNumber: true },
|
||||
{ key: "weight", label: "중량", align: "right" as const },
|
||||
{ key: "inventory_unit", label: "재고단위" },
|
||||
{ key: "user_type01", label: "대분류" },
|
||||
{ key: "user_type02", label: "중분류" },
|
||||
{ key: "lead_time", label: "생산 리드타임(일)", align: "right" as const },
|
||||
];
|
||||
|
||||
// 등록 모달 필드 정의
|
||||
const FORM_FIELDS = [
|
||||
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
|
||||
{ key: "item_name", label: "품명", type: "text", required: true },
|
||||
@@ -58,22 +123,24 @@ const FORM_FIELDS = [
|
||||
{ key: "currency_code", label: "통화", type: "category" },
|
||||
{ key: "user_type01", label: "대분류", type: "category" },
|
||||
{ key: "user_type02", label: "중분류", type: "category" },
|
||||
{ key: "lead_time", label: "생산 리드타임(일)", type: "text", placeholder: "숫자 입력 (예: 7)" },
|
||||
{ key: "image", label: "품목 이미지", type: "image" },
|
||||
{ key: "meno", label: "메모", type: "textarea" },
|
||||
];
|
||||
|
||||
const TABLE_NAME = "item_info";
|
||||
const CATEGORY_COLUMNS = [
|
||||
"division", "type", "unit", "material", "status",
|
||||
"inventory_unit", "currency_code", "user_type01", "user_type02",
|
||||
];
|
||||
|
||||
export default function ItemInfoPage() {
|
||||
const { user } = useAuth();
|
||||
const ts = useTableSettings("c16-item-info", TABLE_NAME, GRID_COLUMNS);
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// 검색
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
const [searchDivision, setSearchDivision] = useState("all");
|
||||
const [searchType, setSearchType] = useState("all");
|
||||
const [searchStatus, setSearchStatus] = useState("all");
|
||||
// 검색 필터 (DynamicSearchFilter)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 모달
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
@@ -91,10 +158,7 @@ export default function ItemInfoPage() {
|
||||
// 선택된 행
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
// 카테고리 컬럼 목록
|
||||
const CATEGORY_COLUMNS = ["division", "type", "unit", "material", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"];
|
||||
|
||||
// 카테고리 옵션 로드 (table_name + column_name 기반)
|
||||
// 카테고리 옵션 로드
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
@@ -130,20 +194,7 @@ export default function ItemInfoPage() {
|
||||
const fetchItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (searchKeyword) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: searchKeyword });
|
||||
}
|
||||
if (searchDivision !== "all") {
|
||||
filters.push({ columnName: "division", operator: "equals", value: searchDivision });
|
||||
}
|
||||
if (searchType !== "all") {
|
||||
filters.push({ columnName: "type", operator: "equals", value: searchType });
|
||||
}
|
||||
if (searchStatus !== "all") {
|
||||
filters.push({ columnName: "status", operator: "equals", value: searchStatus });
|
||||
}
|
||||
|
||||
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1,
|
||||
size: 500,
|
||||
@@ -152,9 +203,16 @@ export default function ItemInfoPage() {
|
||||
});
|
||||
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
// 카테고리 코드→라벨 변환
|
||||
const resolve = (col: string, code: string) => {
|
||||
if (!code) return "";
|
||||
// 쉼표 구분 다중값 지원
|
||||
if (code.includes(",")) {
|
||||
return code.split(",").map((c) => {
|
||||
const trimmed = c.trim();
|
||||
if (!trimmed || trimmed === "s") return "";
|
||||
return categoryOptions[col]?.find((o) => o.code === trimmed)?.label || trimmed;
|
||||
}).filter(Boolean).join(", ");
|
||||
}
|
||||
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
const data = raw.map((r: any) => {
|
||||
@@ -165,34 +223,40 @@ export default function ItemInfoPage() {
|
||||
return converted;
|
||||
});
|
||||
setItems(data);
|
||||
setTotalCount(res.data?.data?.total || raw.length);
|
||||
} catch (err) {
|
||||
console.error("품목 조회 실패:", err);
|
||||
toast.error("품목 목록을 불러오는데 실패했습니다.");
|
||||
toast.error("품목 목록을 불러오는데 실패했어요.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchKeyword, searchDivision, searchType, searchStatus, categoryOptions]);
|
||||
}, [categoryOptions, searchFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [fetchItems]);
|
||||
|
||||
// 카테고리 코드 → 라벨 변환
|
||||
const getCategoryLabel = (columnName: string, code: string) => {
|
||||
if (!code) return "";
|
||||
const opts = categoryOptions[columnName];
|
||||
if (!opts) return code;
|
||||
const found = opts.find((o) => o.code === code);
|
||||
return found?.label || code;
|
||||
// 채번 미리보기 로드
|
||||
const loadNumberingPreview = async () => {
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
|
||||
const rule = ruleRes.data?.data;
|
||||
if (rule?.ruleId) {
|
||||
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} });
|
||||
return previewRes.data?.data?.generatedCode || "";
|
||||
}
|
||||
} catch { /* 채번 규칙 없으면 무시 */ }
|
||||
return "";
|
||||
};
|
||||
|
||||
// 등록 모달 열기
|
||||
const openRegisterModal = () => {
|
||||
const openRegisterModal = async () => {
|
||||
setFormData({});
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
setIsModalOpen(true);
|
||||
// 채번 컬럼 자동 로드
|
||||
const code = await loadNumberingPreview();
|
||||
if (code) setFormData(prev => ({ ...prev, item_number: code }));
|
||||
};
|
||||
|
||||
// 수정 모달 열기
|
||||
@@ -204,42 +268,42 @@ export default function ItemInfoPage() {
|
||||
};
|
||||
|
||||
// 복사 모달 열기
|
||||
const openCopyModal = (item: any) => {
|
||||
const openCopyModal = async (item: any) => {
|
||||
const { id, item_number, created_date, updated_date, writer, ...rest } = item;
|
||||
setFormData(rest);
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
const code = await loadNumberingPreview();
|
||||
if (code) setFormData(prev => ({ ...prev, item_number: code }));
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
if (!formData.item_name) {
|
||||
toast.error("품명은 필수 입력입니다.");
|
||||
toast.error("품명은 필수 입력이에요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEditMode && editId) {
|
||||
// 수정
|
||||
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
|
||||
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
|
||||
originalData: { id: editId },
|
||||
updatedData: updateFields,
|
||||
});
|
||||
toast.success("수정되었습니다.");
|
||||
toast.success("수정되었어요.");
|
||||
} else {
|
||||
// 등록
|
||||
const { id, created_date, updated_date, ...insertFields } = formData;
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, insertFields);
|
||||
toast.success("등록되었습니다.");
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...insertFields });
|
||||
toast.success("등록되었어요.");
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
fetchItems();
|
||||
} catch (err: any) {
|
||||
console.error("저장 실패:", err);
|
||||
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
|
||||
toast.error(err.response?.data?.message || "저장에 실패했어요.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -248,34 +312,34 @@ export default function ItemInfoPage() {
|
||||
// 삭제
|
||||
const handleDelete = async () => {
|
||||
if (!selectedId) {
|
||||
toast.error("삭제할 품목을 선택해주세요.");
|
||||
toast.error("삭제할 품목을 선택해 주세요.");
|
||||
return;
|
||||
}
|
||||
if (!confirm("선택한 품목을 삭제하시겠습니까?")) return;
|
||||
if (!confirm("선택한 품목을 삭제할까요?")) return;
|
||||
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
|
||||
data: [{ id: selectedId }],
|
||||
});
|
||||
toast.success("삭제되었습니다.");
|
||||
toast.success("삭제되었어요.");
|
||||
setSelectedId(null);
|
||||
fetchItems();
|
||||
} catch (err) {
|
||||
console.error("삭제 실패:", err);
|
||||
toast.error("삭제에 실패했습니다.");
|
||||
toast.error("삭제에 실패했어요.");
|
||||
}
|
||||
};
|
||||
|
||||
// 엑셀 다운로드
|
||||
const handleExcelDownload = async () => {
|
||||
if (items.length === 0) {
|
||||
toast.error("다운로드할 데이터가 없습니다.");
|
||||
toast.error("다운로드할 데이터가 없어요.");
|
||||
return;
|
||||
}
|
||||
const exportData = items.map((item) => {
|
||||
const row: Record<string, any> = {};
|
||||
for (const col of TABLE_COLUMNS) {
|
||||
row[col.label] = getCategoryLabel(col.key, item[col.key]) || item[col.key] || "";
|
||||
for (const col of GRID_COLUMNS) {
|
||||
row[col.label] = item[col.key] || "";
|
||||
}
|
||||
return row;
|
||||
});
|
||||
@@ -283,225 +347,179 @@ export default function ItemInfoPage() {
|
||||
toast.success("엑셀 다운로드 완료");
|
||||
};
|
||||
|
||||
// 검색 초기화
|
||||
const handleResetSearch = () => {
|
||||
setSearchKeyword("");
|
||||
setSearchDivision("all");
|
||||
setSearchType("all");
|
||||
setSearchStatus("all");
|
||||
};
|
||||
|
||||
// 카테고리 셀렉트 렌더링
|
||||
const renderCategorySelect = (field: typeof FORM_FIELDS[0]) => {
|
||||
const options = categoryOptions[field.key] || [];
|
||||
return (
|
||||
<Select
|
||||
value={formData[field.key] || ""}
|
||||
onValueChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder={`${field.label} 선택`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt.code} value={opt.code}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
{/* 검색 */}
|
||||
<Card className="shrink-0">
|
||||
<CardContent className="p-4 flex flex-wrap items-end gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">품명/품목코드</Label>
|
||||
<Input
|
||||
placeholder="검색"
|
||||
className="w-[180px] h-9"
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && fetchItems()}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">관리품목</Label>
|
||||
<Select value={searchDivision} onValueChange={setSearchDivision}>
|
||||
<SelectTrigger className="w-[120px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
{(categoryOptions["division"] || []).map((o) => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">품목구분</Label>
|
||||
<Select value={searchType} onValueChange={setSearchType}>
|
||||
<SelectTrigger className="w-[120px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
{(categoryOptions["type"] || []).map((o) => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">상태</Label>
|
||||
<Select value={searchStatus} onValueChange={setSearchStatus}>
|
||||
<SelectTrigger className="w-[110px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
{(categoryOptions["status"] || []).map((o) => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-2">
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={handleResetSearch}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" /> 초기화
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex h-full flex-col gap-0">
|
||||
{/* 검색 필터 바 */}
|
||||
<DynamicSearchFilter
|
||||
tableName={TABLE_NAME}
|
||||
filterId="c16-item-info"
|
||||
onFilterChange={setSearchFilters}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
/>
|
||||
|
||||
{/* 메인 테이블 */}
|
||||
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm flex flex-col">
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
<Package className="w-5 h-5" /> 품목 목록
|
||||
<Badge variant="secondary" className="font-normal">{totalCount}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-4 h-4 mr-1.5" /> 엑셀 업로드
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
|
||||
<Download className="w-4 h-4 mr-1.5" /> 엑셀 다운로드
|
||||
</Button>
|
||||
<Button size="sm" onClick={openRegisterModal}>
|
||||
<Plus className="w-4 h-4 mr-1.5" /> 품목 등록
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={!selectedId} onClick={() => {
|
||||
{/* 액션 바 */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">품목 관리</span>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{items.length}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-4 h-4 mr-1.5" /> 엑셀 업로드
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
|
||||
<Download className="w-4 h-4 mr-1.5" /> 엑셀 다운로드
|
||||
</Button>
|
||||
<div className="mx-1 h-5 w-px bg-border" />
|
||||
<Button size="sm" onClick={openRegisterModal}>
|
||||
<Plus className="w-4 h-4 mr-1.5" /> 품목 등록
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!selectedId}
|
||||
onClick={() => {
|
||||
const item = items.find((i) => i.id === selectedId);
|
||||
if (item) openCopyModal(item);
|
||||
}}>
|
||||
<Copy className="w-4 h-4 mr-1.5" /> 복사
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={!selectedId} onClick={() => {
|
||||
}}
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-1.5" /> 복사
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!selectedId}
|
||||
onClick={() => {
|
||||
const item = items.find((i) => i.id === selectedId);
|
||||
if (item) openEditModal(item);
|
||||
}}>
|
||||
<Pencil className="w-4 h-4 mr-1.5" /> 수정
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" disabled={!selectedId} onClick={handleDelete}>
|
||||
<Trash2 className="w-4 h-4 mr-1.5" /> 삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground gap-2">
|
||||
<Package className="w-8 h-8 opacity-50" />
|
||||
<span>등록된 품목이 없습니다</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px] text-center">No</TableHead>
|
||||
{TABLE_COLUMNS.map((col) => (
|
||||
<TableHead key={col.key} className={col.width}>{col.label}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item, idx) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={cn("cursor-pointer", selectedId === item.id && "bg-primary/5")}
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
onDoubleClick={() => openEditModal(item)}
|
||||
>
|
||||
<TableCell className="text-center text-xs text-muted-foreground">{idx + 1}</TableCell>
|
||||
{TABLE_COLUMNS.map((col) => (
|
||||
<TableCell key={col.key} className="text-sm">
|
||||
{["division", "type", "unit", "material", "status", "inventory_unit", "user_type01", "user_type02", "currency_code"].includes(col.key)
|
||||
? getCategoryLabel(col.key, item[col.key])
|
||||
: item[col.key] || ""}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-1.5" /> 수정
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" disabled={!selectedId} onClick={handleDelete}>
|
||||
<Trash2 className="w-4 h-4 mr-1.5" /> 삭제
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 테이블 */}
|
||||
<EDataTable
|
||||
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
align: (col as any).type === "image" ? "center" : col.align as "left" | "center" | "right" | undefined,
|
||||
formatNumber: (col as any).formatNumber,
|
||||
width: (col as any).type === "image" ? "w-[50px]" : undefined,
|
||||
render: (col as any).type === "image" ? (val: any) => (
|
||||
val ? (
|
||||
<img src={String(val).startsWith("http") || String(val).startsWith("/") ? val : `/api/files/preview/${val}`} alt="" className="h-8 w-8 rounded object-cover border border-border mx-auto" onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
) : <div className="h-8 w-8 rounded bg-muted mx-auto" />
|
||||
) : undefined,
|
||||
}))}
|
||||
data={ts.groupData(items)}
|
||||
loading={loading}
|
||||
emptyMessage="등록된 품목이 없어요"
|
||||
selectedId={selectedId}
|
||||
onSelect={(id) => setSelectedId(id)}
|
||||
onRowDoubleClick={(row) => openEditModal(row)}
|
||||
showRowNumber
|
||||
draggableColumns={false}
|
||||
/>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-auto">
|
||||
<DialogHeader>
|
||||
<DialogContent className="sm:max-w-[600px] w-[95vw] max-h-[90vh] flex flex-col p-0 overflow-hidden">
|
||||
<DialogHeader className="shrink-0 px-6 pt-5 pb-3 border-b">
|
||||
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditMode ? "품목 정보를 수정합니다." : "새로운 품목을 등록합니다."}
|
||||
{isEditMode ? "품목 정보를 수정해요." : "새로운 품목을 등록해요."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
{FORM_FIELDS.map((field) => (
|
||||
<div key={field.key} className={cn("space-y-1.5", field.type === "textarea" && "col-span-2")}>
|
||||
<Label className="text-sm">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{field.type === "category" ? (
|
||||
renderCategorySelect(field)
|
||||
) : field.type === "textarea" ? (
|
||||
<Textarea
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.label}
|
||||
rows={3}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)}
|
||||
disabled={field.disabled && !isEditMode}
|
||||
className="h-9"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="grid grid-cols-2 gap-4 p-6">
|
||||
{FORM_FIELDS.map((field) => (
|
||||
<div
|
||||
key={field.key}
|
||||
className={cn("space-y-1.5", (field.type === "textarea" || field.type === "image") && "col-span-2")}
|
||||
>
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{field.type === "image" ? (
|
||||
<ImageUpload
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
|
||||
tableName={TABLE_NAME}
|
||||
recordId={formData.id || ""}
|
||||
columnName={field.key}
|
||||
height="h-32"
|
||||
/>
|
||||
) : field.type === "category" ? (
|
||||
<CategoryCombobox
|
||||
options={categoryOptions[field.key] || []}
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
|
||||
placeholder={`${field.label} 선택`}
|
||||
/>
|
||||
) : field.type === "textarea" ? (
|
||||
<Textarea
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.label}
|
||||
rows={3}
|
||||
/>
|
||||
) : ["selling_price", "standard_price"].includes(field.key) ? (
|
||||
<Input
|
||||
value={formData[field.key] ? Number(String(formData[field.key]).replace(/,/g, "")).toLocaleString() : ""}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.replace(/[^\d.-]/g, "");
|
||||
setFormData((prev) => ({ ...prev, [field.key]: raw }));
|
||||
}}
|
||||
placeholder={field.placeholder || field.label}
|
||||
className="h-9 text-right"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)}
|
||||
disabled={field.disabled && !isEditMode}
|
||||
className="h-9"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter className="shrink-0 border-t px-6 py-3">
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
|
||||
{saving
|
||||
? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
|
||||
: <Save className="w-4 h-4 mr-1.5" />
|
||||
}
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 테이블 설정 모달 */}
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
|
||||
{/* 엑셀 업로드 모달 */}
|
||||
<ExcelUploadModal
|
||||
open={excelUploadOpen}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { Settings2, Tags, Hash } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||
import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner";
|
||||
|
||||
const TABS = [
|
||||
{ id: "category", label: "카테고리 설정", icon: Tags },
|
||||
{ id: "numbering", label: "코드 설정", icon: Hash },
|
||||
] as const;
|
||||
|
||||
type TabId = (typeof TABS)[number]["id"];
|
||||
|
||||
export default function OptionsSettingPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>("category");
|
||||
|
||||
const [selectedColumn, setSelectedColumn] = useState<string | null>(null);
|
||||
const [selectedColumnLabel, setSelectedColumnLabel] = useState("");
|
||||
const [selectedTableName, setSelectedTableName] = useState("");
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(340);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleMouseDown = useCallback(() => {
|
||||
setIsDragging(true);
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
setLeftWidth(Math.max(260, Math.min(500, e.clientX - rect.left)));
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3 gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className="h-4 w-4 text-primary" />
|
||||
<h1 className="text-sm font-semibold">옵션 설정</h1>
|
||||
</div>
|
||||
<div className="flex bg-muted rounded-md p-0.5 gap-0.5">
|
||||
{TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-all",
|
||||
activeTab === tab.id
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
{activeTab === "category" && (
|
||||
<div ref={containerRef} className="flex h-full">
|
||||
<div
|
||||
style={{ width: leftWidth }}
|
||||
className="shrink-0 border rounded-lg bg-card overflow-hidden"
|
||||
>
|
||||
<CategoryColumnList
|
||||
tableName=""
|
||||
selectedColumn={selectedColumn}
|
||||
onColumnSelect={(uniqueKey, label, tableName) => {
|
||||
setSelectedColumn(uniqueKey);
|
||||
setSelectedColumnLabel(label);
|
||||
setSelectedTableName(tableName);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className={cn(
|
||||
"w-1.5 mx-0.5 cursor-col-resize rounded-full transition-colors shrink-0",
|
||||
isDragging ? "bg-primary" : "bg-border hover:bg-primary/50"
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0 border rounded-lg bg-card overflow-hidden">
|
||||
{selectedColumn && selectedTableName ? (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={selectedColumn.includes(".") ? selectedColumn.split(".").pop()! : selectedColumn}
|
||||
columnLabel={selectedColumnLabel}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
<Tags className="h-8 w-8 mx-auto text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">좌측에서 카테고리 컬럼을 선택해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "numbering" && (
|
||||
<div className="h-full border rounded-lg bg-card overflow-auto">
|
||||
<NumberingRuleDesigner />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,575 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 설비운영모니터링 — 하드코딩 페이지
|
||||
*
|
||||
* 설비(equipment_mng) 목록 + 작업지시(work_instruction) 연결
|
||||
* 실시간 카드 그리드 형태 모니터링 대시보드
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Loader2,
|
||||
Inbox,
|
||||
Wrench,
|
||||
Zap,
|
||||
Pause,
|
||||
Power,
|
||||
} from "lucide-react";
|
||||
|
||||
/* ───── 상태 정의 ───── */
|
||||
|
||||
type OperationStatus = "running" | "idle" | "maintenance" | "off" | "unknown";
|
||||
|
||||
interface StatusConfig {
|
||||
label: string;
|
||||
color: string;
|
||||
bg: string;
|
||||
border: string;
|
||||
bar: string;
|
||||
icon: React.ReactNode;
|
||||
badgeBg: string;
|
||||
badgeText: string;
|
||||
cardGlow: string;
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<OperationStatus, StatusConfig> = {
|
||||
running: {
|
||||
label: "가동중",
|
||||
color: "text-emerald-400",
|
||||
bg: "bg-emerald-500/10",
|
||||
border: "border-emerald-500/30",
|
||||
bar: "bg-emerald-400",
|
||||
icon: <Zap className="h-4 w-4" />,
|
||||
badgeBg: "bg-emerald-500/20",
|
||||
badgeText: "text-emerald-300",
|
||||
cardGlow: "shadow-emerald-500/5",
|
||||
},
|
||||
idle: {
|
||||
label: "대기",
|
||||
color: "text-amber-400",
|
||||
bg: "bg-amber-500/10",
|
||||
border: "border-amber-500/30",
|
||||
bar: "bg-amber-400",
|
||||
icon: <Pause className="h-4 w-4" />,
|
||||
badgeBg: "bg-amber-500/20",
|
||||
badgeText: "text-amber-300",
|
||||
cardGlow: "shadow-amber-500/5",
|
||||
},
|
||||
maintenance: {
|
||||
label: "점검/수리",
|
||||
color: "text-red-400",
|
||||
bg: "bg-red-500/10",
|
||||
border: "border-red-500/30",
|
||||
bar: "bg-red-400",
|
||||
icon: <Wrench className="h-4 w-4" />,
|
||||
badgeBg: "bg-red-500/20",
|
||||
badgeText: "text-red-300",
|
||||
cardGlow: "shadow-red-500/5",
|
||||
},
|
||||
off: {
|
||||
label: "비가동",
|
||||
color: "text-gray-400",
|
||||
bg: "bg-gray-500/10",
|
||||
border: "border-gray-500/30",
|
||||
bar: "bg-gray-500",
|
||||
icon: <Power className="h-4 w-4" />,
|
||||
badgeBg: "bg-gray-500/20",
|
||||
badgeText: "text-gray-400",
|
||||
cardGlow: "shadow-gray-500/5",
|
||||
},
|
||||
unknown: {
|
||||
label: "미설정",
|
||||
color: "text-gray-500",
|
||||
bg: "bg-gray-500/10",
|
||||
border: "border-gray-600/30",
|
||||
bar: "bg-gray-600",
|
||||
icon: <Power className="h-4 w-4" />,
|
||||
badgeBg: "bg-gray-600/20",
|
||||
badgeText: "text-gray-500",
|
||||
cardGlow: "",
|
||||
},
|
||||
};
|
||||
|
||||
/** operation_status 값 → 내부 키 매핑 */
|
||||
function resolveStatus(raw: string | null | undefined): OperationStatus {
|
||||
if (!raw) return "unknown";
|
||||
const v = raw.trim().toLowerCase();
|
||||
if (["running", "가동", "가동중"].includes(v)) return "running";
|
||||
if (["idle", "대기"].includes(v)) return "idle";
|
||||
if (["maintenance", "점검", "수리", "점검/수리", "점검중"].includes(v)) return "maintenance";
|
||||
if (["off", "비가동", "정지"].includes(v)) return "off";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/* ───── 타입 ───── */
|
||||
|
||||
interface Equipment {
|
||||
id: string;
|
||||
equipment_code: string;
|
||||
equipment_name: string;
|
||||
equipment_type: string;
|
||||
installation_location: string;
|
||||
operation_status: string;
|
||||
manufacturer: string;
|
||||
model_name: string;
|
||||
image_path: string;
|
||||
}
|
||||
|
||||
interface WorkInstruction {
|
||||
id: string;
|
||||
instruction_number: string;
|
||||
item_name: string;
|
||||
equipment_id: string;
|
||||
worker_name: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/* ───── 컴포넌트 ───── */
|
||||
|
||||
export default function EquipmentMonitoringPage() {
|
||||
const [equipments, setEquipments] = useState<Equipment[]>([]);
|
||||
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
|
||||
const autoRefreshRef = useRef(autoRefresh);
|
||||
|
||||
// autoRefreshRef 동기화
|
||||
useEffect(() => {
|
||||
autoRefreshRef.current = autoRefresh;
|
||||
}, [autoRefresh]);
|
||||
|
||||
/* ── 시간 업데이트 ── */
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
/* ── 데이터 fetch ── */
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [equipRes, wiRes] = await Promise.all([
|
||||
apiClient.post("/table-management/tables/equipment_mng/data", {
|
||||
autoFilter: true,
|
||||
page: 1,
|
||||
size: 500,
|
||||
}),
|
||||
apiClient.get("/work-instruction/list").catch(() => ({ data: { data: [] } })),
|
||||
]);
|
||||
|
||||
const eqRows: Equipment[] = equipRes.data?.data?.rows ?? equipRes.data?.rows ?? [];
|
||||
setEquipments(eqRows);
|
||||
|
||||
const wiRows: WorkInstruction[] = wiRes.data?.data ?? wiRes.data?.rows ?? [];
|
||||
setWorkInstructions(wiRows);
|
||||
} catch (err) {
|
||||
console.error("설비 모니터링 데이터 조회 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
/* ── 자동 갱신 (30초) ── */
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (autoRefreshRef.current) fetchData();
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchData]);
|
||||
|
||||
/* ── 요약 통계 ── */
|
||||
const stats = useMemo(() => {
|
||||
const counts: Record<OperationStatus, number> = {
|
||||
running: 0,
|
||||
idle: 0,
|
||||
maintenance: 0,
|
||||
off: 0,
|
||||
unknown: 0,
|
||||
};
|
||||
equipments.forEach((eq) => {
|
||||
const s = resolveStatus(eq.operation_status);
|
||||
counts[s]++;
|
||||
});
|
||||
return { total: equipments.length, ...counts };
|
||||
}, [equipments]);
|
||||
|
||||
/* ── 필터된 설비 ── */
|
||||
const filteredEquipments = useMemo(() => {
|
||||
if (filterStatus === "all") return equipments;
|
||||
return equipments.filter((eq) => resolveStatus(eq.operation_status) === filterStatus);
|
||||
}, [equipments, filterStatus]);
|
||||
|
||||
/* ── 설비별 작업지시 맵 ── */
|
||||
const wiMap = useMemo(() => {
|
||||
const map: Record<string, WorkInstruction[]> = {};
|
||||
workInstructions.forEach((wi) => {
|
||||
if (wi.equipment_id) {
|
||||
if (!map[wi.equipment_id]) map[wi.equipment_id] = [];
|
||||
map[wi.equipment_id].push(wi);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [workInstructions]);
|
||||
|
||||
/* ── 가동률 (모킹 — 센서 미연동) ── */
|
||||
const getUtilization = (eq: Equipment): number | null => {
|
||||
const s = resolveStatus(eq.operation_status);
|
||||
if (s === "running") return 75 + Math.floor(Math.random() * 20); // 75~94
|
||||
if (s === "idle") return 20 + Math.floor(Math.random() * 30); // 20~49
|
||||
if (s === "maintenance") return 0;
|
||||
if (s === "off") return 0;
|
||||
return null;
|
||||
};
|
||||
|
||||
/* ── 요약 카드 배열 ── */
|
||||
const summaryCards: {
|
||||
label: string;
|
||||
count: number;
|
||||
status: OperationStatus | "total";
|
||||
color: string;
|
||||
bg: string;
|
||||
border: string;
|
||||
icon: React.ReactNode;
|
||||
}[] = [
|
||||
{
|
||||
label: "전체설비",
|
||||
count: stats.total,
|
||||
status: "total",
|
||||
color: "text-blue-400",
|
||||
bg: "bg-blue-500/10",
|
||||
border: "border-blue-500/30",
|
||||
icon: <Inbox className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
label: "가동중",
|
||||
count: stats.running,
|
||||
status: "running",
|
||||
color: "text-emerald-400",
|
||||
bg: "bg-emerald-500/10",
|
||||
border: "border-emerald-500/30",
|
||||
icon: <Zap className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
label: "대기",
|
||||
count: stats.idle,
|
||||
status: "idle",
|
||||
color: "text-amber-400",
|
||||
bg: "bg-amber-500/10",
|
||||
border: "border-amber-500/30",
|
||||
icon: <Pause className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
label: "점검/수리",
|
||||
count: stats.maintenance,
|
||||
status: "maintenance",
|
||||
color: "text-red-400",
|
||||
bg: "bg-red-500/10",
|
||||
border: "border-red-500/30",
|
||||
icon: <Wrench className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
label: "비가동",
|
||||
count: stats.off + stats.unknown,
|
||||
status: "off",
|
||||
color: "text-gray-400",
|
||||
bg: "bg-gray-500/10",
|
||||
border: "border-gray-500/30",
|
||||
icon: <Power className="h-5 w-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
/* ── 필터 pill ── */
|
||||
const filterPills: { label: string; value: OperationStatus | "all"; color: string }[] = [
|
||||
{ label: "전체", value: "all", color: "bg-blue-500/20 text-blue-300 hover:bg-blue-500/30" },
|
||||
{ label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/30" },
|
||||
{ label: "대기", value: "idle", color: "bg-amber-500/20 text-amber-300 hover:bg-amber-500/30" },
|
||||
{ label: "점검/수리", value: "maintenance", color: "bg-red-500/20 text-red-300 hover:bg-red-500/30" },
|
||||
{ label: "비가동", value: "off", color: "bg-gray-500/20 text-gray-300 hover:bg-gray-500/30" },
|
||||
];
|
||||
|
||||
/* ── 포맷 ── */
|
||||
const formatTime = (d: Date) =>
|
||||
d.toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
|
||||
const formatDate = (d: Date) =>
|
||||
d.toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit", weekday: "short" });
|
||||
|
||||
/* ────────────── 렌더 ────────────── */
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-white p-4 md:p-6 space-y-5">
|
||||
{/* ── 헤더 ── */}
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-1.5 rounded-full bg-amber-400" />
|
||||
<h1 className="text-2xl font-bold tracking-tight">설비운영모니터링</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 현재 시간 */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400 bg-gray-800/60 rounded-lg px-3 py-1.5 border border-gray-700/50">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="font-mono">{formatDate(currentTime)}</span>
|
||||
<span className="font-mono text-white">{formatTime(currentTime)}</span>
|
||||
</div>
|
||||
|
||||
{/* 자동갱신 토글 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"border-gray-700 text-xs gap-1.5",
|
||||
autoRefresh
|
||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20"
|
||||
: "bg-gray-800 text-gray-400 hover:bg-gray-700"
|
||||
)}
|
||||
onClick={() => setAutoRefresh((v) => !v)}
|
||||
>
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", autoRefresh ? "bg-emerald-400 animate-pulse" : "bg-gray-600")} />
|
||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||
</Button>
|
||||
|
||||
{/* 새로고침 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-gray-700 bg-gray-800 text-gray-300 hover:bg-gray-700 gap-1.5"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── 요약 카드 5개 ── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{summaryCards.map((card) => (
|
||||
<button
|
||||
key={card.label}
|
||||
onClick={() =>
|
||||
setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))
|
||||
}
|
||||
className={cn(
|
||||
"relative flex items-center gap-3 rounded-xl border px-4 py-3 transition-all hover:scale-[1.02]",
|
||||
card.bg,
|
||||
card.border,
|
||||
"hover:shadow-lg"
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex items-center justify-center rounded-lg p-2", card.bg, card.color)}>
|
||||
{card.icon}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-xs text-gray-500">{card.label}</p>
|
||||
<p className={cn("text-2xl font-bold tabular-nums", card.color)}>{card.count}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── 필터 pill ── */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filterPills.map((pill) => (
|
||||
<button
|
||||
key={pill.value}
|
||||
onClick={() => setFilterStatus(pill.value)}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
|
||||
filterStatus === pill.value
|
||||
? cn(pill.color, "ring-1 ring-white/20")
|
||||
: "bg-gray-800/60 text-gray-500 hover:text-gray-300 hover:bg-gray-800"
|
||||
)}
|
||||
>
|
||||
{pill.label}
|
||||
</button>
|
||||
))}
|
||||
<span className="ml-auto text-sm text-gray-600 self-center">
|
||||
{filteredEquipments.length}대 표시
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── 로딩 ── */}
|
||||
{loading && equipments.length === 0 && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-600" />
|
||||
<span className="ml-3 text-gray-500">설비 데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 데이터 없음 ── */}
|
||||
{!loading && equipments.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-gray-600">
|
||||
<Inbox className="h-12 w-12 mb-3" />
|
||||
<p className="text-lg">등록된 설비가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 설비 카드 그리드 ── */}
|
||||
{filteredEquipments.length > 0 && (
|
||||
<div
|
||||
className="grid gap-4"
|
||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}
|
||||
>
|
||||
{filteredEquipments.map((eq) => {
|
||||
const status = resolveStatus(eq.operation_status);
|
||||
const cfg = STATUS_MAP[status];
|
||||
const utilization = getUtilization(eq);
|
||||
const eqWIs = wiMap[eq.id] ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={eq.id}
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl border bg-gray-900/80 backdrop-blur transition-all hover:shadow-lg",
|
||||
cfg.border,
|
||||
cfg.cardGlow
|
||||
)}
|
||||
>
|
||||
{/* 좌측 색상 바 */}
|
||||
<div className={cn("absolute left-0 top-0 bottom-0 w-1 rounded-l-xl", cfg.bar)} />
|
||||
|
||||
{/* 상단: 설비명 + 상태 배지 */}
|
||||
<div className="flex items-start justify-between px-4 pt-3 pb-2 pl-5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-base font-semibold text-white truncate">
|
||||
{eq.equipment_name || "이름 없음"}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5 truncate">
|
||||
{eq.equipment_type || "-"} · {eq.installation_location || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
className={cn(
|
||||
"ml-2 shrink-0 border-0 gap-1 text-xs font-medium",
|
||||
cfg.badgeBg,
|
||||
cfg.badgeText
|
||||
)}
|
||||
>
|
||||
{cfg.icon}
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
||||
|
||||
{/* 정보 그리드 */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 pl-5 py-2.5 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">금일 가동시간</span>
|
||||
<p className="text-white font-medium">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">생산수량</span>
|
||||
<p className="text-white font-medium">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">작업자</span>
|
||||
<p className="text-white font-medium">
|
||||
{eqWIs.length > 0 && eqWIs[0].worker_name
|
||||
? eqWIs[0].worker_name
|
||||
: "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">설비코드</span>
|
||||
<p className="text-white font-medium truncate">{eq.equipment_code || "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
||||
|
||||
{/* 가동률 프로그레스 */}
|
||||
<div className="px-4 pl-5 py-2.5">
|
||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
||||
<span className="text-gray-500">가동률</span>
|
||||
<span className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : "text-gray-600")}>
|
||||
{utilization !== null ? `${utilization}%` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-gray-800 overflow-hidden">
|
||||
{utilization !== null && (
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-700", cfg.bar)}
|
||||
style={{ width: `${utilization}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
||||
|
||||
{/* 현재 작업지시 */}
|
||||
<div className="px-4 pl-5 py-2.5">
|
||||
<p className="text-xs text-gray-500 mb-1">현재 작업지시</p>
|
||||
{eqWIs.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{eqWIs.slice(0, 2).map((wi) => (
|
||||
<div key={wi.id} className="flex items-center gap-2 text-sm">
|
||||
<span className="text-blue-400 font-mono text-xs shrink-0">
|
||||
{wi.instruction_number || "-"}
|
||||
</span>
|
||||
<span className="text-gray-300 truncate">
|
||||
{wi.item_name || "-"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{eqWIs.length > 2 && (
|
||||
<p className="text-xs text-gray-600">+{eqWIs.length - 2}건 더</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600 italic">배정된 작업 없음</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="mx-4 ml-5 border-t border-gray-800/80" />
|
||||
|
||||
{/* 센서 데이터 (PLC 미연동) */}
|
||||
<div className="flex items-center gap-4 px-4 pl-5 py-2.5 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-600">온도</span>
|
||||
<span className="text-gray-500 font-mono">-</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-600">압력</span>
|
||||
<span className="text-gray-500 font-mono">-</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-600">RPM</span>
|
||||
<span className="text-gray-500 font-mono">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필터 결과 없음 */}
|
||||
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-600">
|
||||
<Inbox className="h-10 w-10 mb-2" />
|
||||
<p>해당 상태의 설비가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Loader2,
|
||||
Inbox,
|
||||
Timer,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
TrendingUp,
|
||||
Play,
|
||||
Pause,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─── 타입 정의 ─────────────────────────────────────────────
|
||||
interface WorkInstructionDetail {
|
||||
item_name?: string;
|
||||
spec?: string;
|
||||
customer_name?: string;
|
||||
}
|
||||
|
||||
interface WorkInstruction {
|
||||
id: string;
|
||||
wi_id?: string;
|
||||
work_instruction_no: string;
|
||||
status: string; // 일반 / 긴급
|
||||
qty: number;
|
||||
completed_qty: number;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
worker: string | null;
|
||||
equipment_id: string | null;
|
||||
equipment_name?: string | null;
|
||||
details?: WorkInstructionDetail[];
|
||||
}
|
||||
|
||||
interface ProcessStep {
|
||||
wo_id: string;
|
||||
process_name: string;
|
||||
status: string; // acceptable / completed
|
||||
seq_no: number;
|
||||
}
|
||||
|
||||
type FilterTab = "전체" | "대기" | "진행중" | "완료";
|
||||
|
||||
// ─── 유틸리티 ──────────────────────────────────────────────
|
||||
function formatDate(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return "-";
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
const h = String(date.getHours()).padStart(2, "0");
|
||||
const m = String(date.getMinutes()).padStart(2, "0");
|
||||
const s = String(date.getSeconds()).padStart(2, "0");
|
||||
return `${h}:${m}:${s}`;
|
||||
}
|
||||
|
||||
// 작업지시별 공정현황으로 진행상태 계산
|
||||
function computeProgress(
|
||||
wiId: string,
|
||||
processMap: Map<string, ProcessStep[]>
|
||||
): "대기" | "진행중" | "완료" {
|
||||
const steps = processMap.get(wiId);
|
||||
if (!steps || steps.length === 0) return "대기";
|
||||
const completedCount = steps.filter((s) => s.status === "completed").length;
|
||||
if (completedCount === 0) return "대기";
|
||||
if (completedCount === steps.length) return "완료";
|
||||
return "진행중";
|
||||
}
|
||||
|
||||
// ─── 메인 컴포넌트 ────────────────────────────────────────
|
||||
export default function ProductionMonitoringPage() {
|
||||
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
|
||||
const [processMap, setProcessMap] = useState<Map<string, ProcessStep[]>>(new Map());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
|
||||
|
||||
// ─── 실시간 시계 ─────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// ─── 데이터 로드 ─────────────────────────────────────────
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 작업지시 목록 조회
|
||||
const wiRes = await apiClient.get("/work-instruction/list");
|
||||
const wiRaw: WorkInstruction[] =
|
||||
wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
||||
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
|
||||
const seen = new Set<string>();
|
||||
const wiData = wiRaw.filter((wi) => {
|
||||
const key = wi.work_instruction_no || wi.id;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
setWorkInstructions(wiData);
|
||||
|
||||
// 공정현황 조회 (실패해도 작업지시는 표시)
|
||||
try {
|
||||
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
|
||||
page: 1, size: 1000, autoFilter: true,
|
||||
});
|
||||
const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || [];
|
||||
|
||||
// wo_id별로 그룹핑 후, 같은 seq_no+process_name은 1건으로 합침 (배치 실행 이력 중복 제거)
|
||||
const rawMap = new Map<string, ProcessStep[]>();
|
||||
rows.forEach((row) => {
|
||||
const key = String(row.wo_id);
|
||||
if (!rawMap.has(key)) rawMap.set(key, []);
|
||||
rawMap.get(key)!.push(row);
|
||||
});
|
||||
|
||||
const map = new Map<string, ProcessStep[]>();
|
||||
rawMap.forEach((steps, woId) => {
|
||||
// seq_no + process_name 기준으로 대표 1건만 (completed 우선)
|
||||
const grouped = new Map<string, ProcessStep>();
|
||||
for (const s of steps) {
|
||||
const gk = `${s.seq_no}_${s.process_name}`;
|
||||
const existing = grouped.get(gk);
|
||||
if (!existing || (s.status === "completed" && existing.status !== "completed")) {
|
||||
grouped.set(gk, s);
|
||||
}
|
||||
}
|
||||
const deduped = Array.from(grouped.values()).sort((a, b) => (a.seq_no ?? 0) - (b.seq_no ?? 0));
|
||||
map.set(woId, deduped);
|
||||
});
|
||||
setProcessMap(map);
|
||||
} catch {
|
||||
// 공정현황 조회 실패 → 빈 맵 유지
|
||||
setProcessMap(new Map());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("생산모니터링 데이터 조회 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// ─── 자동갱신 (30초) ─────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const timer = setInterval(fetchData, 30000);
|
||||
return () => clearInterval(timer);
|
||||
}, [autoRefresh, fetchData]);
|
||||
|
||||
// ─── 통계 계산 ───────────────────────────────────────────
|
||||
const stats = useMemo(() => {
|
||||
let waiting = 0;
|
||||
let inProgress = 0;
|
||||
let completed = 0;
|
||||
let totalQty = 0;
|
||||
let completedQty = 0;
|
||||
|
||||
workInstructions.forEach((wi) => {
|
||||
const progress = computeProgress(wi.wi_id || wi.id, processMap);
|
||||
if (progress === "대기") waiting++;
|
||||
else if (progress === "진행중") inProgress++;
|
||||
else completed++;
|
||||
totalQty += Number((wi as any).total_qty || wi.qty || 0);
|
||||
completedQty += Number(wi.completed_qty) || 0;
|
||||
});
|
||||
|
||||
const achievementRate = totalQty > 0 ? Math.round((completedQty / totalQty) * 100) : 0;
|
||||
|
||||
return { waiting, inProgress, completed, achievementRate };
|
||||
}, [workInstructions, processMap]);
|
||||
|
||||
// ─── 필터링된 작업 목록 ──────────────────────────────────
|
||||
const filteredInstructions = useMemo(() => {
|
||||
return workInstructions.filter((wi) => {
|
||||
if (activeTab === "전체") return true;
|
||||
const progress = computeProgress(wi.wi_id || wi.id, processMap);
|
||||
return progress === activeTab;
|
||||
});
|
||||
}, [workInstructions, processMap, activeTab]);
|
||||
|
||||
// ─── 렌더링 ──────────────────────────────────────────────
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 bg-background p-4 gap-4 overflow-auto">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between flex-shrink-0">
|
||||
<h1 className="text-2xl font-bold text-foreground">생산모니터링</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground text-sm">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span className="font-mono">{formatTime(currentTime)}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button
|
||||
variant={autoRefresh ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{autoRefresh ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-4 gap-4 flex-shrink-0">
|
||||
<SummaryCard
|
||||
icon={<Timer className="w-5 h-5" />}
|
||||
label="대기중"
|
||||
value={stats.waiting}
|
||||
colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<Loader2 className="w-5 h-5" />}
|
||||
label="진행중"
|
||||
value={stats.inProgress}
|
||||
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<CheckCircle2 className="w-5 h-5" />}
|
||||
label="완료"
|
||||
value={stats.completed}
|
||||
colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<TrendingUp className="w-5 h-5" />}
|
||||
label="달성율"
|
||||
value={`${stats.achievementRate}%`}
|
||||
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 탭 필터 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
|
||||
<Button
|
||||
key={tab}
|
||||
variant={activeTab === tab ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
"min-w-[64px]",
|
||||
activeTab === tab && tab === "대기" && "bg-amber-500 hover:bg-amber-600 text-white",
|
||||
activeTab === tab && tab === "진행중" && "bg-blue-500 hover:bg-blue-600 text-white",
|
||||
activeTab === tab && tab === "완료" && "bg-emerald-500 hover:bg-emerald-600 text-white"
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
{tab === "전체" && ` (${workInstructions.length})`}
|
||||
{tab === "대기" && ` (${stats.waiting})`}
|
||||
{tab === "진행중" && ` (${stats.inProgress})`}
|
||||
{tab === "완료" && ` (${stats.completed})`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 로딩 상태 */}
|
||||
{loading && workInstructions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="w-10 h-10 animate-spin mb-3" />
|
||||
<span className="text-sm">데이터를 불러오는 중입니다...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 빈 상태 */}
|
||||
{!loading && filteredInstructions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Inbox className="w-12 h-12 mb-3" />
|
||||
<span className="text-sm">
|
||||
{activeTab === "전체"
|
||||
? "등록된 작업지시가 없습니다."
|
||||
: `"${activeTab}" 상태의 작업지시가 없습니다.`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 작업 카드 그리드 */}
|
||||
{filteredInstructions.length > 0 && (
|
||||
<div
|
||||
className="grid gap-4 flex-1"
|
||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" }}
|
||||
>
|
||||
{filteredInstructions.map((wi, idx) => (
|
||||
<WorkCard
|
||||
key={wi.id || `wi-${idx}`}
|
||||
instruction={wi}
|
||||
steps={processMap.get(wi.wi_id || wi.id) || []}
|
||||
progress={computeProgress(wi.wi_id || wi.id, processMap)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 요약 카드 ─────────────────────────────────────────────
|
||||
function SummaryCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
colorClass,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: number | string;
|
||||
colorClass: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-card border rounded-lg p-4 flex items-center gap-4">
|
||||
<div className={cn("p-2.5 rounded-lg border", colorClass)}>{icon}</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-2xl font-bold text-foreground">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 작업 카드 ─────────────────────────────────────────────
|
||||
function WorkCard({
|
||||
instruction: wi,
|
||||
steps,
|
||||
progress,
|
||||
}: {
|
||||
instruction: WorkInstruction;
|
||||
steps: ProcessStep[];
|
||||
progress: "대기" | "진행중" | "완료";
|
||||
}) {
|
||||
// API 응답은 flat 구조 (details 배열 아님)
|
||||
const itemName = (wi as any).item_name || "-";
|
||||
const spec = (wi as any).item_spec || "-";
|
||||
const customerName = (wi as any).customer_name || "-";
|
||||
|
||||
// 진척률 (total_qty 또는 qty)
|
||||
const totalQty = Number((wi as any).total_qty || wi.qty || 0);
|
||||
const completedQty = Number(wi.completed_qty || 0);
|
||||
const progressPercent = totalQty > 0 ? Math.min(100, Math.round((completedQty / totalQty) * 100)) : 0;
|
||||
|
||||
// 공정 현황 계산
|
||||
const completedSteps = steps.filter((s) => s.status === "completed").length;
|
||||
const currentStep = steps.find((s) => s.status !== "completed");
|
||||
|
||||
// 프로그레스바 색상
|
||||
const barColor =
|
||||
progressPercent >= 100
|
||||
? "bg-emerald-500"
|
||||
: progressPercent >= 50
|
||||
? "bg-blue-500"
|
||||
: "bg-amber-500";
|
||||
|
||||
// 상태 배지 스타일
|
||||
const statusBadge: Record<string, string> = {
|
||||
"대기": "bg-amber-500/10 text-amber-500 border-amber-500/30",
|
||||
"진행중": "bg-blue-500/10 text-blue-500 border-blue-500/30",
|
||||
"완료": "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
||||
};
|
||||
|
||||
const isUrgent = wi.status === "긴급";
|
||||
|
||||
return (
|
||||
<div className="bg-card border rounded-lg overflow-hidden flex flex-col">
|
||||
{/* 카드 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm text-foreground">
|
||||
{wi.work_instruction_no}
|
||||
</span>
|
||||
{isUrgent && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-red-500/10 text-red-500 border-red-500/30 text-xs gap-1"
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
긴급
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="outline" className={cn("text-xs", statusBadge[progress])}>
|
||||
{progress}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 카드 본문 - 정보 */}
|
||||
<div className="px-4 py-3 grid grid-cols-2 gap-x-4 gap-y-1.5 text-sm border-b">
|
||||
<InfoRow label="품목명" value={itemName} />
|
||||
<InfoRow label="규격" value={spec} />
|
||||
<InfoRow label="거래처" value={customerName} />
|
||||
<InfoRow label="작업자" value={wi.worker || "-"} />
|
||||
<InfoRow label="납기일" value={formatDate(wi.end_date)} />
|
||||
<InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />
|
||||
</div>
|
||||
|
||||
{/* 공정현황 */}
|
||||
<div className="px-4 py-3 border-b">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-muted-foreground font-medium">공정현황</span>
|
||||
{steps.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
완료 {completedSteps}/{steps.length}
|
||||
{currentStep && (
|
||||
<span>
|
||||
{" "}
|
||||
· 현재: <span className="text-blue-400">{currentStep.process_name}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{steps.length > 0 ? (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{steps.map((step, idx) => {
|
||||
const isDone = step.status === "completed";
|
||||
const isCurrent = !isDone && idx === completedSteps;
|
||||
return (
|
||||
<span
|
||||
key={`${step.wo_id}-${step.seq_no}-${idx}`}
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded text-xs font-medium transition-all",
|
||||
isDone && "bg-emerald-500/20 text-emerald-400",
|
||||
isCurrent && "bg-blue-500/20 text-blue-400 animate-pulse",
|
||||
!isDone && !isCurrent && "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{step.process_name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">공정 정보 없음</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 프로그레스바 */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{completedQty} / {totalQty}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-bold",
|
||||
progressPercent >= 100
|
||||
? "text-emerald-500"
|
||||
: progressPercent >= 50
|
||||
? "text-blue-500"
|
||||
: "text-amber-500"
|
||||
)}
|
||||
>
|
||||
{progressPercent}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-500", barColor)}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 정보 행 ───────────────────────────────────────────────
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-muted-foreground shrink-0">{label}:</span>
|
||||
<span className="text-foreground truncate">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Loader2,
|
||||
Inbox,
|
||||
Search,
|
||||
ClipboardCheck,
|
||||
} from "lucide-react";
|
||||
|
||||
/* ───── 타입 ───── */
|
||||
interface ProcessRow {
|
||||
id: number;
|
||||
wo_id: number;
|
||||
process_code: string;
|
||||
process_name: string;
|
||||
status: string;
|
||||
plan_qty: number;
|
||||
input_qty: number;
|
||||
good_qty: number;
|
||||
defect_qty: number;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
worker_name: string;
|
||||
}
|
||||
|
||||
interface InspectionRow {
|
||||
no: number;
|
||||
inspectionNo: string;
|
||||
inspectionType: string;
|
||||
itemName: string;
|
||||
spec: string;
|
||||
inspectionQty: number;
|
||||
goodQty: number;
|
||||
defectQty: number;
|
||||
defectRate: number;
|
||||
result: "합격" | "불합격" | "대기";
|
||||
inspectorName: string;
|
||||
inspectedAt: string;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
/* ───── 탭 정의 ───── */
|
||||
const TABS = [
|
||||
{ key: "all", label: "전체" },
|
||||
{ key: "process", label: "공정검사" },
|
||||
{ key: "incoming", label: "입고검사" },
|
||||
{ key: "shipping", label: "출하검사" },
|
||||
] as const;
|
||||
type TabKey = (typeof TABS)[number]["key"];
|
||||
|
||||
/* ───── 유틸 ───── */
|
||||
const fmt = (n: number) => n.toLocaleString("ko-KR");
|
||||
const pct = (n: number) =>
|
||||
`${n.toFixed(1)}%`;
|
||||
|
||||
const badgeVariant = (
|
||||
type: "result" | "type" | "defectRate",
|
||||
value: string | number,
|
||||
) => {
|
||||
if (type === "result") {
|
||||
if (value === "합격")
|
||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||
if (value === "불합격") return "bg-red-100 text-red-700 border-red-200";
|
||||
return "bg-amber-100 text-amber-700 border-amber-200";
|
||||
}
|
||||
if (type === "type") {
|
||||
if (value === "공정검사")
|
||||
return "bg-purple-100 text-purple-700 border-purple-200";
|
||||
if (value === "입고검사") return "bg-blue-100 text-blue-700 border-blue-200";
|
||||
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||
}
|
||||
// defectRate
|
||||
const rate = typeof value === "number" ? value : parseFloat(String(value));
|
||||
if (rate > 3) return "text-red-600 font-semibold";
|
||||
if (rate >= 1) return "text-amber-600 font-semibold";
|
||||
return "text-emerald-600";
|
||||
};
|
||||
|
||||
/* ───── 컴포넌트 ───── */
|
||||
export default function QualityMonitoringPage() {
|
||||
const [processData, setProcessData] = useState<ProcessRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
/* ───── 시계 ───── */
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
/* ───── 데이터 조회 ───── */
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(
|
||||
"/table-management/tables/work_order_process/data",
|
||||
{ autoFilter: true },
|
||||
);
|
||||
const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? [];
|
||||
setProcessData(rows);
|
||||
} catch (err) {
|
||||
console.error("품질점검현황 데이터 조회 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
/* ───── 자동 갱신 ───── */
|
||||
useEffect(() => {
|
||||
if (autoRefresh) {
|
||||
intervalRef.current = setInterval(fetchData, 30_000);
|
||||
}
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [autoRefresh, fetchData]);
|
||||
|
||||
/* ───── 검사 행 변환 ───── */
|
||||
const inspectionRows: InspectionRow[] = useMemo(() => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
return processData
|
||||
.filter((r) => {
|
||||
// 금일 데이터만
|
||||
const dt = r.completed_at || r.started_at || "";
|
||||
return dt.slice(0, 10) === today;
|
||||
})
|
||||
.map((r, idx) => {
|
||||
const inspQty = r.input_qty || r.plan_qty || 0;
|
||||
const goodQty = r.good_qty ?? 0;
|
||||
const defectQty = r.defect_qty ?? 0;
|
||||
const defectRate = inspQty > 0 ? (defectQty / inspQty) * 100 : 0;
|
||||
const result: InspectionRow["result"] =
|
||||
r.status !== "completed"
|
||||
? "대기"
|
||||
: defectQty > 0
|
||||
? "불합격"
|
||||
: "합격";
|
||||
|
||||
return {
|
||||
no: idx + 1,
|
||||
inspectionNo: `QC-${String(r.id).padStart(8, "0").slice(0, 8)}`,
|
||||
inspectionType: "공정검사",
|
||||
itemName: r.process_name || "-",
|
||||
spec: r.process_code || "-",
|
||||
inspectionQty: inspQty,
|
||||
goodQty,
|
||||
defectQty,
|
||||
defectRate,
|
||||
result,
|
||||
inspectorName: r.worker_name || "-",
|
||||
inspectedAt: r.completed_at || r.started_at || "-",
|
||||
remark: "",
|
||||
};
|
||||
});
|
||||
}, [processData]);
|
||||
|
||||
/* ───── 탭 필터링 ───── */
|
||||
const filteredRows = useMemo(() => {
|
||||
if (activeTab === "all" || activeTab === "process") return inspectionRows;
|
||||
// 입고/출하는 데이터 없음
|
||||
return [];
|
||||
}, [activeTab, inspectionRows]);
|
||||
|
||||
/* ───── 요약 통계 ───── */
|
||||
const summary = useMemo(() => {
|
||||
const total = inspectionRows.length;
|
||||
const passed = inspectionRows.filter((r) => r.result === "합격").length;
|
||||
const failed = inspectionRows.filter((r) => r.result === "불합격").length;
|
||||
const pending = inspectionRows.filter((r) => r.result === "대기").length;
|
||||
const passRate = total > 0 ? (passed / total) * 100 : 0;
|
||||
return { total, passed, failed, pending, passRate };
|
||||
}, [inspectionRows]);
|
||||
|
||||
/* ───── 요약 카드 정의 ───── */
|
||||
const summaryCards = [
|
||||
{
|
||||
label: "금일 검사건수",
|
||||
value: fmt(summary.total),
|
||||
sub: "건",
|
||||
color: "from-slate-500 to-slate-600",
|
||||
textColor: "text-white",
|
||||
},
|
||||
{
|
||||
label: "합격",
|
||||
value: fmt(summary.passed),
|
||||
sub: "건",
|
||||
color: "from-emerald-500 to-emerald-600",
|
||||
textColor: "text-white",
|
||||
},
|
||||
{
|
||||
label: "불합격",
|
||||
value: fmt(summary.failed),
|
||||
sub: "건",
|
||||
color: "from-red-500 to-red-600",
|
||||
textColor: "text-white",
|
||||
},
|
||||
{
|
||||
label: "검사대기",
|
||||
value: fmt(summary.pending),
|
||||
sub: "건",
|
||||
color: "from-amber-500 to-amber-600",
|
||||
textColor: "text-white",
|
||||
},
|
||||
{
|
||||
label: "합격률",
|
||||
value: pct(summary.passRate),
|
||||
sub: "",
|
||||
color: "from-purple-500 to-purple-600",
|
||||
textColor: "text-white",
|
||||
},
|
||||
];
|
||||
|
||||
/* ───── 렌더링 ───── */
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
|
||||
{/* ── 헤더 ── */}
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-800 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
품질점검현황{" "}
|
||||
<span className="text-emerald-600">모니터링</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="font-mono">
|
||||
{currentTime.toLocaleString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
<span className="ml-1">새로고침</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={autoRefresh ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setAutoRefresh((p) => !p)}
|
||||
className={cn(
|
||||
autoRefresh &&
|
||||
"bg-emerald-600 hover:bg-emerald-700 text-white",
|
||||
)}
|
||||
>
|
||||
<Clock className="h-4 w-4 mr-1" />
|
||||
자동갱신 {autoRefresh ? "ON" : "OFF"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 본문 ── */}
|
||||
<div className="flex-1 overflow-auto p-6 space-y-6">
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{summaryCards.map((card) => (
|
||||
<div
|
||||
key={card.label}
|
||||
className={cn(
|
||||
"rounded-xl bg-gradient-to-br p-5 shadow-md",
|
||||
card.color,
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium text-white/80">
|
||||
{card.label}
|
||||
</p>
|
||||
<p className={cn("mt-2 text-3xl font-bold", card.textColor)}>
|
||||
{card.value}
|
||||
{card.sub && (
|
||||
<span className="ml-1 text-base font-normal text-white/70">
|
||||
{card.sub}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 검사유형 탭 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={cn(
|
||||
"px-4 py-1.5 rounded-full text-sm font-medium transition-colors",
|
||||
activeTab === tab.key
|
||||
? "bg-emerald-600 text-white shadow"
|
||||
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 border",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 테이블 영역 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border overflow-hidden">
|
||||
{/* 입고/출하 준비중 */}
|
||||
{(activeTab === "incoming" || activeTab === "shipping") ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
||||
<Search className="h-12 w-12 mb-4 opacity-40" />
|
||||
<p className="text-lg font-medium">준비중</p>
|
||||
<p className="text-sm mt-1">
|
||||
{activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는
|
||||
아직 지원되지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
) : loading && filteredRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
||||
<Loader2 className="h-10 w-10 animate-spin mb-4" />
|
||||
<p>데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
) : filteredRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
||||
<Inbox className="h-12 w-12 mb-4 opacity-40" />
|
||||
<p className="text-lg font-medium">금일 검사 데이터가 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<TableHead className="w-[50px] text-center">No</TableHead>
|
||||
<TableHead className="min-w-[120px]">검사번호</TableHead>
|
||||
<TableHead className="min-w-[90px] text-center">
|
||||
검사유형
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[140px]">품목명</TableHead>
|
||||
<TableHead className="min-w-[100px]">규격</TableHead>
|
||||
<TableHead className="min-w-[80px] text-right">
|
||||
검사수량
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px] text-right">
|
||||
합격수량
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px] text-right">
|
||||
불합격수량
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[70px] text-right">
|
||||
불량율
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[160px] text-center">
|
||||
검사결과
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[70px] text-center">
|
||||
판정
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px] text-center">
|
||||
검사자
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[150px]">검사일시</TableHead>
|
||||
<TableHead className="min-w-[100px]">비고</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRows.map((row) => {
|
||||
const goodPct =
|
||||
row.inspectionQty > 0
|
||||
? (row.goodQty / row.inspectionQty) * 100
|
||||
: 0;
|
||||
const defectPct =
|
||||
row.inspectionQty > 0
|
||||
? (row.defectQty / row.inspectionQty) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={row.no}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/30"
|
||||
>
|
||||
<TableCell className="text-center text-sm text-gray-500">
|
||||
{row.no}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{row.inspectionNo}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs",
|
||||
badgeVariant("type", row.inspectionType),
|
||||
)}
|
||||
>
|
||||
{row.inspectionType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-medium">
|
||||
{row.itemName}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">
|
||||
{row.spec}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm">
|
||||
{fmt(row.inspectionQty)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm text-emerald-600">
|
||||
{fmt(row.goodQty)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm text-red-600">
|
||||
{fmt(row.defectQty)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
"text-right text-sm",
|
||||
badgeVariant("defectRate", row.defectRate),
|
||||
)}
|
||||
>
|
||||
{pct(row.defectRate)}
|
||||
</TableCell>
|
||||
{/* 검사결과 프로그레스바 */}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-3 bg-gray-100 dark:bg-gray-600 rounded-full overflow-hidden flex">
|
||||
<div
|
||||
className="h-full bg-emerald-500 transition-all"
|
||||
style={{ width: `${goodPct}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-red-500 transition-all"
|
||||
style={{ width: `${defectPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap w-[42px] text-right">
|
||||
{pct(goodPct)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* 판정 배지 */}
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs",
|
||||
badgeVariant("result", row.result),
|
||||
)}
|
||||
>
|
||||
{row.result}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{row.inspectorName}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">
|
||||
{row.inspectedAt !== "-"
|
||||
? new Date(row.inspectedAt).toLocaleString(
|
||||
"ko-KR",
|
||||
{
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
},
|
||||
)
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-400">
|
||||
{row.remark || "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
* 외주업체관리와 양방향 연동 (같은 subcontractor_item_mapping 테이블)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
@@ -20,46 +20,35 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import { Plus, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search, Settings2 } from "lucide-react";
|
||||
import { Plus, Save, Loader2, FileSpreadsheet, Download, Pencil, Inbox, Search, RotateCcw, Settings2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
|
||||
const ITEM_TABLE = "item_info";
|
||||
const MAPPING_TABLE = "subcontractor_item_mapping";
|
||||
const SUBCONTRACTOR_TABLE = "subcontractor_mng";
|
||||
|
||||
// 좌측: 품목 컬럼
|
||||
const LEFT_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "item_number", label: "품번", width: "w-[110px]" },
|
||||
{ key: "item_name", label: "품명", minWidth: "min-w-[130px]" },
|
||||
{ key: "size", label: "규격", width: "w-[90px]" },
|
||||
{ key: "unit", label: "단위", width: "w-[60px]" },
|
||||
{ key: "standard_price", label: "기준단가", width: "w-[90px]", formatNumber: true, align: "right" },
|
||||
{ key: "selling_price", label: "판매가격", width: "w-[90px]", formatNumber: true, align: "right" },
|
||||
{ key: "currency_code", label: "통화", width: "w-[50px]" },
|
||||
{ key: "status", label: "상태", width: "w-[60px]" },
|
||||
];
|
||||
const formatNum = (v: any) => (v == null || v === "" ? "-" : Number(v).toLocaleString());
|
||||
|
||||
// 우측: 외주업체 정보 컬럼
|
||||
const RIGHT_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "subcontractor_code", label: "외주업체코드", width: "w-[110px]" },
|
||||
{ key: "subcontractor_name", label: "외주업체명", minWidth: "min-w-[120px]" },
|
||||
{ key: "subcontractor_item_code", label: "외주품번", width: "w-[100px]" },
|
||||
{ key: "subcontractor_item_name", label: "외주품명", width: "w-[100px]" },
|
||||
{ key: "base_price", label: "기준가", width: "w-[80px]", formatNumber: true, align: "right" },
|
||||
{ key: "calculated_price", label: "단가", width: "w-[80px]", formatNumber: true, align: "right" },
|
||||
{ key: "currency_code", label: "통화", width: "w-[50px]" },
|
||||
const GRID_COLUMNS_CONFIG = [
|
||||
{ key: "item_number", label: "품번" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "size", label: "규격" },
|
||||
{ key: "unit", label: "단위" },
|
||||
{ key: "standard_price", label: "기준단가" },
|
||||
{ key: "selling_price", label: "판매가격" },
|
||||
{ key: "currency_code", label: "통화" },
|
||||
{ key: "status", label: "상태" },
|
||||
];
|
||||
|
||||
export default function SubcontractorItemPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
@@ -68,9 +57,8 @@ export default function SubcontractorItemPage() {
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [itemLoading, setItemLoading] = useState(false);
|
||||
const [itemCount, setItemCount] = useState(0);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
||||
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||
const [inputKeyword, setInputKeyword] = useState("");
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
||||
|
||||
// 우측: 외주업체
|
||||
@@ -95,14 +83,8 @@ export default function SubcontractorItemPage() {
|
||||
// 엑셀
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
|
||||
const applyTableSettings = useCallback((settings: TableSettings) => {
|
||||
setFilterConfig(settings.filters);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = loadTableSettings("subcontractor-item");
|
||||
if (saved) applyTableSettings(saved);
|
||||
}, []);
|
||||
// 테이블 설정
|
||||
const ts = useTableSettings("c16-subcontractor-item", ITEM_TABLE, GRID_COLUMNS_CONFIG);
|
||||
|
||||
// 카테고리 로드
|
||||
useEffect(() => {
|
||||
@@ -132,6 +114,19 @@ export default function SubcontractorItemPage() {
|
||||
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
|
||||
const mainTableColumns = useMemo<EDataTableColumn[]>(() => {
|
||||
const cols: EDataTableColumn[] = [];
|
||||
if (ts.isVisible("item_number")) cols.push({ key: "item_number", label: "품번", width: "w-[110px]" });
|
||||
if (ts.isVisible("item_name")) cols.push({ key: "item_name", label: "품명", minWidth: "min-w-[130px]", render: (v) => v || "-" });
|
||||
if (ts.isVisible("size")) cols.push({ key: "size", label: "규격", width: "w-[90px]", render: (v) => v || "-" });
|
||||
if (ts.isVisible("unit")) cols.push({ key: "unit", label: "단위", width: "w-[60px]", render: (v) => v || "-" });
|
||||
if (ts.isVisible("standard_price")) cols.push({ key: "standard_price", label: "기준단가", width: "w-[90px]", align: "right", formatNumber: true });
|
||||
if (ts.isVisible("selling_price")) cols.push({ key: "selling_price", label: "판매가격", width: "w-[90px]", align: "right", formatNumber: true });
|
||||
if (ts.isVisible("currency_code")) cols.push({ key: "currency_code", label: "통화", width: "w-[50px]", render: (v) => v || "-" });
|
||||
if (ts.isVisible("status")) cols.push({ key: "status", label: "상태", width: "w-[60px]", render: (v) => v || "-" });
|
||||
return cols;
|
||||
}, [ts.visibleColumns]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링)
|
||||
const outsourcingDivisionCode = categoryOptions["division"]?.find(
|
||||
(o) => o.label === "외주관리" || o.label === "외주" || o.label.includes("외주")
|
||||
@@ -140,11 +135,13 @@ export default function SubcontractorItemPage() {
|
||||
const fetchItems = useCallback(async () => {
|
||||
setItemLoading(true);
|
||||
try {
|
||||
const filters: any[] = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||
// division = 외주관리 필터 추가
|
||||
const filters: any[] = [];
|
||||
if (outsourcingDivisionCode) {
|
||||
filters.push({ columnName: "division", operator: "equals", value: outsourcingDivisionCode });
|
||||
}
|
||||
if (searchKeyword) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: searchKeyword });
|
||||
}
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
@@ -167,7 +164,7 @@ export default function SubcontractorItemPage() {
|
||||
} finally {
|
||||
setItemLoading(false);
|
||||
}
|
||||
}, [searchFilters, categoryOptions, outsourcingDivisionCode]);
|
||||
}, [searchKeyword, categoryOptions, outsourcingDivisionCode]);
|
||||
|
||||
useEffect(() => { fetchItems(); }, [fetchItems]);
|
||||
|
||||
@@ -181,7 +178,6 @@ export default function SubcontractorItemPage() {
|
||||
const fetchSubcontractorItems = async () => {
|
||||
setSubcontractorLoading(true);
|
||||
try {
|
||||
// subcontractor_item_mapping에서 해당 품목의 매핑 조회
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
|
||||
@@ -189,7 +185,6 @@ export default function SubcontractorItemPage() {
|
||||
});
|
||||
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
|
||||
|
||||
// subcontractor_id → subcontractor_mng 조인 (외주업체명)
|
||||
const subIds = [...new Set(mappings.map((m: any) => m.subcontractor_id).filter(Boolean))];
|
||||
let subMap: Record<string, any> = {};
|
||||
if (subIds.length > 0) {
|
||||
@@ -231,7 +226,6 @@ export default function SubcontractorItemPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const all = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
// 이미 등록된 외주업체 제외
|
||||
const existing = new Set(subcontractorItems.map((s: any) => s.subcontractor_id || s.subcontractor_code));
|
||||
setSubSearchResults(all.filter((s: any) => !existing.has(s.subcontractor_code)));
|
||||
} catch { /* skip */ } finally { setSubSearchLoading(false); }
|
||||
@@ -244,6 +238,7 @@ export default function SubcontractorItemPage() {
|
||||
try {
|
||||
for (const sub of selected) {
|
||||
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
subcontractor_id: sub.subcontractor_code,
|
||||
item_id: selectedItem.item_number,
|
||||
});
|
||||
@@ -251,7 +246,6 @@ export default function SubcontractorItemPage() {
|
||||
toast.success(`${selected.length}개 외주업체가 추가되었습니다.`);
|
||||
setSubCheckedIds(new Set());
|
||||
setSubSelectOpen(false);
|
||||
// 우측 새로고침
|
||||
const sid = selectedItemId;
|
||||
setSelectedItemId(null);
|
||||
setTimeout(() => setSelectedItemId(sid), 50);
|
||||
@@ -300,56 +294,74 @@ export default function SubcontractorItemPage() {
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
|
||||
const handleSearch = () => setSearchKeyword(inputKeyword);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
{/* 검색 */}
|
||||
<DynamicSearchFilter
|
||||
tableName={ITEM_TABLE}
|
||||
filterId="subcontractor-item"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={itemCount}
|
||||
externalFilterConfig={filterConfig}
|
||||
extraActions={
|
||||
<div className="flex gap-1.5">
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
|
||||
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" /> 엑셀 업로드
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{/* 브레드크럼 */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
|
||||
<span>외주관리</span>
|
||||
<span className="text-muted-foreground/50">/</span>
|
||||
<span className="text-foreground font-medium">외주품목정보</span>
|
||||
</div>
|
||||
|
||||
{/* 검색 바 */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-card border rounded-lg shrink-0">
|
||||
<span className="text-[11px] font-semibold text-muted-foreground whitespace-nowrap">품명</span>
|
||||
<Input
|
||||
className="h-9 w-[200px]"
|
||||
placeholder="품명 검색"
|
||||
value={inputKeyword}
|
||||
onChange={(e) => setInputKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => { setInputKeyword(""); setSearchKeyword(""); }}>
|
||||
<RotateCcw className="w-3.5 h-3.5 mr-1" /> 초기화
|
||||
</Button>
|
||||
<Button size="sm" className="h-9" onClick={handleSearch}>
|
||||
<Search className="w-3.5 h-3.5 mr-1" /> 조회
|
||||
</Button>
|
||||
<div className="ml-auto flex gap-1.5">
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" /> 엑셀 업로드
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 분할 패널 */}
|
||||
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
|
||||
<div className="flex-1 overflow-hidden border rounded-lg bg-card">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
{/* 좌측: 외주품목 목록 */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
<Package className="w-4 h-4" /> 외주품목 목록
|
||||
<Badge variant="secondary" className="font-normal">{itemCount}건</Badge>
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/50 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-[13px] font-bold">외주품목 목록</h3>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">{itemCount}건</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button variant="outline" size="sm" disabled={!selectedItemId} onClick={openEditItem}>
|
||||
<Pencil className="w-3.5 h-3.5 mr-1" /> 수정
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)}>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DataGrid
|
||||
gridId="subcontractor-item-left"
|
||||
columns={LEFT_COLUMNS}
|
||||
data={items}
|
||||
<EDataTable
|
||||
columns={mainTableColumns}
|
||||
data={ts.groupData(items)}
|
||||
loading={itemLoading}
|
||||
emptyMessage="등록된 외주품목이 없어요"
|
||||
selectedId={selectedItemId}
|
||||
onSelect={setSelectedItemId}
|
||||
onSelect={(id) => setSelectedItemId(id)}
|
||||
onRowDoubleClick={() => openEditItem()}
|
||||
emptyMessage="등록된 외주품목이 없습니다"
|
||||
showPagination={true}
|
||||
draggableColumns={false}
|
||||
columnOrderKey="c16-subcontractor-item-main"
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
@@ -359,9 +371,9 @@ export default function SubcontractorItemPage() {
|
||||
{/* 우측: 외주업체 정보 */}
|
||||
<ResizablePanel defaultSize={45} minSize={25}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
<Users className="w-4 h-4" /> 외주업체 정보
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/50 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-[13px] font-bold">외주업체 정보</h3>
|
||||
{selectedItem && <Badge variant="outline" className="font-normal">{selectedItem.item_name}</Badge>}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" disabled={!selectedItemId}
|
||||
@@ -370,18 +382,51 @@ export default function SubcontractorItemPage() {
|
||||
</Button>
|
||||
</div>
|
||||
{!selectedItemId ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
||||
좌측에서 품목을 선택하세요
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 m-3 border-2 border-dashed rounded-lg text-center">
|
||||
<Inbox className="w-12 h-12 text-muted-foreground/40" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-muted-foreground">품목을 선택해주세요</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">좌측에서 품목을 선택하면 외주업체 정보가 표시돼요</p>
|
||||
</div>
|
||||
</div>
|
||||
) : subcontractorLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : subcontractorItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Inbox className="w-8 h-8 mb-2 opacity-40" />
|
||||
<p className="text-sm">등록된 외주업체가 없어요</p>
|
||||
</div>
|
||||
) : (
|
||||
<DataGrid
|
||||
gridId="subcontractor-item-right"
|
||||
columns={RIGHT_COLUMNS}
|
||||
data={subcontractorItems}
|
||||
loading={subcontractorLoading}
|
||||
showRowNumber={false}
|
||||
emptyMessage="등록된 외주업체가 없습니다"
|
||||
/>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">외주업체코드</TableHead>
|
||||
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">외주업체명</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">외주품번</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">외주품명</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">기준가</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단가</TableHead>
|
||||
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">통화</TableHead>
|
||||
</TableRow>
|
||||
</thead>
|
||||
<TableBody>
|
||||
{subcontractorItems.map((item, idx) => (
|
||||
<TableRow key={item.id || idx}>
|
||||
<TableCell className="text-[13px] font-mono">{item.subcontractor_code || "-"}</TableCell>
|
||||
<TableCell className="text-sm">{item.subcontractor_name || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.subcontractor_item_code || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.subcontractor_item_name || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono">{formatNum(item.base_price)}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono">{formatNum(item.calculated_price)}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.currency_code || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
@@ -389,68 +434,64 @@ export default function SubcontractorItemPage() {
|
||||
</div>
|
||||
|
||||
{/* 품목 수정 모달 */}
|
||||
<FullscreenDialog
|
||||
open={editItemOpen}
|
||||
onOpenChange={setEditItemOpen}
|
||||
title="외주품목 수정"
|
||||
description={`${editItemForm.item_number || ""} — ${editItemForm.item_name || ""}`}
|
||||
defaultMaxWidth="max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<Dialog open={editItemOpen} onOpenChange={setEditItemOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>외주품목 수정</DialogTitle>
|
||||
<DialogDescription>{editItemForm.item_number || ""} — {editItemForm.item_name || ""}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
{[
|
||||
{ key: "item_number", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "size", label: "규격" },
|
||||
{ key: "unit", label: "단위" },
|
||||
{ key: "material", label: "재질" },
|
||||
{ key: "status", label: "상태" },
|
||||
].map((f) => (
|
||||
<div key={f.key} className="space-y-1.5">
|
||||
<Label className="text-sm text-muted-foreground">{f.label}</Label>
|
||||
<Input value={editItemForm[f.key] || ""} className="h-9 bg-muted/50" disabled />
|
||||
</div>
|
||||
))}
|
||||
<div className="col-span-2 border-t my-2" />
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">판매가격</Label>
|
||||
<Input value={editItemForm.selling_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, selling_price: e.target.value }))}
|
||||
placeholder="판매가격" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">기준단가</Label>
|
||||
<Input value={editItemForm.standard_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))}
|
||||
placeholder="기준단가" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">통화</Label>
|
||||
<Select value={editItemForm.currency_code || ""} onValueChange={(v) => setEditItemForm((p) => ({ ...p, currency_code: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="통화" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditItemOpen(false)}>취소</Button>
|
||||
<Button onClick={handleEditSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
{[
|
||||
{ key: "item_number", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "size", label: "규격" },
|
||||
{ key: "unit", label: "단위" },
|
||||
{ key: "material", label: "재질" },
|
||||
{ key: "status", label: "상태" },
|
||||
].map((f) => (
|
||||
<div key={f.key} className="space-y-1.5">
|
||||
<Label className="text-sm text-muted-foreground">{f.label}</Label>
|
||||
<Input value={editItemForm[f.key] || ""} className="h-9 bg-muted/50" disabled />
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="col-span-2 border-t my-2" />
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">판매가격</Label>
|
||||
<Input value={editItemForm.selling_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, selling_price: e.target.value }))}
|
||||
placeholder="판매가격" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">기준단가</Label>
|
||||
<Input value={editItemForm.standard_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))}
|
||||
placeholder="기준단가" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">통화</Label>
|
||||
<Select value={editItemForm.currency_code || ""} onValueChange={(v) => setEditItemForm((p) => ({ ...p, currency_code: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="통화" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</FullscreenDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 외주업체 추가 모달 */}
|
||||
<Dialog open={subSelectOpen} onOpenChange={setSubSelectOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[70vh]">
|
||||
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>외주업체 선택</DialogTitle>
|
||||
<DialogDescription>품목에 추가할 외주업체를 선택하세요.</DialogDescription>
|
||||
<DialogDescription>품목에 추가할 외주업체를 선택해주세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder="외주업체명 검색" value={subSearchKeyword}
|
||||
onChange={(e) => setSubSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && searchSubcontractors()}
|
||||
@@ -459,9 +500,9 @@ export default function SubcontractorItemPage() {
|
||||
{subSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> 조회</>}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[350px] border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<div className="flex-1 overflow-auto border rounded-lg">
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<input type="checkbox"
|
||||
@@ -471,15 +512,15 @@ export default function SubcontractorItemPage() {
|
||||
else setSubCheckedIds(new Set());
|
||||
}} />
|
||||
</TableHead>
|
||||
<TableHead className="w-[110px]">외주업체코드</TableHead>
|
||||
<TableHead className="min-w-[130px]">외주업체명</TableHead>
|
||||
<TableHead className="w-[80px]">거래유형</TableHead>
|
||||
<TableHead className="w-[80px]">담당자</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">외주업체코드</TableHead>
|
||||
<TableHead className="min-w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">외주업체명</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">거래유형</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">담당자</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</thead>
|
||||
<TableBody>
|
||||
{subSearchResults.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">검색 결과가 없습니다</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">검색 결과가 없어요</TableCell></TableRow>
|
||||
) : subSearchResults.map((s) => (
|
||||
<TableRow key={s.id} className={cn("cursor-pointer", subCheckedIds.has(s.id) && "bg-primary/5")}
|
||||
onClick={() => setSubCheckedIds((prev) => {
|
||||
@@ -488,10 +529,10 @@ export default function SubcontractorItemPage() {
|
||||
return next;
|
||||
})}>
|
||||
<TableCell className="text-center"><input type="checkbox" checked={subCheckedIds.has(s.id)} readOnly /></TableCell>
|
||||
<TableCell className="text-xs">{s.subcontractor_code}</TableCell>
|
||||
<TableCell className="text-[13px]">{s.subcontractor_code}</TableCell>
|
||||
<TableCell className="text-sm">{s.subcontractor_name}</TableCell>
|
||||
<TableCell className="text-xs">{s.division}</TableCell>
|
||||
<TableCell className="text-xs">{s.contact_person}</TableCell>
|
||||
<TableCell className="text-[13px]">{s.division}</TableCell>
|
||||
<TableCell className="text-[13px]">{s.contact_person}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -520,12 +561,14 @@ export default function SubcontractorItemPage() {
|
||||
onSuccess={() => fetchItems()}
|
||||
/>
|
||||
|
||||
{/* 테이블 설정 */}
|
||||
<TableSettingsModal
|
||||
open={tableSettingsOpen}
|
||||
onOpenChange={setTableSettingsOpen}
|
||||
tableName={ITEM_TABLE}
|
||||
settingsId="subcontractor-item"
|
||||
onSave={applyTableSettings}
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -80,13 +80,13 @@ import TimelineScheduler, {
|
||||
type ZoomLevel,
|
||||
type StatusColor,
|
||||
} from "@/components/common/TimelineScheduler";
|
||||
import { DynamicSearchFilter, type FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
|
||||
// ─── 상수 ───
|
||||
|
||||
@@ -104,6 +104,19 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
completed: "완료",
|
||||
};
|
||||
|
||||
// 수주 테이블 컬럼 설정
|
||||
const ORDER_COLUMNS = [
|
||||
{ key: "total_order_qty", label: "총수주량" },
|
||||
{ key: "total_ship_qty", label: "출고량" },
|
||||
{ key: "total_balance_qty", label: "잔량" },
|
||||
{ key: "current_stock", label: "현재고" },
|
||||
{ key: "safety_stock", label: "안전재고" },
|
||||
{ key: "existing_plan_qty", label: "기생산계획량" },
|
||||
{ key: "in_progress_qty", label: "생산진행" },
|
||||
{ key: "required_plan_qty", label: "필요생산계획" },
|
||||
{ key: "lead_time", label: "리드타임(일)" },
|
||||
];
|
||||
|
||||
// ========== 메인 컴포넌트 ==========
|
||||
|
||||
export default function ProductionPlanManagementPage() {
|
||||
@@ -134,15 +147,15 @@ export default function ProductionPlanManagementPage() {
|
||||
const [filterUnplannedOrdersOnly, setFilterUnplannedOrdersOnly] = useState(false);
|
||||
const [selectedStockItems, setSelectedStockItems] = useState<Set<string>>(new Set());
|
||||
|
||||
// 검색 필터 (DynamicSearchFilter에서 사용)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
||||
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||
const [searchItemCode, setSearchItemCode] = useState("");
|
||||
const [searchStatus, setSearchStatus] = useState("all");
|
||||
const [searchStartDate, setSearchStartDate] = useState("");
|
||||
const [searchEndDate, setSearchEndDate] = useState("");
|
||||
|
||||
// 테이블 설정
|
||||
const ts = useTableSettings("c16-plan-management", "sales_order_mng", ORDER_COLUMNS);
|
||||
|
||||
// 타임라인 옵션
|
||||
const [recalculateUnstarted, setRecalculateUnstarted] = useState(true);
|
||||
const [finishedZoom, setFinishedZoom] = useState<ZoomLevel>("week");
|
||||
@@ -256,39 +269,33 @@ export default function ProductionPlanManagementPage() {
|
||||
|
||||
// ========== DynamicSearchFilter 콜백 ==========
|
||||
|
||||
const handleSearchFilterChange = useCallback(
|
||||
(filters: FilterValue[]) => {
|
||||
setSearchFilters(filters);
|
||||
// 필터에서 주요 값 추출
|
||||
let itemCode = "";
|
||||
let status = "all";
|
||||
let startDate = "";
|
||||
let endDate = "";
|
||||
for (const f of filters) {
|
||||
if (f.columnName === "item_code" && f.value) itemCode = f.value;
|
||||
if (f.columnName === "status" && f.value) status = f.value;
|
||||
if (f.columnName === "start_date" && f.value) {
|
||||
const [s, e] = f.value.split(",");
|
||||
if (s) startDate = s;
|
||||
if (e) endDate = e;
|
||||
}
|
||||
const handleSearchFilterChange = useCallback((filters: FilterValue[]) => {
|
||||
setSearchFilters(filters);
|
||||
let itemCode = "";
|
||||
let status = "all";
|
||||
let startDate = "";
|
||||
let endDate = "";
|
||||
for (const f of filters) {
|
||||
if (f.columnName === "item_code" && f.value) itemCode = f.value;
|
||||
if (f.columnName === "status" && f.value) status = f.value;
|
||||
if (f.columnName === "start_date" && f.operator === "between" && f.value) {
|
||||
const [s, e] = f.value.split("|");
|
||||
if (s) startDate = s;
|
||||
if (e) endDate = e;
|
||||
}
|
||||
setSearchItemCode(itemCode);
|
||||
setSearchStatus(status);
|
||||
setSearchStartDate(startDate);
|
||||
setSearchEndDate(endDate);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const applyTableSettings = useCallback((settings: TableSettings) => {
|
||||
setFilterConfig(settings.filters);
|
||||
}
|
||||
setSearchItemCode(itemCode);
|
||||
setSearchStatus(status || "all");
|
||||
setSearchStartDate(startDate);
|
||||
setSearchEndDate(endDate);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = loadTableSettings("production-plan");
|
||||
if (saved) applyTableSettings(saved);
|
||||
}, []);
|
||||
if (searchFilters.length > 0) {
|
||||
fetchOrderSummary();
|
||||
fetchPlans();
|
||||
}
|
||||
}, [searchItemCode, searchStatus, searchStartDate, searchEndDate]);
|
||||
|
||||
// ========== 토글/선택 핸들러 ==========
|
||||
|
||||
@@ -912,29 +919,28 @@ export default function ProductionPlanManagementPage() {
|
||||
// 숫자 포맷
|
||||
const formatNumber = (num: number | string) => Number(num).toLocaleString();
|
||||
|
||||
// 컬럼 표시 여부
|
||||
const isColVisible = (key: string) => ts.isVisible(key);
|
||||
const orderColSpan = 4 + ORDER_COLUMNS.filter((c) => isColVisible(c.key)).length;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col bg-muted/30 gap-4", isFullscreen ? "fixed inset-0 z-50 p-4" : "h-[calc(100vh-4rem)] p-5")}>
|
||||
<div className={cn("flex flex-col gap-3", isFullscreen ? "fixed inset-0 z-50 bg-background p-4" : "h-full p-3")}>
|
||||
{/* 브레드크럼 */}
|
||||
<nav className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
|
||||
<span>생산관리</span>
|
||||
<span className="text-muted-foreground/50">/</span>
|
||||
<span className="font-medium text-foreground">생산계획</span>
|
||||
</nav>
|
||||
|
||||
{/* 상단 바 */}
|
||||
<div className="shrink-0 rounded-lg border bg-background p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<DynamicSearchFilter
|
||||
tableName="production_plan_mng"
|
||||
filterId="production-plan"
|
||||
onFilterChange={handleSearchFilterChange}
|
||||
dataCount={finishedPlans.length + semiPlans.length}
|
||||
externalFilterConfig={filterConfig}
|
||||
extraActions={
|
||||
<Button size="sm" onClick={handleSearch}>
|
||||
<RefreshCw className="mr-1.5 h-4 w-4" />
|
||||
조회
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button variant="outline" size="sm" onClick={() => setTableSettingsOpen(true)}>
|
||||
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="text-emerald-600 dark:text-emerald-400 border-emerald-500/30 hover:bg-emerald-500/10" onClick={() => setExcelUploadOpen(true)}>
|
||||
<DynamicSearchFilter
|
||||
tableName="sales_order_mng"
|
||||
filterId="c16-plan-management"
|
||||
onFilterChange={handleSearchFilterChange}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
extraActions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="text-success border-success/30 hover:bg-success/10" onClick={() => setExcelUploadOpen(true)}>
|
||||
<Upload className="mr-1.5 h-4 w-4" />
|
||||
엑셀업로드
|
||||
</Button>
|
||||
@@ -946,14 +952,14 @@ export default function ProductionPlanManagementPage() {
|
||||
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 데이터 섹션 */}
|
||||
<ResizablePanelGroup direction="horizontal" className="flex-1 overflow-hidden rounded-lg">
|
||||
{/* 왼쪽 패널 */}
|
||||
<ResizablePanel defaultSize={45} minSize={25}>
|
||||
<div className="flex h-full flex-col rounded-lg border bg-background shadow-sm">
|
||||
<div className="flex h-full flex-col rounded-lg border bg-card">
|
||||
<Tabs value={leftTab} onValueChange={setLeftTab} className="flex h-full flex-col">
|
||||
<div className="shrink-0 border-b bg-muted/30 px-5">
|
||||
<TabsList className="h-11 bg-transparent gap-1">
|
||||
@@ -984,7 +990,7 @@ export default function ProductionPlanManagementPage() {
|
||||
/>
|
||||
<span className="font-medium text-foreground">계획에 없는 품목만</span>
|
||||
</label>
|
||||
<Button size="sm" variant="outline" className="text-emerald-600 dark:text-emerald-400 border-emerald-500/30 hover:bg-emerald-500/10" onClick={() => setOrderImportModalOpen(true)}>
|
||||
<Button size="sm" variant="outline" className="text-success border-success/30 hover:bg-success/10" onClick={() => setOrderImportModalOpen(true)}>
|
||||
<Download className="mr-1 h-3.5 w-3.5" />
|
||||
불러오기
|
||||
</Button>
|
||||
@@ -996,6 +1002,9 @@ export default function ProductionPlanManagementPage() {
|
||||
{loadingOrders ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="mr-1 h-3.5 w-3.5" />}
|
||||
새로고침
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => ts.setOpen(true)}>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
@@ -1010,29 +1019,46 @@ export default function ProductionPlanManagementPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[30px]">
|
||||
<Checkbox checked={selectedItemGroups.size === orderItems.length && orderItems.length > 0} onCheckedChange={(c) => toggleAllItemGroups(!!c)} className="h-4 w-4" />
|
||||
</TableHead>
|
||||
<TableHead className="w-[40px]" />
|
||||
<TableHead className="text-xs font-semibold whitespace-nowrap">품목코드</TableHead>
|
||||
<TableHead className="text-xs font-semibold whitespace-nowrap">품목명</TableHead>
|
||||
<TableHead className="text-xs font-semibold whitespace-nowrap text-right">총수주량</TableHead>
|
||||
<TableHead className="text-xs font-semibold whitespace-nowrap text-right">출고량</TableHead>
|
||||
<TableHead className="text-xs font-semibold whitespace-nowrap text-right">잔량</TableHead>
|
||||
<TableHead className="text-xs font-semibold whitespace-nowrap text-right">현재고</TableHead>
|
||||
<TableHead className="text-xs font-semibold whitespace-nowrap text-right">안전재고</TableHead>
|
||||
<TableHead className="text-xs font-semibold whitespace-nowrap text-right">기생산계획량</TableHead>
|
||||
<TableHead className="text-xs font-semibold whitespace-nowrap text-right">생산진행</TableHead>
|
||||
<TableHead className="text-xs font-semibold whitespace-nowrap text-right">필요생산계획</TableHead>
|
||||
<TableHead className="text-xs font-semibold whitespace-nowrap text-right">리드타임(일)</TableHead>
|
||||
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>
|
||||
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
{isColVisible("total_order_qty") && <TableHead style={ts.thStyle("total_order_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">총수주량</TableHead>}
|
||||
{isColVisible("total_ship_qty") && <TableHead style={ts.thStyle("total_ship_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">출고량</TableHead>}
|
||||
{isColVisible("total_balance_qty") && <TableHead style={ts.thStyle("total_balance_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">잔량</TableHead>}
|
||||
{isColVisible("current_stock") && <TableHead style={ts.thStyle("current_stock")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">현재고</TableHead>}
|
||||
{isColVisible("safety_stock") && <TableHead style={ts.thStyle("safety_stock")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">안전재고</TableHead>}
|
||||
{isColVisible("existing_plan_qty") && <TableHead style={ts.thStyle("existing_plan_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">기생산계획량</TableHead>}
|
||||
{isColVisible("in_progress_qty") && <TableHead style={ts.thStyle("in_progress_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">생산진행</TableHead>}
|
||||
{isColVisible("required_plan_qty") && <TableHead style={ts.thStyle("required_plan_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">필요생산계획</TableHead>}
|
||||
{isColVisible("lead_time") && <TableHead style={ts.thStyle("lead_time")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">리드타임(일)</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{orderItems.map((item) => (
|
||||
<React.Fragment key={item.item_code}>
|
||||
{ts.groupData(orderItems).map((item, rowIdx) => {
|
||||
if (item._isGroupSummary) {
|
||||
return (
|
||||
<TableRow key={`summary-${rowIdx}`} className="bg-muted/60 font-semibold border-t border-primary/20">
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
{ts.visibleColumns.map((col) => {
|
||||
const v = (item as any)[col.key];
|
||||
return (
|
||||
<TableCell key={col.key} style={ts.thStyle(col.key)} className={typeof v === "number" ? "text-right font-mono text-[13px]" : "text-[13px] text-primary"}>
|
||||
{typeof v === "number" ? Number(v).toLocaleString() : (v || "")}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<React.Fragment key={item.item_code || rowIdx}>
|
||||
<TableRow className={cn("cursor-pointer border-t-2 border-t-primary/30 bg-primary/5 font-semibold hover:bg-primary/10", selectedItemGroups.has(item.item_code) && "bg-primary/10")}>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={selectedItemGroups.has(item.item_code)} onCheckedChange={() => toggleItemGroupSelect(item.item_code)} className="h-4 w-4" />
|
||||
@@ -1040,21 +1066,25 @@ export default function ProductionPlanManagementPage() {
|
||||
<TableCell className="text-center" onClick={() => toggleItemExpand(item.item_code)}>
|
||||
<ChevronRight className={cn("h-4 w-4 transition-transform duration-200", expandedItems.has(item.item_code) && "rotate-90")} />
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-primary" onClick={() => toggleItemExpand(item.item_code)}>{item.item_code}</TableCell>
|
||||
<TableCell className="text-xs text-primary" onClick={() => toggleItemExpand(item.item_code)}>{item.item_name}</TableCell>
|
||||
<TableCell className="text-xs text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.total_order_qty)}</TableCell>
|
||||
<TableCell className="text-xs text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.total_ship_qty)}</TableCell>
|
||||
<TableCell className="text-xs text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.total_balance_qty)}</TableCell>
|
||||
<TableCell className="text-xs text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.current_stock)}</TableCell>
|
||||
<TableCell className="text-xs text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.safety_stock)}</TableCell>
|
||||
<TableCell className="text-xs text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.existing_plan_qty)}</TableCell>
|
||||
<TableCell className="text-xs text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.in_progress_qty)}</TableCell>
|
||||
<TableCell className={cn("text-xs text-right font-bold", Number(item.required_plan_qty) > 0 ? "text-destructive" : "text-emerald-600 dark:text-emerald-400")} onClick={() => toggleItemExpand(item.item_code)}>
|
||||
{formatNumber(item.required_plan_qty)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>
|
||||
{Number(item.lead_time) > 0 ? `${item.lead_time}일` : "-"}
|
||||
</TableCell>
|
||||
<TableCell style={{ width: "140px", minWidth: "140px" }} className="text-[13px] text-primary truncate" onClick={() => toggleItemExpand(item.item_code)}>{item.item_code}</TableCell>
|
||||
<TableCell style={{ width: "140px", minWidth: "140px" }} className="text-[13px] text-primary truncate" onClick={() => toggleItemExpand(item.item_code)}>{item.item_name}</TableCell>
|
||||
{isColVisible("total_order_qty") && <TableCell style={ts.thStyle("total_order_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.total_order_qty)}</TableCell>}
|
||||
{isColVisible("total_ship_qty") && <TableCell style={ts.thStyle("total_ship_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.total_ship_qty)}</TableCell>}
|
||||
{isColVisible("total_balance_qty") && <TableCell style={ts.thStyle("total_balance_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.total_balance_qty)}</TableCell>}
|
||||
{isColVisible("current_stock") && <TableCell style={ts.thStyle("current_stock")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.current_stock)}</TableCell>}
|
||||
{isColVisible("safety_stock") && <TableCell style={ts.thStyle("safety_stock")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.safety_stock)}</TableCell>}
|
||||
{isColVisible("existing_plan_qty") && <TableCell style={ts.thStyle("existing_plan_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.existing_plan_qty)}</TableCell>}
|
||||
{isColVisible("in_progress_qty") && <TableCell style={ts.thStyle("in_progress_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.in_progress_qty)}</TableCell>}
|
||||
{isColVisible("required_plan_qty") && (
|
||||
<TableCell style={ts.thStyle("required_plan_qty")} className={cn("text-[13px] text-right font-bold", Number(item.required_plan_qty) > 0 ? "text-destructive" : "text-success")} onClick={() => toggleItemExpand(item.item_code)}>
|
||||
{formatNumber(item.required_plan_qty)}
|
||||
</TableCell>
|
||||
)}
|
||||
{isColVisible("lead_time") && (
|
||||
<TableCell style={ts.thStyle("lead_time")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>
|
||||
{Number(item.lead_time) > 0 ? `${item.lead_time}일` : "-"}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
{expandedItems.has(item.item_code) && item.orders?.map((detail) => (
|
||||
<TableRow key={detail.id || detail.order_no} className="cursor-pointer hover:bg-muted/50">
|
||||
@@ -1071,16 +1101,17 @@ export default function ProductionPlanManagementPage() {
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-right">{formatNumber(detail.order_qty)}</TableCell>
|
||||
<TableCell className="text-xs text-right">{formatNumber(detail.ship_qty)}</TableCell>
|
||||
<TableCell className="text-xs text-right">{formatNumber(detail.balance_qty)}</TableCell>
|
||||
<TableCell colSpan={6} className="text-xs text-muted-foreground">
|
||||
{isColVisible("total_order_qty") && <TableCell style={ts.thStyle("total_order_qty")} className="text-[13px] text-right">{formatNumber(detail.order_qty)}</TableCell>}
|
||||
{isColVisible("total_ship_qty") && <TableCell style={ts.thStyle("total_ship_qty")} className="text-[13px] text-right">{formatNumber(detail.ship_qty)}</TableCell>}
|
||||
{isColVisible("total_balance_qty") && <TableCell style={ts.thStyle("total_balance_qty")} className="text-[13px] text-right">{formatNumber(detail.balance_qty)}</TableCell>}
|
||||
<TableCell colSpan={orderColSpan - 2 - (isColVisible("total_order_qty") ? 1 : 0) - (isColVisible("total_ship_qty") ? 1 : 0) - (isColVisible("total_balance_qty") ? 1 : 0)} className="text-[13px] text-muted-foreground">
|
||||
납기일: {detail.due_date || "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
@@ -1093,7 +1124,7 @@ export default function ProductionPlanManagementPage() {
|
||||
<div className="flex items-center justify-between border-b bg-muted/20 px-5 py-3">
|
||||
<span className="text-base font-semibold">안전재고 부족 품목</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="text-emerald-600 dark:text-emerald-400 border-emerald-500/30 hover:bg-emerald-500/10" onClick={() => setStockImportModalOpen(true)}>
|
||||
<Button size="sm" variant="outline" className="text-success border-success/30 hover:bg-success/10" onClick={() => setStockImportModalOpen(true)}>
|
||||
<Download className="mr-1 h-3.5 w-3.5" />
|
||||
불러오기
|
||||
</Button>
|
||||
@@ -1120,18 +1151,18 @@ export default function ProductionPlanManagementPage() {
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[30px]">
|
||||
<Checkbox checked={selectedStockItems.size === stockItems.length && stockItems.length > 0} onCheckedChange={(c) => toggleAllStockItems(!!c)} className="h-4 w-4" />
|
||||
</TableHead>
|
||||
<TableHead className="text-xs font-semibold">품목코드</TableHead>
|
||||
<TableHead className="text-xs font-semibold">품목명</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-right">현재고</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-right">안전재고</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-right">부족수량</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-right">권장생산량</TableHead>
|
||||
<TableHead className="text-xs font-semibold">최종입고일</TableHead>
|
||||
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>
|
||||
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="text-xs font-bold text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">현재고</TableHead>
|
||||
<TableHead className="text-xs font-bold text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">안전재고</TableHead>
|
||||
<TableHead className="text-xs font-bold text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">부족수량</TableHead>
|
||||
<TableHead className="text-xs font-bold text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">권장생산량</TableHead>
|
||||
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">최종입고일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -1140,13 +1171,13 @@ export default function ProductionPlanManagementPage() {
|
||||
<TableCell>
|
||||
<Checkbox checked={selectedStockItems.has(stock.item_code)} onCheckedChange={() => toggleStockItem(stock.item_code)} className="h-4 w-4" />
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{stock.item_code}</TableCell>
|
||||
<TableCell className="text-xs">{stock.item_name}</TableCell>
|
||||
<TableCell className="text-xs text-right">{formatNumber(stock.current_qty)}</TableCell>
|
||||
<TableCell className="text-xs text-right">{formatNumber(stock.safety_qty)}</TableCell>
|
||||
<TableCell className="text-xs text-right font-semibold text-destructive">{formatNumber(stock.shortage_qty)}</TableCell>
|
||||
<TableCell className="text-xs text-right font-semibold text-emerald-600 dark:text-emerald-400">{formatNumber(stock.recommended_qty)}</TableCell>
|
||||
<TableCell className="text-xs">{stock.last_in_date || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{stock.item_code}</TableCell>
|
||||
<TableCell className="text-[13px]">{stock.item_name}</TableCell>
|
||||
<TableCell className="text-[13px] text-right">{formatNumber(stock.current_qty)}</TableCell>
|
||||
<TableCell className="text-[13px] text-right">{formatNumber(stock.safety_qty)}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-semibold text-destructive">{formatNumber(stock.shortage_qty)}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-semibold text-success">{formatNumber(stock.recommended_qty)}</TableCell>
|
||||
<TableCell className="text-[13px]">{stock.last_in_date || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -1163,7 +1194,7 @@ export default function ProductionPlanManagementPage() {
|
||||
|
||||
{/* 오른쪽 패널 — 타임라인 */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
<div className="flex h-full flex-col rounded-lg border bg-background shadow-sm">
|
||||
<div className="flex h-full flex-col rounded-lg border bg-card">
|
||||
<Tabs value={rightTab} onValueChange={setRightTab} className="flex h-full flex-col">
|
||||
<div className="shrink-0 border-b bg-muted/30 px-5">
|
||||
<TabsList className="h-11 bg-transparent gap-1">
|
||||
@@ -1278,7 +1309,7 @@ export default function ProductionPlanManagementPage() {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 rounded-lg border border-yellow-500/20 bg-yellow-500/5 p-4">
|
||||
<div className="flex-1 rounded-lg border border-warning/20 bg-warning/5 p-4">
|
||||
<p className="font-semibold text-foreground text-sm mb-2">반제품 계획 안내</p>
|
||||
<ul className="text-xs text-foreground space-y-1 leading-relaxed">
|
||||
<li>완제품 생산계획 기준으로 필요한 반제품 계획 자동 생성</li>
|
||||
@@ -1335,15 +1366,15 @@ export default function ProductionPlanManagementPage() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">품목코드</Label>
|
||||
<Input value={selectedPlan.item_code} readOnly className="h-8 text-xs bg-muted/50" />
|
||||
<Input value={selectedPlan.item_code} readOnly className="h-9 text-xs bg-muted/50" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">품목명</Label>
|
||||
<Input value={selectedPlan.item_name || ""} readOnly className="h-8 text-xs bg-muted/50" />
|
||||
<Input value={selectedPlan.item_name || ""} readOnly className="h-9 text-xs bg-muted/50" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">계획번호</Label>
|
||||
<Input value={selectedPlan.plan_no || ""} readOnly className="h-8 text-xs bg-muted/50" />
|
||||
<Input value={selectedPlan.plan_no || ""} readOnly className="h-9 text-xs bg-muted/50" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">상태</Label>
|
||||
@@ -1359,12 +1390,12 @@ export default function ProductionPlanManagementPage() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">총 생산수량</Label>
|
||||
<Input type="number" value={modalQuantity} onChange={(e) => setModalQuantity(Number(e.target.value))} className="h-8 text-xs" min={0} />
|
||||
<Input type="number" value={modalQuantity} onChange={(e) => setModalQuantity(Number(e.target.value))} className="h-9 text-xs" min={0} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">설비</Label>
|
||||
<Select value={modalEquipmentId} onValueChange={setModalEquipmentId}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue placeholder="설비 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1378,20 +1409,20 @@ export default function ProductionPlanManagementPage() {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3">
|
||||
<div className="mt-3 rounded-lg border border-success/20 bg-success/5 p-3">
|
||||
<p className="text-xs font-semibold text-foreground mb-2">계획 기간 설정</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label className="text-[11px] text-muted-foreground">시작일</Label>
|
||||
<FormDatePicker value={modalStartDate} onChange={setModalStartDate} placeholder="시작일" />
|
||||
<Input type="date" value={modalStartDate} onChange={(e) => setModalStartDate(e.target.value)} className="h-9 text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px] text-muted-foreground">종료일</Label>
|
||||
<FormDatePicker value={modalEndDate} onChange={setModalEndDate} placeholder="종료일" />
|
||||
<Input type="date" value={modalEndDate} onChange={(e) => setModalEndDate(e.target.value)} className="h-9 text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px] text-muted-foreground">생산 기간</Label>
|
||||
<div className="flex h-8 items-center justify-center rounded-md border bg-background text-xs font-semibold text-primary">
|
||||
<div className="flex h-9 items-center justify-center rounded-md border bg-muted/50 text-xs font-semibold text-primary">
|
||||
{modalStartDate && modalEndDate
|
||||
? `${Math.ceil((new Date(modalEndDate).getTime() - new Date(modalStartDate).getTime()) / (1000 * 60 * 60 * 24) + 1)}일`
|
||||
: "-"}
|
||||
@@ -1401,7 +1432,7 @@ export default function ProductionPlanManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-yellow-500/20 bg-yellow-500/5 p-4">
|
||||
<div className="rounded-lg border border-warning/20 bg-warning/5 p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-semibold flex items-center gap-1.5">
|
||||
<Scissors className="h-4 w-4" />
|
||||
@@ -1409,7 +1440,8 @@ export default function ProductionPlanManagementPage() {
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs bg-gradient-to-br from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700"
|
||||
variant="warning"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => {
|
||||
const qty = Math.floor(modalQuantity / 2);
|
||||
if (qty > 0) handleSplitSchedule(qty);
|
||||
@@ -1426,15 +1458,15 @@ export default function ProductionPlanManagementPage() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">담당자</Label>
|
||||
<Input value={modalManager} onChange={(e) => setModalManager(e.target.value)} className="h-8 text-xs" placeholder="담당자명" />
|
||||
<Input value={modalManager} onChange={(e) => setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">작업지시번호</Label>
|
||||
<Input value={modalWorkOrderNo} onChange={(e) => setModalWorkOrderNo(e.target.value)} className="h-8 text-xs" placeholder="자동생성" />
|
||||
<Input value={modalWorkOrderNo} onChange={(e) => setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs">비고</Label>
|
||||
<Input value={modalRemarks} onChange={(e) => setModalRemarks(e.target.value)} className="h-8 text-xs" placeholder="비고사항 입력" />
|
||||
<Input value={modalRemarks} onChange={(e) => setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1442,7 +1474,7 @@ export default function ProductionPlanManagementPage() {
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="destructive" onClick={handleDeletePlan} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||
<Button variant="outline" onClick={handleDeletePlan} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
삭제
|
||||
</Button>
|
||||
@@ -1498,9 +1530,9 @@ export default function ProductionPlanManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-yellow-500/20 bg-yellow-500/5 p-3">
|
||||
<div className="rounded-lg border border-warning/20 bg-warning/5 p-3">
|
||||
<p className="font-semibold text-sm mb-2 flex items-center gap-1.5">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
|
||||
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||
주의사항
|
||||
</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-1 list-disc list-inside leading-relaxed">
|
||||
@@ -1579,9 +1611,9 @@ export default function ProductionPlanManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-yellow-500/20 bg-yellow-500/5 p-3">
|
||||
<div className="rounded-lg border border-warning/20 bg-warning/5 p-3">
|
||||
<p className="font-semibold text-sm mb-2 flex items-center gap-1.5">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
|
||||
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||
주의사항
|
||||
</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-1 list-disc list-inside leading-relaxed">
|
||||
@@ -1636,11 +1668,11 @@ export default function ProductionPlanManagementPage() {
|
||||
<p className="text-2xl font-bold text-foreground">{previewData.summary?.total ?? 0}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">총 계획</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-emerald-500/10 p-3 text-center">
|
||||
<div className="rounded-lg border bg-success/10 p-3 text-center">
|
||||
<p className="text-2xl font-bold text-foreground">{previewData.summary?.new_count ?? 0}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">신규 생성</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-yellow-500/10 p-3 text-center">
|
||||
<div className="rounded-lg border bg-warning/10 p-3 text-center">
|
||||
<p className="text-2xl font-bold text-foreground">{previewData.summary?.kept_count ?? 0}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">유지됨</p>
|
||||
</div>
|
||||
@@ -1652,29 +1684,29 @@ export default function ProductionPlanManagementPage() {
|
||||
|
||||
{(previewData.schedules?.length || 0) > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 text-emerald-600 dark:text-emerald-400 flex items-center gap-1.5">
|
||||
<p className="text-sm font-semibold mb-2 text-success flex items-center gap-1.5">
|
||||
<Zap className="h-4 w-4" />
|
||||
신규 생성 ({previewData.schedules?.length || 0}건)
|
||||
</p>
|
||||
<div className="rounded-md border border-emerald-500/20 overflow-x-auto">
|
||||
<div className="rounded-md border border-success/20 overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-emerald-500/5">
|
||||
<TableHead className="text-xs font-semibold">품목코드</TableHead>
|
||||
<TableHead className="text-xs font-semibold">품목명</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-right">수량</TableHead>
|
||||
<TableHead className="text-xs font-semibold">시작일</TableHead>
|
||||
<TableHead className="text-xs font-semibold">종료일</TableHead>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>
|
||||
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="text-xs font-bold text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
||||
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">시작일</TableHead>
|
||||
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">종료일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(previewData.schedules || []).map((s: any, idx: number) => (
|
||||
<TableRow key={`new-${idx}`}>
|
||||
<TableCell className="text-xs">{s.item_code}</TableCell>
|
||||
<TableCell className="text-xs">{s.item_name}</TableCell>
|
||||
<TableCell className="text-xs text-right">{formatNumber(s.plan_qty || s.required_qty)}</TableCell>
|
||||
<TableCell className="text-xs">{s.start_date?.split("T")[0]}</TableCell>
|
||||
<TableCell className="text-xs">{s.end_date?.split("T")[0]}</TableCell>
|
||||
<TableCell className="text-[13px]">{s.item_code}</TableCell>
|
||||
<TableCell className="text-[13px]">{s.item_name}</TableCell>
|
||||
<TableCell className="text-[13px] text-right">{formatNumber(s.plan_qty || s.required_qty)}</TableCell>
|
||||
<TableCell className="text-[13px]">{s.start_date?.split("T")[0]}</TableCell>
|
||||
<TableCell className="text-[13px]">{s.end_date?.split("T")[0]}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -1691,25 +1723,25 @@ export default function ProductionPlanManagementPage() {
|
||||
</p>
|
||||
<div className="rounded-md border border-destructive/20 overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-destructive/5">
|
||||
<TableHead className="text-xs font-semibold">계획번호</TableHead>
|
||||
<TableHead className="text-xs font-semibold">품목코드</TableHead>
|
||||
<TableHead className="text-xs font-semibold">품목명</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-right">수량</TableHead>
|
||||
<TableHead className="text-xs font-semibold">시작일</TableHead>
|
||||
<TableHead className="text-xs font-semibold">종료일</TableHead>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">계획번호</TableHead>
|
||||
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>
|
||||
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="text-xs font-bold text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
||||
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">시작일</TableHead>
|
||||
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">종료일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(previewData.deletedSchedules || []).map((s: any, idx: number) => (
|
||||
<TableRow key={`del-${idx}`} className="text-destructive/80">
|
||||
<TableCell className="text-xs">{s.plan_no || "-"}</TableCell>
|
||||
<TableCell className="text-xs">{s.item_code}</TableCell>
|
||||
<TableCell className="text-xs">{s.item_name}</TableCell>
|
||||
<TableCell className="text-xs text-right">{formatNumber(s.plan_qty)}</TableCell>
|
||||
<TableCell className="text-xs">{s.start_date?.split("T")[0]}</TableCell>
|
||||
<TableCell className="text-xs">{s.end_date?.split("T")[0]}</TableCell>
|
||||
<TableCell className="text-[13px]">{s.plan_no || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{s.item_code}</TableCell>
|
||||
<TableCell className="text-[13px]">{s.item_name}</TableCell>
|
||||
<TableCell className="text-[13px] text-right">{formatNumber(s.plan_qty)}</TableCell>
|
||||
<TableCell className="text-[13px]">{s.start_date?.split("T")[0]}</TableCell>
|
||||
<TableCell className="text-[13px]">{s.end_date?.split("T")[0]}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -1720,29 +1752,29 @@ export default function ProductionPlanManagementPage() {
|
||||
|
||||
{(previewData.keptSchedules?.length || 0) > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 text-yellow-600 dark:text-yellow-400 flex items-center gap-1.5">
|
||||
<p className="text-sm font-semibold mb-2 text-warning flex items-center gap-1.5">
|
||||
<Package className="h-4 w-4" />
|
||||
유지됨 ({previewData.keptSchedules?.length || 0}건)
|
||||
</p>
|
||||
<div className="rounded-md border border-yellow-500/20 overflow-x-auto">
|
||||
<div className="rounded-md border border-warning/20 overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-yellow-500/5">
|
||||
<TableHead className="text-xs font-semibold">계획번호</TableHead>
|
||||
<TableHead className="text-xs font-semibold">품목코드</TableHead>
|
||||
<TableHead className="text-xs font-semibold">품목명</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-right">수량</TableHead>
|
||||
<TableHead className="text-xs font-semibold">상태</TableHead>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">계획번호</TableHead>
|
||||
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>
|
||||
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="text-xs font-bold text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
||||
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(previewData.keptSchedules || []).map((s: any, idx: number) => (
|
||||
<TableRow key={`kept-${idx}`}>
|
||||
<TableCell className="text-xs">{s.plan_no || "-"}</TableCell>
|
||||
<TableCell className="text-xs">{s.item_code}</TableCell>
|
||||
<TableCell className="text-xs">{s.item_name}</TableCell>
|
||||
<TableCell className="text-xs text-right">{formatNumber(s.plan_qty)}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<TableCell className="text-[13px]">{s.plan_no || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{s.item_code}</TableCell>
|
||||
<TableCell className="text-[13px]">{s.item_name}</TableCell>
|
||||
<TableCell className="text-[13px] text-right">{formatNumber(s.plan_qty)}</TableCell>
|
||||
<TableCell className="text-[13px]">
|
||||
<Badge variant="outline" className="text-[10px]">{STATUS_LABEL[s.status] || s.status}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -1776,16 +1808,18 @@ export default function ProductionPlanManagementPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 테이블 설정 모달 */}
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
|
||||
{/* ConfirmDialog 렌더 */}
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
<TableSettingsModal
|
||||
open={tableSettingsOpen}
|
||||
onOpenChange={setTableSettingsOpen}
|
||||
tableName="production_plan_mng"
|
||||
settingsId="production-plan"
|
||||
onSave={applyTableSettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,8 +22,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
@@ -47,8 +45,8 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import {
|
||||
getProcessList,
|
||||
createProcess,
|
||||
@@ -61,7 +59,7 @@ import {
|
||||
type ProcessMaster,
|
||||
type ProcessEquipment,
|
||||
type Equipment,
|
||||
} from "@/lib/api/processInfo";
|
||||
} from "@/lib/api/processInfo"; // API: /process-info/*
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
|
||||
const ALL_VALUE = "__all__";
|
||||
@@ -121,7 +119,7 @@ export function ProcessMasterTab() {
|
||||
useYn: filterUseYn === ALL_VALUE ? undefined : filterUseYn,
|
||||
});
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "공정 목록을 불러오지 못했습니다.");
|
||||
toast.error(res.message || "공정 목록을 불러오지 못했어요");
|
||||
return;
|
||||
}
|
||||
setProcesses(res.data ?? []);
|
||||
@@ -135,12 +133,12 @@ export function ProcessMasterTab() {
|
||||
try {
|
||||
const [procRes, eqRes] = await Promise.all([getProcessList(), getEquipmentList()]);
|
||||
if (!procRes.success) {
|
||||
toast.error(procRes.message || "공정 목록을 불러오지 못했습니다.");
|
||||
toast.error(procRes.message || "공정 목록을 불러오지 못했어요");
|
||||
} else {
|
||||
setProcesses(procRes.data ?? []);
|
||||
}
|
||||
if (!eqRes.success) {
|
||||
toast.error(eqRes.message || "설비 목록을 불러오지 못했습니다.");
|
||||
toast.error(eqRes.message || "설비 목록을 불러오지 못했어요");
|
||||
} else {
|
||||
setEquipmentMaster(eqRes.data ?? []);
|
||||
}
|
||||
@@ -187,7 +185,7 @@ export function ProcessMasterTab() {
|
||||
const res = await getProcessEquipments(selectedProcess.process_code);
|
||||
if (cancelled) return;
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "공정 설비를 불러오지 못했습니다.");
|
||||
toast.error(res.message || "공정 설비를 불러오지 못했어요");
|
||||
setProcessEquipments([]);
|
||||
} else {
|
||||
setProcessEquipments(res.data ?? []);
|
||||
@@ -199,28 +197,6 @@ export function ProcessMasterTab() {
|
||||
};
|
||||
}, [selectedProcess?.process_code]);
|
||||
|
||||
const allSelected = useMemo(() => {
|
||||
if (processes.length === 0) return false;
|
||||
return processes.every((p) => selectedIds.has(p.id));
|
||||
}, [processes, selectedIds]);
|
||||
|
||||
const toggleAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIds(new Set(processes.map((p) => p.id)));
|
||||
} else {
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const toggleOne = (id: string, checked: boolean) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setFilterCode("");
|
||||
setFilterName("");
|
||||
@@ -246,7 +222,7 @@ export function ProcessMasterTab() {
|
||||
|
||||
const openEdit = () => {
|
||||
if (!selectedProcess) {
|
||||
toast.message("수정할 공정을 좌측 목록에서 선택하세요.");
|
||||
toast.message("수정할 공정을 좌측 목록에서 선택해주세요");
|
||||
return;
|
||||
}
|
||||
setFormMode("edit");
|
||||
@@ -262,7 +238,7 @@ export function ProcessMasterTab() {
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formProcessName.trim()) {
|
||||
toast.error("공정명을 입력하세요.");
|
||||
toast.error("공정명을 입력해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -277,10 +253,10 @@ export function ProcessMasterTab() {
|
||||
use_yn: formUseYn,
|
||||
});
|
||||
if (!res.success || !res.data) {
|
||||
toast.error(res.message || "등록에 실패했습니다.");
|
||||
toast.error(res.message || "등록에 실패했어요");
|
||||
return;
|
||||
}
|
||||
toast.success("공정이 등록되었습니다.");
|
||||
toast.success("공정이 등록되었어요");
|
||||
setFormOpen(false);
|
||||
await loadProcesses();
|
||||
setSelectedProcess(res.data);
|
||||
@@ -294,10 +270,10 @@ export function ProcessMasterTab() {
|
||||
use_yn: formUseYn,
|
||||
});
|
||||
if (!res.success || !res.data) {
|
||||
toast.error(res.message || "수정에 실패했습니다.");
|
||||
toast.error(res.message || "수정에 실패했어요");
|
||||
return;
|
||||
}
|
||||
toast.success("공정이 수정되었습니다.");
|
||||
toast.success("공정이 수정되었어요");
|
||||
setFormOpen(false);
|
||||
await loadProcesses();
|
||||
setSelectedProcess(res.data);
|
||||
@@ -309,7 +285,7 @@ export function ProcessMasterTab() {
|
||||
|
||||
const openDelete = () => {
|
||||
if (selectedIds.size === 0) {
|
||||
toast.message("삭제할 공정을 체크박스로 선택하세요.");
|
||||
toast.message("삭제할 공정을 체크박스로 선택해주세요");
|
||||
return;
|
||||
}
|
||||
setDeleteOpen(true);
|
||||
@@ -321,10 +297,10 @@ export function ProcessMasterTab() {
|
||||
try {
|
||||
const res = await deleteProcesses(ids);
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "삭제에 실패했습니다.");
|
||||
toast.error(res.message || "삭제에 실패했어요");
|
||||
return;
|
||||
}
|
||||
toast.success(`${ids.length}건 삭제되었습니다.`);
|
||||
toast.success(`${ids.length}건 삭제되었어요`);
|
||||
setDeleteOpen(false);
|
||||
setSelectedIds(new Set());
|
||||
if (selectedProcess && ids.includes(selectedProcess.id)) {
|
||||
@@ -344,7 +320,7 @@ export function ProcessMasterTab() {
|
||||
const handleAddEquipment = async () => {
|
||||
if (!selectedProcess) return;
|
||||
if (!equipmentPick) {
|
||||
toast.message("추가할 설비를 선택하세요.");
|
||||
toast.message("추가할 설비를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
setAddingEquipment(true);
|
||||
@@ -354,10 +330,10 @@ export function ProcessMasterTab() {
|
||||
equipment_code: equipmentPick,
|
||||
});
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "설비 추가에 실패했습니다.");
|
||||
toast.error(res.message || "설비 추가에 실패했어요");
|
||||
return;
|
||||
}
|
||||
toast.success("설비가 등록되었습니다.");
|
||||
toast.success("설비가 등록되었어요");
|
||||
setEquipmentPick("");
|
||||
const listRes = await getProcessEquipments(selectedProcess.process_code);
|
||||
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
|
||||
@@ -369,10 +345,10 @@ export function ProcessMasterTab() {
|
||||
const handleRemoveEquipment = async (row: ProcessEquipment) => {
|
||||
const res = await removeProcessEquipment(row.id);
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "설비 제거에 실패했습니다.");
|
||||
toast.error(res.message || "설비 제거에 실패했어요");
|
||||
return;
|
||||
}
|
||||
toast.success("설비가 제거되었습니다.");
|
||||
toast.success("설비가 제거되었어요");
|
||||
if (selectedProcess) {
|
||||
const listRes = await getProcessEquipments(selectedProcess.process_code);
|
||||
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
|
||||
@@ -381,260 +357,162 @@ export function ProcessMasterTab() {
|
||||
|
||||
const listBusy = loadingInitial || loadingList;
|
||||
|
||||
// 표시용 데이터
|
||||
const processGridData = useMemo(
|
||||
() =>
|
||||
processes.map((p) => ({
|
||||
...p,
|
||||
process_type_display: getProcessTypeLabel(p.process_type),
|
||||
use_yn_display: p.use_yn === "Y" ? "사용" : "미사용",
|
||||
})),
|
||||
[processes, getProcessTypeLabel]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[560px] flex-1 flex-col gap-3">
|
||||
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1 rounded-lg">
|
||||
<div className="flex h-full flex-col">
|
||||
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1">
|
||||
{/* 좌측: 공정 목록 */}
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-lg border bg-card shadow-sm">
|
||||
<div className="flex shrink-0 flex-col gap-2 border-b bg-muted/30 p-3 sm:p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
<span className="text-sm font-semibold sm:text-base">공정 마스터</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs sm:text-sm">공정코드</Label>
|
||||
<Input
|
||||
value={filterCode}
|
||||
onChange={(e) => setFilterCode(e.target.value)}
|
||||
placeholder="코드"
|
||||
className="h-8 w-[120px] text-xs sm:h-10 sm:w-[140px] sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs sm:text-sm">공정명</Label>
|
||||
<Input
|
||||
value={filterName}
|
||||
onChange={(e) => setFilterName(e.target.value)}
|
||||
placeholder="이름"
|
||||
className="h-8 w-[120px] text-xs sm:h-10 sm:w-[160px] sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs sm:text-sm">공정유형</Label>
|
||||
<Select value={filterType} onValueChange={setFilterType}>
|
||||
<SelectTrigger className="h-8 w-[120px] text-xs sm:h-10 sm:w-[130px] sm:text-sm">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VALUE} className="text-xs sm:text-sm">
|
||||
전체
|
||||
</SelectItem>
|
||||
{processTypeOptions.map((o, idx) => (
|
||||
<SelectItem key={`pt-filter-${idx}`} value={o.valueCode} className="text-xs sm:text-sm">
|
||||
{o.valueLabel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs sm:text-sm">사용여부</Label>
|
||||
<Select value={filterUseYn} onValueChange={setFilterUseYn}>
|
||||
<SelectTrigger className="h-8 w-[100px] text-xs sm:h-10 sm:w-[110px] sm:text-sm">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VALUE} className="text-xs sm:text-sm">
|
||||
전체
|
||||
</SelectItem>
|
||||
<SelectItem value="Y" className="text-xs sm:text-sm">사용</SelectItem>
|
||||
<SelectItem value="N" className="text-xs sm:text-sm">미사용</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
onClick={handleResetFilters}
|
||||
>
|
||||
<RotateCcw className="mr-1 h-3.5 w-3.5" />
|
||||
초기화
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
onClick={handleSearch}
|
||||
disabled={listBusy}
|
||||
>
|
||||
<Search className="mr-1 h-3.5 w-3.5" />
|
||||
조회
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
onClick={openAdd}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
공정 추가
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
onClick={openEdit}
|
||||
>
|
||||
<Pencil className="mr-1 h-3.5 w-3.5" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
onClick={openDelete}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 패널 헤더 */}
|
||||
<div className="flex items-center gap-2 border-b bg-muted/30 px-4 py-2">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">공정 마스터</span>
|
||||
<Badge variant="secondary" className="font-mono text-xs">
|
||||
{processes.length}건
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="p-2 sm:p-3">
|
||||
{listBusy ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
<p className="mt-2 text-xs sm:text-sm">불러오는 중...</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10 text-center">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={(v) => toggleAll(v === true)}
|
||||
aria-label="전체 선택"
|
||||
className="mx-auto"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-xs sm:text-sm">공정코드</TableHead>
|
||||
<TableHead className="text-xs sm:text-sm">공정명</TableHead>
|
||||
<TableHead className="text-xs sm:text-sm">공정유형</TableHead>
|
||||
<TableHead className="text-right text-xs sm:text-sm">표준시간(분)</TableHead>
|
||||
<TableHead className="text-right text-xs sm:text-sm">작업인원</TableHead>
|
||||
<TableHead className="text-center text-xs sm:text-sm">사용여부</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{processes.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="py-12 text-center text-muted-foreground">
|
||||
<p className="text-xs sm:text-sm">조회된 공정이 없습니다.</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
processes.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors",
|
||||
selectedProcess?.id === row.id && "bg-accent"
|
||||
)}
|
||||
onClick={() => setSelectedProcess(row)}
|
||||
>
|
||||
<TableCell
|
||||
className="text-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(row.id)}
|
||||
onCheckedChange={(v) => toggleOne(row.id, v === true)}
|
||||
aria-label={`${row.process_code} 선택`}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-medium sm:text-sm">
|
||||
{row.process_code}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs sm:text-sm">{row.process_name}</TableCell>
|
||||
<TableCell className="text-xs sm:text-sm">
|
||||
<Badge variant="secondary" className="text-[10px] sm:text-xs">
|
||||
{getProcessTypeLabel(row.process_type)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs sm:text-sm">
|
||||
{row.standard_time ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs sm:text-sm">
|
||||
{row.worker_count ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs sm:text-sm">
|
||||
<Badge
|
||||
variant={row.use_yn === "N" ? "outline" : "default"}
|
||||
className="text-[10px] sm:text-xs"
|
||||
>
|
||||
{row.use_yn === "Y" ? "사용" : "미사용"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{/* 필터 바 */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-b px-3 py-2">
|
||||
<Input value={filterCode} onChange={(e) => setFilterCode(e.target.value)} placeholder="공정코드" className="h-8 w-[110px] text-xs" />
|
||||
<Input value={filterName} onChange={(e) => setFilterName(e.target.value)} placeholder="공정명" className="h-8 w-[130px] text-xs" />
|
||||
<Select value={filterType} onValueChange={setFilterType}>
|
||||
<SelectTrigger className="h-8 w-[110px] text-xs">
|
||||
<SelectValue placeholder="유형 전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VALUE}>전체</SelectItem>
|
||||
{processTypeOptions.map((o, idx) => (
|
||||
<SelectItem key={`pt-filter-${idx}`} value={o.valueCode}>{o.valueLabel}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filterUseYn} onValueChange={setFilterUseYn}>
|
||||
<SelectTrigger className="h-8 w-[100px] text-xs">
|
||||
<SelectValue placeholder="사용 전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VALUE}>전체</SelectItem>
|
||||
<SelectItem value="Y">사용</SelectItem>
|
||||
<SelectItem value="N">미사용</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex-1" />
|
||||
{listBusy && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
|
||||
<Button size="sm" className="h-8 gap-1 text-xs" onClick={handleSearch} disabled={listBusy}>
|
||||
<Search className="h-3 w-3" />조회
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1 text-xs text-muted-foreground" onClick={handleResetFilters}>
|
||||
<RotateCcw className="h-3 w-3" />초기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 액션 바 */}
|
||||
<div className="flex items-center justify-end gap-2 border-b bg-muted/30 px-4 py-2">
|
||||
<Button size="sm" onClick={openAdd}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
공정 추가
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={openEdit}>
|
||||
<Pencil className="mr-1 h-3.5 w-3.5" />
|
||||
수정
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={openDelete}>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 공정 목록 테이블 */}
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<EDataTable
|
||||
columns={[
|
||||
{ key: "process_code", label: "공정코드", width: "w-[130px]", render: (val: any) => <span className="font-mono text-xs">{val}</span> },
|
||||
{ key: "process_name", label: "공정명" },
|
||||
{ key: "process_type_display", label: "공정유형", width: "w-[120px]" },
|
||||
{ key: "standard_time", label: "표준시간(분)", width: "w-[110px]", align: "right" as const },
|
||||
{ key: "worker_count", label: "작업인원", width: "w-[90px]", align: "right" as const },
|
||||
{ key: "use_yn_display", label: "사용여부", width: "w-[90px]", align: "center" as const },
|
||||
] as EDataTableColumn[]}
|
||||
data={processGridData}
|
||||
rowKey={(row) => row.id}
|
||||
loading={listBusy}
|
||||
emptyMessage="조회된 공정이 없어요"
|
||||
selectedId={selectedProcess?.id ?? null}
|
||||
onSelect={(id) => {
|
||||
const proc = processes.find((p) => p.id === id);
|
||||
setSelectedProcess(proc || null);
|
||||
}}
|
||||
onRowClick={(row) => {
|
||||
const proc = processes.find((p) => p.id === row.id);
|
||||
setSelectedProcess(proc || null);
|
||||
}}
|
||||
showCheckbox
|
||||
checkedIds={Array.from(selectedIds)}
|
||||
onCheckedChange={(ids) => setSelectedIds(new Set(ids))}
|
||||
showPagination={false}
|
||||
draggableColumns={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 우측: 공정별 설비 */}
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-lg border bg-card shadow-sm">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b bg-muted/30 px-3 py-2 sm:px-4 sm:py-3">
|
||||
<Wrench className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 패널 헤더 */}
|
||||
<div className="flex items-center gap-2 border-b bg-muted/30 px-4 py-2">
|
||||
<Wrench className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold sm:text-base">공정별 사용설비</p>
|
||||
<p className="truncate text-sm font-semibold">공정별 사용설비</p>
|
||||
{selectedProcess ? (
|
||||
<p className="truncate text-xs text-muted-foreground sm:text-sm">
|
||||
{selectedProcess.process_name}{" "}
|
||||
<span className="text-muted-foreground/80">({selectedProcess.process_code})</span>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{selectedProcess.process_name} ({selectedProcess.process_code})
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">공정 미선택</p>
|
||||
<p className="text-xs text-muted-foreground">공정을 선택해주세요</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedProcess ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 py-12 text-center text-muted-foreground">
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-muted-foreground">
|
||||
<Settings className="h-10 w-10 opacity-40" />
|
||||
<p className="text-sm font-medium text-foreground">좌측에서 공정을 선택하세요</p>
|
||||
<p className="max-w-xs text-xs sm:text-sm">
|
||||
목록 행을 클릭하면 이 공정에 연결된 설비를 관리할 수 있습니다.
|
||||
<p className="text-sm font-medium text-foreground">좌측에서 공정을 선택해주세요</p>
|
||||
<p className="max-w-xs text-xs">
|
||||
목록 행을 클릭하면 이 공정에 연결된 설비를 관리할 수 있어요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 p-3 sm:p-4">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1 sm:max-w-xs">
|
||||
<Label className="text-xs sm:text-sm">설비 선택</Label>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">설비 선택</Label>
|
||||
<Select
|
||||
key={selectedProcess.id}
|
||||
value={equipmentPick || undefined}
|
||||
onValueChange={setEquipmentPick}
|
||||
disabled={addingEquipment || availableEquipments.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="설비를 선택하세요" />
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue placeholder="설비를 선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableEquipments.map((eq) => (
|
||||
<SelectItem
|
||||
key={eq.id}
|
||||
value={eq.equipment_code}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<SelectItem key={eq.id} value={eq.equipment_code}>
|
||||
{eq.equipment_code} · {eq.equipment_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -642,9 +520,7 @@ export function ProcessMasterTab() {
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
onClick={() => void handleAddEquipment()}
|
||||
disabled={addingEquipment || !equipmentPick}
|
||||
>
|
||||
@@ -657,47 +533,31 @@ export function ProcessMasterTab() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{loadingEquipments ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Loader2 className="h-7 w-7 animate-spin" />
|
||||
<p className="mt-2 text-xs sm:text-sm">설비 목록 불러오는 중...</p>
|
||||
<p className="mt-2 text-sm">설비 목록을 불러오고 있어요...</p>
|
||||
</div>
|
||||
) : processEquipments.length === 0 ? (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground sm:text-sm">
|
||||
등록된 설비가 없습니다. 상단에서 설비를 추가하세요.
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
등록된 설비가 없어요. 상단에서 설비를 추가해주세요.
|
||||
</p>
|
||||
) : (
|
||||
<ScrollArea className="h-[min(420px,calc(100vh-20rem))] pr-3">
|
||||
<ul className="space-y-2">
|
||||
{processEquipments.map((pe) => (
|
||||
<li key={pe.id}>
|
||||
<Card className="rounded-lg border bg-card text-card-foreground shadow-sm">
|
||||
<CardContent className="flex items-center gap-3 p-3 sm:p-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium sm:text-sm">
|
||||
{pe.equipment_code}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground sm:text-sm">
|
||||
{pe.equipment_name || "설비명 없음"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 text-xs sm:h-9 sm:text-sm"
|
||||
onClick={() => void handleRemoveEquipment(pe)}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
제거
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
<ul className="space-y-2">
|
||||
{processEquipments.map((pe) => (
|
||||
<li key={pe.id} className="flex items-center gap-3 rounded-lg border p-3 transition-colors hover:bg-muted/30">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{pe.equipment_code}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{pe.equipment_name || "설비명 없음"}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => void handleRemoveEquipment(pe)}>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
제거
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -706,136 +566,95 @@ export function ProcessMasterTab() {
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
{/* 공정 등록/수정 모달 */}
|
||||
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{formMode === "add" ? "공정 추가" : "공정 수정"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
공정 마스터 정보를 입력합니다. 표준시간과 작업인원은 숫자로 입력하세요.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{formMode === "add" ? "공정 추가" : "공정 수정"}</DialogTitle>
|
||||
<DialogDescription>공정 마스터 정보를 입력해주세요</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="pm-process-name" className="text-xs sm:text-sm">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
공정명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="pm-process-name"
|
||||
value={formProcessName}
|
||||
onChange={(e) => setFormProcessName(e.target.value)}
|
||||
placeholder="공정명"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
placeholder="공정명을 입력해주세요"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">공정유형</Label>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">공정유형</Label>
|
||||
<Select value={formProcessType} onValueChange={setFormProcessType}>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue placeholder="선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{processTypeOptions.map((o, idx) => (
|
||||
<SelectItem key={`pt-form-${idx}`} value={o.valueCode} className="text-xs sm:text-sm">
|
||||
{o.valueLabel}
|
||||
</SelectItem>
|
||||
<SelectItem key={`pt-form-${idx}`} value={o.valueCode}>{o.valueLabel}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="pm-standard-time" className="text-xs sm:text-sm">
|
||||
표준작업시간(분)
|
||||
</Label>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">표준작업시간(분)</Label>
|
||||
<Input
|
||||
id="pm-standard-time"
|
||||
value={formStandardTime}
|
||||
onChange={(e) => setFormStandardTime(e.target.value)}
|
||||
placeholder="0"
|
||||
inputMode="numeric"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="pm-worker-count" className="text-xs sm:text-sm">
|
||||
작업인원수
|
||||
</Label>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">작업인원수</Label>
|
||||
<Input
|
||||
id="pm-worker-count"
|
||||
value={formWorkerCount}
|
||||
onChange={(e) => setFormWorkerCount(e.target.value)}
|
||||
placeholder="0"
|
||||
inputMode="numeric"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">사용여부</Label>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">사용여부</Label>
|
||||
<Select value={formUseYn} onValueChange={setFormUseYn}>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue placeholder="선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Y" className="text-xs sm:text-sm">사용</SelectItem>
|
||||
<SelectItem value="N" className="text-xs sm:text-sm">미사용</SelectItem>
|
||||
<SelectItem value="Y">사용</SelectItem>
|
||||
<SelectItem value="N">미사용</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setFormOpen(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
disabled={savingForm}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void submitForm()}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
disabled={savingForm}
|
||||
>
|
||||
{savingForm ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
저장
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setFormOpen(false)} disabled={savingForm}>취소</Button>
|
||||
<Button onClick={() => void submitForm()} disabled={savingForm}>
|
||||
{savingForm && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
저장해요
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 */}
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">공정 삭제</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
선택한 {selectedIds.size}건의 공정을 삭제합니다. 연결된 공정-설비 매핑도 함께 삭제됩니다. 이 작업은
|
||||
되돌릴 수 없습니다.
|
||||
<DialogTitle>공정 삭제</DialogTitle>
|
||||
<DialogDescription>
|
||||
선택한 {selectedIds.size}건의 공정을 삭제해요. 연결된 공정-설비 매핑도 함께 삭제돼요. 이 작업은 되돌릴 수 없어요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDeleteOpen(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
disabled={deleting}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => void confirmDelete()}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
삭제
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => setDeleteOpen(false)} disabled={deleting}>취소</Button>
|
||||
<Button variant="destructive" onClick={() => void confirmDelete()} disabled={deleting}>
|
||||
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
삭제해요
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,56 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Settings, GitBranch, ClipboardList } from "lucide-react";
|
||||
import {
|
||||
Settings,
|
||||
GitBranch,
|
||||
ClipboardList,
|
||||
ChevronRight,
|
||||
Factory,
|
||||
Keyboard,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
List,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { ProcessMasterTab } from "./ProcessMasterTab";
|
||||
import { ItemRoutingTab } from "./ItemRoutingTab";
|
||||
import { ProcessWorkStandardTab } from "./ProcessWorkStandardTab";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "process_code", label: "공정코드" },
|
||||
{ key: "process_name", label: "공정명" },
|
||||
{ key: "process_type", label: "공정유형" },
|
||||
{ key: "standard_time", label: "표준시간(분)" },
|
||||
{ key: "worker_count", label: "작업인원" },
|
||||
{ key: "use_yn", label: "사용여부" },
|
||||
];
|
||||
|
||||
const TAB_META = [
|
||||
{
|
||||
value: "process",
|
||||
label: "공정 마스터",
|
||||
shortLabel: "공정",
|
||||
description: "공정코드/유형/표준시간/사용설비 관리",
|
||||
detailDesc: "공정코드, 공정유형, 표준시간을 등록하고 사용 설비를 매핑합니다.",
|
||||
icon: Settings,
|
||||
color: "text-blue-500",
|
||||
badgeColor: "bg-blue-50 text-blue-700 ring-blue-600/20",
|
||||
shortcut: "1",
|
||||
actions: ["공정 등록", "공정 수정", "공정 삭제", "설비 연결"],
|
||||
},
|
||||
{
|
||||
value: "routing",
|
||||
label: "품목별 라우팅",
|
||||
shortLabel: "라우팅",
|
||||
description: "품목 라우팅 버전 및 공정 순서 관리",
|
||||
detailDesc: "품목별 생산 라우팅 버전을 관리하고 공정 순서 및 소요시간을 설정합니다.",
|
||||
icon: GitBranch,
|
||||
color: "text-emerald-500",
|
||||
badgeColor: "bg-emerald-50 text-emerald-700 ring-emerald-600/20",
|
||||
shortcut: "2",
|
||||
actions: ["버전 생성", "공정 순서 설정", "품목 등록", "품목 해제"],
|
||||
},
|
||||
{
|
||||
value: "workstandard",
|
||||
label: "공정 작업기준",
|
||||
shortLabel: "작업기준",
|
||||
description: "공정별 작업기준서 및 작업 표준 관리",
|
||||
detailDesc: "공정별 작업기준서를 등록하고 작업 표준을 문서화하여 품질을 관리합니다.",
|
||||
icon: ClipboardList,
|
||||
color: "text-violet-500",
|
||||
badgeColor: "bg-violet-50 text-violet-700 ring-violet-600/20",
|
||||
shortcut: "3",
|
||||
actions: ["기준서 등록", "기준서 수정", "기준서 삭제", "작업 표준 관리"],
|
||||
},
|
||||
] as const;
|
||||
|
||||
type TabValue = (typeof TAB_META)[number]["value"];
|
||||
|
||||
const ACTION_ICONS = [Plus, Pencil, Trash2, List] as const;
|
||||
|
||||
export default function ProcessInfoPage() {
|
||||
const [activeTab, setActiveTab] = useState("process");
|
||||
const ts = useTableSettings("c16-process-info", "process_mst", GRID_COLUMNS);
|
||||
const [activeTab, setActiveTab] = useState<TabValue>("process");
|
||||
const [showShortcutHint, setShowShortcutHint] = useState(false);
|
||||
|
||||
const activeMeta = TAB_META.find((t) => t.value === activeTab)!;
|
||||
|
||||
const handleTabChange = useCallback((value: string) => {
|
||||
setActiveTab(value as TabValue);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!e.altKey) return;
|
||||
const tabByShortcut = TAB_META.find((t) => t.shortcut === e.key);
|
||||
if (tabByShortcut) {
|
||||
e.preventDefault();
|
||||
setActiveTab(tabByShortcut.value);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col bg-muted/30">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex h-full flex-col">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="shrink-0 border-b bg-background px-6 py-3">
|
||||
<nav
|
||||
className="mb-1 flex items-center gap-1 text-xs text-muted-foreground"
|
||||
aria-label="breadcrumb"
|
||||
>
|
||||
<Factory className="h-3 w-3" />
|
||||
<span>생산관리</span>
|
||||
<ChevronRight className="h-3 w-3 opacity-50" />
|
||||
<span className="font-medium text-foreground">공정정보관리</span>
|
||||
</nav>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h1 className="text-base font-semibold text-foreground">공정정보관리</h1>
|
||||
<span className="text-xs text-muted-foreground">{activeMeta.description}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={() => setShowShortcutHint((v) => !v)}
|
||||
aria-label="키보드 단축키 보기"
|
||||
>
|
||||
<Keyboard className="h-3 w-3" />
|
||||
<span>단축키</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showShortcutHint && (
|
||||
<div className="mt-2 flex items-center gap-3 rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">탭 전환:</span>
|
||||
{TAB_META.map((t) => (
|
||||
<span key={t.value} className="flex items-center gap-1">
|
||||
<kbd className="rounded border border-border bg-background px-1.5 py-0.5 font-mono text-[10px]">
|
||||
Alt+{t.shortcut}
|
||||
</kbd>
|
||||
<span>{t.shortLabel}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
{/* 탭 네비게이션 */}
|
||||
<div className="shrink-0 border-b bg-background px-4">
|
||||
<TabsList className="h-12 bg-transparent gap-1">
|
||||
<TabsTrigger
|
||||
value="process"
|
||||
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4"
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
공정 마스터
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="routing"
|
||||
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4"
|
||||
>
|
||||
<GitBranch className="mr-2 h-4 w-4" />
|
||||
품목별 라우팅
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="workstandard"
|
||||
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4"
|
||||
>
|
||||
<ClipboardList className="mr-2 h-4 w-4" />
|
||||
공정 작업기준
|
||||
</TabsTrigger>
|
||||
{TAB_META.map(({ value, label, icon: Icon, shortcut }) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
value={value}
|
||||
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4 gap-1.5"
|
||||
aria-label={`${label} (Alt+${shortcut})`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="process" className="flex-1 overflow-hidden mt-0">
|
||||
{/* 탭 설명 배너 */}
|
||||
<div className="shrink-0 border-b bg-background/60 px-6 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset ${activeMeta.badgeColor}`}
|
||||
>
|
||||
{activeMeta.shortLabel}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{activeMeta.detailDesc}</span>
|
||||
</div>
|
||||
<div className="hidden items-center gap-2 sm:flex">
|
||||
{activeMeta.actions.map((action, i) => {
|
||||
const ActionIcon = ACTION_ICONS[i % ACTION_ICONS.length];
|
||||
return (
|
||||
<span
|
||||
key={action}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground/70"
|
||||
>
|
||||
<ActionIcon className="h-3 w-3" />
|
||||
{action}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭 컨텐츠 */}
|
||||
<TabsContent value="process" className="min-h-0 flex-1 overflow-hidden mt-0">
|
||||
<ProcessMasterTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="routing" className="flex-1 overflow-hidden mt-0">
|
||||
<TabsContent value="routing" className="min-h-0 flex-1 overflow-hidden mt-0">
|
||||
<ItemRoutingTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="workstandard" className="flex-1 overflow-hidden mt-0">
|
||||
<TabsContent value="workstandard" className="min-h-0 flex-1 overflow-hidden mt-0">
|
||||
<ProcessWorkStandardTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+675
-207
File diff suppressed because it is too large
Load Diff
@@ -1,37 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import React, { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Plus, Trash2, RotateCcw, Save, X, ChevronLeft, ChevronRight, Search, Loader2, Wrench, Pencil, CheckCircle2, ArrowRight, Check, ChevronsUpDown, ClipboardCheck } from "lucide-react";
|
||||
import { Plus, Trash2, Save, X, ChevronLeft, ChevronRight, Search, Loader2, Wrench, Pencil, CheckCircle2, ArrowRight, Check, ChevronsUpDown, ClipboardCheck, Inbox, Settings2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||
// API: /work-instruction/*
|
||||
import {
|
||||
getWorkInstructionList, previewWorkInstructionNo, saveWorkInstruction, deleteWorkInstructions,
|
||||
getWIItemSource, getWISalesOrderSource, getWIProductionPlanSource, getEquipmentList, getEmployeeList,
|
||||
getRoutingVersions, RoutingVersionData,
|
||||
} from "@/lib/api/workInstruction";
|
||||
import { WorkStandardEditModal } from "./WorkStandardEditModal";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "work_instruction_no", label: "작업지시번호" },
|
||||
{ key: "status", label: "상태" },
|
||||
{ key: "progress", label: "진행현황" },
|
||||
{ key: "item_name", label: "품목명" },
|
||||
{ key: "spec", label: "규격" },
|
||||
{ key: "qty", label: "수량" },
|
||||
{ key: "equipment", label: "설비" },
|
||||
{ key: "routing", label: "라우팅" },
|
||||
{ key: "work_team", label: "작업조" },
|
||||
{ key: "worker", label: "작업자" },
|
||||
{ key: "start_date", label: "시작일" },
|
||||
{ key: "end_date", label: "완료일" },
|
||||
{ key: "actions", label: "작업" },
|
||||
];
|
||||
|
||||
type SourceType = "production" | "order" | "item";
|
||||
|
||||
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
|
||||
"일반": { label: "일반", cls: "bg-blue-100 text-blue-800 border-blue-200" },
|
||||
"긴급": { label: "긴급", cls: "bg-red-100 text-red-800 border-red-200" },
|
||||
"일반": { label: "일반", cls: "bg-primary/10 text-primary border-primary/20" },
|
||||
"긴급": { label: "긴급", cls: "bg-destructive/10 text-destructive border-destructive/20" },
|
||||
};
|
||||
const PROGRESS_BADGE: Record<string, { label: string; cls: string }> = {
|
||||
"대기": { label: "대기", cls: "bg-amber-100 text-amber-800" },
|
||||
"진행중": { label: "진행중", cls: "bg-blue-100 text-blue-800" },
|
||||
"완료": { label: "완료", cls: "bg-emerald-100 text-emerald-800" },
|
||||
"대기": { label: "대기", cls: "bg-warning/10 text-warning" },
|
||||
"진행중": { label: "진행중", cls: "bg-primary/10 text-primary" },
|
||||
"완료": { label: "완료", cls: "bg-success/10 text-success" },
|
||||
};
|
||||
|
||||
interface EquipmentOption { id: string; equipment_code: string; equipment_name: string; }
|
||||
@@ -39,21 +58,18 @@ interface EmployeeOption { user_id: string; user_name: string; dept_name: string
|
||||
interface SelectedItem {
|
||||
itemCode: string; itemName: string; spec: string; qty: number; remark: string;
|
||||
sourceType: SourceType; sourceTable: string; sourceId: string | number;
|
||||
routing?: string; routingOptions?: RoutingVersionData[];
|
||||
}
|
||||
|
||||
export default function WorkInstructionPage() {
|
||||
const ts = useTableSettings("c16-work-instruction", "work_instruction", GRID_COLUMNS);
|
||||
const [orders, setOrders] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [equipmentOptions, setEquipmentOptions] = useState<EquipmentOption[]>([]);
|
||||
const [employeeOptions, setEmployeeOptions] = useState<EmployeeOption[]>([]);
|
||||
|
||||
// 검색
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
const [debouncedKeyword, setDebouncedKeyword] = useState("");
|
||||
const [searchStatus, setSearchStatus] = useState("all");
|
||||
const [searchProgress, setSearchProgress] = useState("all");
|
||||
const [searchDateFrom, setSearchDateFrom] = useState("");
|
||||
const [searchDateTo, setSearchDateTo] = useState("");
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 1단계: 등록 모달
|
||||
const [isRegModalOpen, setIsRegModalOpen] = useState(false);
|
||||
@@ -119,9 +135,6 @@ export default function WorkInstructionPage() {
|
||||
const [wsModalItemName, setWsModalItemName] = useState("");
|
||||
const [wsModalItemCode, setWsModalItemCode] = useState("");
|
||||
|
||||
useEffect(() => { const t = setTimeout(() => setDebouncedKeyword(searchKeyword), 500); return () => clearTimeout(t); }, [searchKeyword]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
getEquipmentList().then(r => { if (r.success) setEquipmentOptions(r.data || []); });
|
||||
getEmployeeList().then(r => { if (r.success) setEmployeeOptions(r.data || []); });
|
||||
@@ -131,23 +144,28 @@ export default function WorkInstructionPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
if (searchDateFrom) params.dateFrom = searchDateFrom;
|
||||
if (searchDateTo) params.dateTo = searchDateTo;
|
||||
if (searchStatus !== "all") params.status = searchStatus;
|
||||
if (searchProgress !== "all") params.progressStatus = searchProgress;
|
||||
if (debouncedKeyword.trim()) params.keyword = debouncedKeyword.trim();
|
||||
for (const f of searchFilters) {
|
||||
if (f.columnName === "start_date" && f.operator === "between" && f.value) {
|
||||
const [from, to] = f.value.split("|");
|
||||
if (from) params.dateFrom = from;
|
||||
if (to) params.dateTo = to;
|
||||
} else if (f.columnName === "status" && f.value) {
|
||||
params.status = f.value;
|
||||
} else if (f.columnName === "progress" && f.value) {
|
||||
params.progressStatus = f.value;
|
||||
} else if (f.columnName === "work_instruction_no" && f.value) {
|
||||
params.keyword = f.value;
|
||||
} else if (f.columnName === "item_name" && f.value) {
|
||||
params.keyword = f.value;
|
||||
}
|
||||
}
|
||||
const r = await getWorkInstructionList(params);
|
||||
if (r.success) setOrders(r.data || []);
|
||||
} catch {} finally { setLoading(false); }
|
||||
}, [searchDateFrom, searchDateTo, searchStatus, searchProgress, debouncedKeyword]);
|
||||
}, [searchFilters]);
|
||||
|
||||
useEffect(() => { fetchOrders(); }, [fetchOrders]);
|
||||
|
||||
const handleResetSearch = () => {
|
||||
setSearchKeyword(""); setDebouncedKeyword(""); setSearchStatus("all"); setSearchProgress("all");
|
||||
setSearchDateFrom(""); setSearchDateTo("");
|
||||
};
|
||||
|
||||
// ─── 1단계 등록 ───
|
||||
const openRegModal = () => {
|
||||
setRegSourceType("production"); setRegSourceData([]); setRegKeyword(""); setRegCheckedIds(new Set());
|
||||
@@ -206,14 +224,17 @@ export default function WorkInstructionPage() {
|
||||
setConfirmRouting(""); setConfirmRoutingOptions([]);
|
||||
previewWorkInstructionNo().then(r => { if (r.success) setConfirmWiNo(r.instructionNo); else setConfirmWiNo("(자동생성)"); }).catch(() => setConfirmWiNo("(자동생성)"));
|
||||
|
||||
// 첫 번째 품목의 라우팅 로드
|
||||
const firstItem = items.length > 0 ? items[0] : null;
|
||||
if (firstItem) {
|
||||
getRoutingVersions("__new__", firstItem.itemCode).then(r => {
|
||||
// 품목별 라우팅 옵션 로드
|
||||
const finalItems = regMergeSameItem ? Array.from(new Map(items.map(i => [i.itemCode, i])).values()) : items;
|
||||
const uniqueItemCodes = [...new Set(finalItems.map(i => i.itemCode).filter(Boolean))];
|
||||
for (const ic of uniqueItemCodes) {
|
||||
getRoutingVersions("__new__", ic).then(r => {
|
||||
if (r.success && r.data) {
|
||||
setConfirmRoutingOptions(r.data);
|
||||
const defaultRouting = r.data.find(rv => rv.is_default);
|
||||
if (defaultRouting) setConfirmRouting(defaultRouting.id);
|
||||
setConfirmItems(prev => prev.map(it => {
|
||||
if (it.itemCode !== ic) return it;
|
||||
const defaultRv = r.data.find(rv => rv.is_default);
|
||||
return { ...it, routingOptions: r.data, routing: defaultRv?.id || "" };
|
||||
}));
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
@@ -242,7 +263,7 @@ export default function WorkInstructionPage() {
|
||||
status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate,
|
||||
equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker,
|
||||
routing: confirmRouting || null,
|
||||
items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode })),
|
||||
items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })),
|
||||
};
|
||||
const r = await saveWorkInstruction(payload);
|
||||
if (r.success) { setIsConfirmModalOpen(false); fetchOrders(); alert("작업지시가 등록되었습니다."); }
|
||||
@@ -258,21 +279,35 @@ export default function WorkInstructionPage() {
|
||||
setEditStartDate(order.start_date || ""); setEditEndDate(order.end_date || "");
|
||||
setEditEquipmentId(order.equipment_id || ""); setEditWorkTeam(order.work_team || "");
|
||||
setEditWorker(order.worker || ""); setEditRemark(order.wi_remark || "");
|
||||
setEditItems(relatedDetails.map((d: any) => ({
|
||||
const items: SelectedItem[] = relatedDetails.map((d: any) => ({
|
||||
itemCode: d.item_number || d.part_code || "", itemName: d.item_name || "", spec: d.item_spec || "",
|
||||
qty: Number(d.detail_qty || 0), remark: d.detail_remark || "",
|
||||
sourceType: (d.source_table === "sales_order_detail" ? "order" : d.source_table === "production_plan_mng" ? "production" : "item") as SourceType,
|
||||
sourceTable: d.source_table || "item_info", sourceId: d.source_id || "",
|
||||
})));
|
||||
routing: d.detail_routing_version_id || order.routing_version_id || "",
|
||||
routingOptions: [],
|
||||
}));
|
||||
setEditItems(items);
|
||||
setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker("");
|
||||
setEditRouting(order.routing_version_id || "");
|
||||
setEditRoutingOptions([]);
|
||||
|
||||
// 라우팅 옵션 로드
|
||||
const itemCode = order.item_number || order.part_code || "";
|
||||
if (itemCode) {
|
||||
getRoutingVersions(wiNo, itemCode).then(r => {
|
||||
if (r.success && r.data) setEditRoutingOptions(r.data);
|
||||
// 품목별 라우팅 옵션 로드
|
||||
const uniqueItemCodes = [...new Set(items.map(i => i.itemCode).filter(Boolean))];
|
||||
for (const ic of uniqueItemCodes) {
|
||||
getRoutingVersions(wiNo, ic).then(r => {
|
||||
if (r.success && r.data) {
|
||||
setEditItems(prev => prev.map(it => {
|
||||
if (it.itemCode !== ic) return it;
|
||||
const opts = r.data;
|
||||
const hasRouting = it.routing && opts.some(rv => rv.id === it.routing);
|
||||
return {
|
||||
...it,
|
||||
routingOptions: opts,
|
||||
routing: hasRouting ? it.routing : (opts.find(rv => rv.is_default)?.id || it.routing || ""),
|
||||
};
|
||||
}));
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -296,7 +331,7 @@ export default function WorkInstructionPage() {
|
||||
id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate,
|
||||
equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark,
|
||||
routing: editRouting || null,
|
||||
items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode })),
|
||||
items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })),
|
||||
};
|
||||
const r = await saveWorkInstruction(payload);
|
||||
if (r.success) { setIsEditModalOpen(false); fetchOrders(); alert("수정되었습니다."); }
|
||||
@@ -377,157 +412,121 @@ export default function WorkInstructionPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-4 p-4">
|
||||
{/* 검색 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">작업기간</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-[150px]"><FormDatePicker value={searchDateFrom} onChange={setSearchDateFrom} placeholder="시작일" /></div>
|
||||
<span className="text-muted-foreground">~</span>
|
||||
<div className="w-[150px]"><FormDatePicker value={searchDateTo} onChange={setSearchDateTo} placeholder="종료일" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">검색</Label>
|
||||
<Input placeholder="작업지시번호/품목명" value={searchKeyword} onChange={e => setSearchKeyword(e.target.value)} className="h-9 w-[200px]" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">상태</Label>
|
||||
<Select value={searchStatus} onValueChange={setSearchStatus}>
|
||||
<SelectTrigger className="h-9 w-[120px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="all">전체</SelectItem><SelectItem value="일반">일반</SelectItem><SelectItem value="긴급">긴급</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">진행현황</Label>
|
||||
<Select value={searchProgress} onValueChange={setSearchProgress}>
|
||||
<SelectTrigger className="h-9 w-[130px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="all">전체</SelectItem><SelectItem value="대기">대기</SelectItem><SelectItem value="진행중">진행중</SelectItem><SelectItem value="완료">완료</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-2">
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={handleResetSearch}><RotateCcw className="w-4 h-4 mr-1.5" /> 초기화</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex flex-col h-[calc(100vh-4rem)] p-3 gap-3">
|
||||
{/* 검색 필터 바 */}
|
||||
<DynamicSearchFilter
|
||||
tableName="work_instruction"
|
||||
filterId="c16-work-instruction"
|
||||
onFilterChange={setSearchFilters}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
dataCount={new Set(orders.map(o => o.work_instruction_no)).size}
|
||||
/>
|
||||
|
||||
{/* 메인 테이블 */}
|
||||
<Card className="flex-1 flex flex-col overflow-hidden">
|
||||
<CardContent className="p-0 flex flex-col flex-1 overflow-hidden">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Wrench className="w-4 h-4" /> 작업지시 목록
|
||||
<Badge variant="secondary" className="text-xs">{new Set(orders.map(o => o.work_instruction_no)).size}건 ({orders.length}행)</Badge>
|
||||
</h3>
|
||||
<Button size="sm" onClick={openRegModal}><Plus className="w-4 h-4 mr-1.5" /> 작업지시 등록</Button>
|
||||
<div className="flex-1 overflow-hidden border bg-card rounded-lg flex flex-col">
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 패널 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/30 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wrench className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-[13px] font-bold text-foreground">작업지시 목록</span>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/8 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
||||
{new Set(orders.map(o => o.work_instruction_no)).size}건 ({orders.length}행)
|
||||
</span>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={openRegModal}>
|
||||
<Plus className="w-3.5 h-3.5" /> 작업지시 등록
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow>
|
||||
<TableHead className="w-[150px]">작업지시번호</TableHead>
|
||||
<TableHead className="w-[70px] text-center">상태</TableHead>
|
||||
<TableHead className="w-[100px] text-center">진행현황</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-[100px]">규격</TableHead>
|
||||
<TableHead className="w-[80px] text-right">수량</TableHead>
|
||||
<TableHead className="w-[120px]">설비</TableHead>
|
||||
<TableHead className="w-[120px]">라우팅</TableHead>
|
||||
<TableHead className="w-[80px] text-center">작업조</TableHead>
|
||||
<TableHead className="w-[100px]">작업자</TableHead>
|
||||
<TableHead className="w-[100px] text-center">시작일</TableHead>
|
||||
<TableHead className="w-[100px] text-center">완료일</TableHead>
|
||||
<TableHead className="w-[150px] text-center">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={13} className="text-center py-12"><Loader2 className="w-6 h-6 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
||||
) : orders.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={13} className="text-center py-12 text-muted-foreground">작업지시가 없습니다</TableCell></TableRow>
|
||||
) : orders.map((o, rowIdx) => {
|
||||
const pct = getProgress(o);
|
||||
const pLabel = getProgressLabel(o);
|
||||
const pBadge = PROGRESS_BADGE[pLabel] || PROGRESS_BADGE["대기"];
|
||||
const sBadge = STATUS_BADGE[o.status] || STATUS_BADGE["일반"];
|
||||
const isFirstOfGroup = Number(o.detail_seq) === 1;
|
||||
|
||||
{/* 테이블 */}
|
||||
<EDataTable
|
||||
columns={[
|
||||
{ key: "work_instruction_no", label: "작업지시번호", width: "w-[150px]", render: (_v, row) => <span className="font-mono text-[13px] font-medium">{getDisplayNo(row)}</span> },
|
||||
{ key: "status", label: "상태", width: "w-[70px]", align: "center", render: (v) => {
|
||||
const sBadge = STATUS_BADGE[v] || STATUS_BADGE["일반"];
|
||||
return <Badge variant="outline" className={cn("text-[10px]", sBadge.cls)}>{sBadge.label}</Badge>;
|
||||
}},
|
||||
{ key: "progress", label: "진행현황", width: "w-[100px]", align: "center", sortable: false, filterable: false, render: (_v, row) => {
|
||||
const isFirstOfGroup = Number(row.detail_seq) === 1;
|
||||
if (!isFirstOfGroup) return <span className="text-[10px] text-muted-foreground">↑</span>;
|
||||
const pct = getProgress(row);
|
||||
const pLabel = getProgressLabel(row);
|
||||
const pBadge = PROGRESS_BADGE[pLabel] || PROGRESS_BADGE["대기"];
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Badge variant="secondary" className={cn("text-[10px]", pBadge.cls)}>{pBadge.label}</Badge>
|
||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className={cn("h-full rounded-full transition-all", pct >= 100 ? "bg-success" : pct > 0 ? "bg-primary" : "bg-muted-foreground/30")} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">{pct}%</span>
|
||||
</div>
|
||||
);
|
||||
}},
|
||||
{ key: "item_name", label: "품목명", render: (_v, row) => row.item_name || row.item_number || "-" },
|
||||
{ key: "item_spec", label: "규격", width: "w-[100px]" },
|
||||
{ key: "detail_qty", label: "수량", width: "w-[80px]", align: "right", formatNumber: true },
|
||||
{ key: "equipment_name", label: "설비", width: "w-[120px]", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" },
|
||||
{ key: "routing", label: "라우팅", width: "w-[120px]", sortable: false, filterable: false, render: (_v, row) => {
|
||||
const isFirstOfGroup = Number(row.detail_seq) === 1;
|
||||
if (!isFirstOfGroup) return "";
|
||||
if (row.routing_version_id) {
|
||||
return (
|
||||
<TableRow key={`${o.wi_id}-${o.detail_id}`} className="hover:bg-muted/50">
|
||||
<TableCell className="font-mono text-xs font-medium">{getDisplayNo(o)}</TableCell>
|
||||
<TableCell className="text-center"><Badge variant="outline" className={cn("text-[10px]", sBadge.cls)}>{sBadge.label}</Badge></TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isFirstOfGroup ? (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Badge variant="secondary" className={cn("text-[10px]", pBadge.cls)}>{pBadge.label}</Badge>
|
||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className={cn("h-full rounded-full transition-all", pct >= 100 ? "bg-emerald-500" : pct > 0 ? "bg-blue-500" : "bg-gray-300")} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">{pct}%</span>
|
||||
</div>
|
||||
) : <span className="text-[10px] text-muted-foreground">↑</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{o.item_name || o.item_number || "-"}</TableCell>
|
||||
<TableCell className="text-xs">{o.item_spec || "-"}</TableCell>
|
||||
<TableCell className="text-right text-xs font-medium">{Number(o.detail_qty || 0).toLocaleString()}</TableCell>
|
||||
<TableCell className="text-xs">{isFirstOfGroup ? (o.equipment_name || "-") : ""}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{isFirstOfGroup ? (
|
||||
o.routing_version_id ? (
|
||||
<button
|
||||
className="text-primary underline underline-offset-2 hover:text-primary/80 cursor-pointer text-xs text-left"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
openWorkStandardModal(
|
||||
o.work_instruction_no,
|
||||
o.routing_version_id,
|
||||
o.routing_name || "",
|
||||
o.item_name || o.item_number || "",
|
||||
o.item_number || ""
|
||||
);
|
||||
}}
|
||||
>
|
||||
{o.routing_name || "라우팅"} <ClipboardCheck className="w-3 h-3 inline ml-0.5" />
|
||||
</button>
|
||||
) : <span className="text-muted-foreground">-</span>
|
||||
) : ""}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs">{isFirstOfGroup ? (o.work_team || "-") : ""}</TableCell>
|
||||
<TableCell className="text-xs">{isFirstOfGroup ? getWorkerName(o.worker) : ""}</TableCell>
|
||||
<TableCell className="text-center text-xs">{isFirstOfGroup ? (o.start_date || "-") : ""}</TableCell>
|
||||
<TableCell className="text-center text-xs">{isFirstOfGroup ? (o.end_date || "-") : ""}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isFirstOfGroup && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs px-2" onClick={() => openEditModal(o)}><Pencil className="w-3 h-3 mr-1" /> 수정</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs px-2 text-destructive border-destructive/30 hover:bg-destructive/10" onClick={() => handleDelete(o.wi_id)}><Trash2 className="w-3 h-3 mr-1" /> 삭제</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<button
|
||||
className="text-primary underline underline-offset-2 hover:text-primary/80 cursor-pointer text-xs text-left"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
openWorkStandardModal(row.work_instruction_no, row.routing_version_id, row.routing_name || "", row.item_name || row.item_number || "", row.item_number || "");
|
||||
}}
|
||||
>
|
||||
{row.routing_name || "라우팅"} <ClipboardCheck className="w-3 h-3 inline ml-0.5" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}},
|
||||
{ key: "work_team", label: "작업조", width: "w-[80px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" },
|
||||
{ key: "worker", label: "작업자", width: "w-[100px]", render: (v, row) => Number(row.detail_seq) === 1 ? getWorkerName(v) : "" },
|
||||
{ key: "start_date", label: "시작일", width: "w-[100px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" },
|
||||
{ key: "end_date", label: "완료일", width: "w-[100px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" },
|
||||
{ key: "actions", label: "작업", width: "w-[150px]", align: "center", sortable: false, filterable: false, render: (_v, row) => {
|
||||
const isFirstOfGroup = Number(row.detail_seq) === 1;
|
||||
if (!isFirstOfGroup) return null;
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs px-2" onClick={() => openEditModal(row)}><Pencil className="w-3 h-3" /> 수정</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs px-2 text-destructive hover:text-destructive" onClick={() => handleDelete(row.wi_id)}><Trash2 className="w-3 h-3" /></Button>
|
||||
</div>
|
||||
);
|
||||
}},
|
||||
] as EDataTableColumn[]}
|
||||
data={ts.groupData(orders)}
|
||||
rowKey={(row) => `${row.wi_id}-${row.detail_id}`}
|
||||
loading={loading}
|
||||
emptyMessage="등록된 작업지시가 없어요"
|
||||
showPagination
|
||||
draggableColumns
|
||||
columnOrderKey="c16-work-instruction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 1단계: 등록 모달 ── */}
|
||||
<Dialog open={isRegModalOpen} onOpenChange={setIsRegModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1200px] h-[80vh] flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="px-6 py-4 border-b shrink-0">
|
||||
<DialogTitle className="text-base flex items-center gap-2"><Plus className="w-4 h-4" /> 작업지시 등록</DialogTitle>
|
||||
<DialogDescription className="text-xs">근거를 선택하고 품목을 체크한 후 "작업지시 적용" 버튼을 눌러주세요.</DialogDescription>
|
||||
<DialogDescription className="text-xs text-muted-foreground">근거를 선택하고 품목을 체크한 후 "작업지시 적용" 버튼을 눌러주세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 py-3 border-b bg-muted/30 flex items-center gap-3 flex-wrap shrink-0">
|
||||
<Label className="text-sm font-semibold whitespace-nowrap">근거:</Label>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground whitespace-nowrap">근거</span>
|
||||
<Select value={regSourceType} onValueChange={v => setRegSourceType(v as SourceType)}>
|
||||
<SelectTrigger className="h-9 w-[160px]"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="production">생산계획</SelectItem><SelectItem value="order">수주</SelectItem><SelectItem value="item">품목정보</SelectItem></SelectContent>
|
||||
@@ -542,25 +541,37 @@ export default function WorkInstructionPage() {
|
||||
<div className="flex-1" />
|
||||
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<Checkbox checked={regMergeSameItem} onCheckedChange={v => setRegMergeSameItem(!!v)} />
|
||||
<span className="text-sm font-semibold">동일품목 합산</span>
|
||||
<span className="text-xs font-semibold text-foreground">동일품목 합산</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto px-6 py-4">
|
||||
{!regSourceType ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm">근거를 선택하고 검색해주세요</div>
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="rounded-full border-2 border-dashed border-muted-foreground/20 w-12 h-12 flex items-center justify-center mb-4">
|
||||
<Search className="w-6 h-6 text-muted-foreground/40" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">근거를 선택해주세요</p>
|
||||
<p className="text-xs text-muted-foreground/60">근거를 선택하고 검색하면 품목이 표시돼요</p>
|
||||
</div>
|
||||
) : regSourceLoading ? (
|
||||
<div className="flex items-center justify-center h-full"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||
) : regSourceData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm">데이터가 없습니다</div>
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="rounded-full border-2 border-dashed border-muted-foreground/20 w-12 h-12 flex items-center justify-center mb-4">
|
||||
<Inbox className="w-6 h-6 text-muted-foreground/40" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">검색 결과가 없어요</p>
|
||||
<p className="text-xs text-muted-foreground/60">다른 키워드로 검색해주세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center"><Checkbox checked={regSourceData.length > 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /></TableHead>
|
||||
{regSourceType === "item" && <><TableHead className="w-[120px]">품목코드</TableHead><TableHead>품목명</TableHead><TableHead className="w-[120px]">규격</TableHead></>}
|
||||
{regSourceType === "order" && <><TableHead className="w-[110px]">수주번호</TableHead><TableHead className="w-[100px]">품번</TableHead><TableHead>품목명</TableHead><TableHead className="w-[100px]">규격</TableHead><TableHead className="w-[80px] text-right">수량</TableHead><TableHead className="w-[100px]">납기일</TableHead></>}
|
||||
{regSourceType === "production" && <><TableHead className="w-[110px]">계획번호</TableHead><TableHead className="w-[100px]">품번</TableHead><TableHead>품목명</TableHead><TableHead className="w-[80px] text-right">계획수량</TableHead><TableHead className="w-[90px]">시작일</TableHead><TableHead className="w-[90px]">완료일</TableHead><TableHead className="w-[100px]">설비</TableHead></>}
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground"><Checkbox checked={regSourceData.length > 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /></TableHead>
|
||||
{regSourceType === "item" && <><TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead><TableHead>품목명</TableHead><TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead></>}
|
||||
{regSourceType === "order" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수주번호</TableHead><TableHead className="w-[100px]">품번</TableHead><TableHead>품목명</TableHead><TableHead className="w-[100px]">규격</TableHead><TableHead className="w-[80px] text-right">수량</TableHead><TableHead className="w-[100px]">납기일</TableHead></>}
|
||||
{regSourceType === "production" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">계획번호</TableHead><TableHead className="w-[100px]">품번</TableHead><TableHead>품목명</TableHead><TableHead className="w-[80px] text-right">계획수량</TableHead><TableHead className="w-[90px]">시작일</TableHead><TableHead className="w-[90px]">완료일</TableHead><TableHead className="w-[100px]">설비</TableHead></>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -568,11 +579,11 @@ export default function WorkInstructionPage() {
|
||||
const id = getRegId(item);
|
||||
const checked = regCheckedIds.has(id);
|
||||
return (
|
||||
<TableRow key={`${regSourceType}-${id}-${idx}`} className={cn("cursor-pointer hover:bg-muted/50", checked && "bg-primary/5")} onClick={() => toggleRegItem(id)}>
|
||||
<TableRow key={`${regSourceType}-${id}-${idx}`} className={cn("cursor-pointer hover:bg-muted/30", checked && "bg-primary/5")} onClick={() => toggleRegItem(id)}>
|
||||
<TableCell className="text-center" onClick={e => e.stopPropagation()}><Checkbox checked={checked} onCheckedChange={() => toggleRegItem(id)} /></TableCell>
|
||||
{regSourceType === "item" && <><TableCell className="text-xs font-medium">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-xs">{item.spec || "-"}</TableCell></>}
|
||||
{regSourceType === "order" && <><TableCell className="text-xs">{item.order_no}</TableCell><TableCell className="text-xs">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-xs">{item.spec || "-"}</TableCell><TableCell className="text-right text-xs">{Number(item.qty || 0).toLocaleString()}</TableCell><TableCell className="text-xs">{item.due_date || "-"}</TableCell></>}
|
||||
{regSourceType === "production" && <><TableCell className="text-xs">{item.plan_no}</TableCell><TableCell className="text-xs">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-xs">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-xs">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-xs">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-xs">{item.equipment_name || "-"}</TableCell></>}
|
||||
{regSourceType === "item" && <><TableCell className="text-[13px] font-medium">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-[13px]">{item.spec || "-"}</TableCell></>}
|
||||
{regSourceType === "order" && <><TableCell className="text-[13px]">{item.order_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-[13px]">{item.spec || "-"}</TableCell><TableCell className="text-right text-[13px]">{Number(item.qty || 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.due_date || "-"}</TableCell></>}
|
||||
{regSourceType === "production" && <><TableCell className="text-[13px]">{item.plan_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-[13px]">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.equipment_name || "-"}</TableCell></>}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
@@ -585,9 +596,9 @@ export default function WorkInstructionPage() {
|
||||
<div className="px-6 py-2 border-t bg-muted/10 flex items-center justify-between shrink-0">
|
||||
<span className="text-xs text-muted-foreground">총 {regTotalCount}건 (선택: {regCheckedIds.size}건)</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="outline" size="icon" className="h-7 w-7" disabled={regPage <= 1} onClick={() => { const p = regPage - 1; setRegPage(p); fetchRegSource(p); }}><ChevronLeft className="w-3.5 h-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" disabled={regPage <= 1} onClick={() => { const p = regPage - 1; setRegPage(p); fetchRegSource(p); }}><ChevronLeft className="w-3.5 h-3.5" /></Button>
|
||||
<span className="text-xs font-medium px-2">{regPage} / {totalRegPages}</span>
|
||||
<Button variant="outline" size="icon" className="h-7 w-7" disabled={regPage >= totalRegPages} onClick={() => { const p = regPage + 1; setRegPage(p); fetchRegSource(p); }}><ChevronRight className="w-3.5 h-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" disabled={regPage >= totalRegPages} onClick={() => { const p = regPage + 1; setRegPage(p); fetchRegSource(p); }}><ChevronRight className="w-3.5 h-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -601,61 +612,78 @@ export default function WorkInstructionPage() {
|
||||
|
||||
{/* ── 2단계: 확인 모달 ── */}
|
||||
<Dialog open={isConfirmModalOpen} onOpenChange={setIsConfirmModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1000px] max-h-[90vh] flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="px-6 py-4 border-b shrink-0">
|
||||
<DialogTitle className="text-base flex items-center gap-2"><CheckCircle2 className="w-4 h-4" /> 작업지시 적용 확인</DialogTitle>
|
||||
<DialogDescription className="text-xs">기본 정보를 입력하고 "최종 적용" 버튼을 눌러주세요.</DialogDescription>
|
||||
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>작업지시 적용 확인</DialogTitle>
|
||||
<DialogDescription>기본 정보를 입력하고 '최종 적용' 버튼을 눌러주세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-auto p-6 space-y-5">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="space-y-5">
|
||||
<div className="bg-muted/30 border rounded-lg p-5">
|
||||
<h4 className="text-sm font-semibold mb-4">작업지시 기본 정보</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div className="space-y-1.5"><Label className="text-xs">작업지시번호</Label><Input value={confirmWiNo} readOnly className="h-9 bg-muted/50 text-muted-foreground" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">상태</Label>
|
||||
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground">작업지시 기본 정보</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">작업지시번호</Label><Input value={confirmWiNo} readOnly className="h-9 bg-muted cursor-not-allowed font-mono" /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">상태</Label>
|
||||
<Select value={confirmStatus} onValueChange={setConfirmStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반">일반</SelectItem><SelectItem value="긴급">긴급</SelectItem></SelectContent></Select>
|
||||
</div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">시작일</Label><FormDatePicker value={confirmStartDate} onChange={setConfirmStartDate} placeholder="시작일" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">완료예정일</Label><FormDatePicker value={confirmEndDate} onChange={setConfirmEndDate} placeholder="완료예정일" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">설비</Label>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">시작일</Label><Input type="date" value={confirmStartDate} onChange={(e) => setConfirmStartDate(e.target.value)} className="h-9" /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">완료예정일</Label><Input type="date" value={confirmEndDate} onChange={(e) => setConfirmEndDate(e.target.value)} className="h-9" /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">설비</Label>
|
||||
<Select value={nv(confirmEquipmentId)} onValueChange={v => setConfirmEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select>
|
||||
</div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">작업조</Label>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">작업조</Label>
|
||||
<Select value={nv(confirmWorkTeam)} onValueChange={v => setConfirmWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem><SelectItem value="주간">주간</SelectItem><SelectItem value="야간">야간</SelectItem></SelectContent></Select>
|
||||
</div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">작업자</Label>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">작업자</Label>
|
||||
<WorkerCombobox value={confirmWorker} onChange={setConfirmWorker} open={confirmWorkerOpen} onOpenChange={setConfirmWorkerOpen} />
|
||||
</div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">라우팅</Label>
|
||||
<Select value={nv(confirmRouting)} onValueChange={v => setConfirmRouting(fromNv(v))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="라우팅 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안 함</SelectItem>
|
||||
{confirmRoutingOptions.map(rv => (
|
||||
<SelectItem key={rv.id} value={rv.id}>
|
||||
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}공정
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">비고</Label><Input className="h-9" placeholder="비고를 입력해주세요" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg p-5">
|
||||
<h4 className="text-sm font-semibold mb-3">품목 목록</h4>
|
||||
<div className="max-h-[300px] overflow-auto">
|
||||
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground mb-3">품목 목록</h4>
|
||||
<div className="overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow><TableHead className="w-[60px]">순번</TableHead><TableHead className="w-[120px]">품목코드</TableHead><TableHead>품목명</TableHead><TableHead className="w-[100px]">규격</TableHead><TableHead className="w-[100px]">수량</TableHead><TableHead>비고</TableHead><TableHead className="w-[60px]" /></TableRow>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">순번</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
||||
<TableHead className="w-[180px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">라우팅</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>
|
||||
<TableHead className="w-[40px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{confirmItems.map((item, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className="text-xs text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-xs font-medium">{item.itemCode}</TableCell>
|
||||
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
|
||||
<TableCell className="text-sm">{item.itemName || item.itemCode}</TableCell>
|
||||
<TableCell className="text-xs">{item.spec || "-"}</TableCell>
|
||||
<TableCell><Input type="number" className="h-7 text-xs w-20" value={item.qty} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
||||
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
|
||||
<TableCell className="text-[13px]">{item.spec || "-"}</TableCell>
|
||||
<TableCell><Input type="number" className="h-7 text-[13px] w-20" value={item.qty} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={nv(item.routing || "")}
|
||||
onValueChange={v => {
|
||||
const val = fromNv(v);
|
||||
setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, routing: val } : it));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="라우팅" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안 함</SelectItem>
|
||||
{(item.routingOptions || []).map((rv: RoutingVersionData) => (
|
||||
<SelectItem key={rv.id} value={rv.id}>
|
||||
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}공정
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell><Input className="h-7 text-[13px]" value={item.remark} placeholder="비고" onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
|
||||
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setConfirmItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -664,7 +692,8 @@ export default function WorkInstructionPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="px-6 py-3 border-t shrink-0">
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setIsConfirmModalOpen(false); setIsRegModalOpen(true); }}><ChevronLeft className="w-4 h-4 mr-1" /> 이전</Button>
|
||||
<Button variant="outline" onClick={() => setIsConfirmModalOpen(false)}>취소</Button>
|
||||
<Button onClick={finalizeRegistration} disabled={saving}>{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <CheckCircle2 className="w-4 h-4 mr-1.5" />} 최종 적용</Button>
|
||||
@@ -673,83 +702,101 @@ export default function WorkInstructionPage() {
|
||||
</Dialog>
|
||||
|
||||
{/* ── 수정 모달 ── */}
|
||||
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1100px] max-h-[90vh] flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="px-6 py-4 border-b shrink-0">
|
||||
<DialogTitle className="text-base flex items-center gap-2"><Wrench className="w-4 h-4" /> 작업지시 관리 - {editOrder?.work_instruction_no}</DialogTitle>
|
||||
<DialogDescription className="text-xs">품목을 추가/삭제하고 정보를 수정하세요.</DialogDescription>
|
||||
<Dialog open={isEditModalOpen} onOpenChange={(v) => { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }}>
|
||||
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`}</DialogTitle>
|
||||
<DialogDescription>품목을 추가/삭제하고 정보를 수정해주세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-auto p-6 space-y-5">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="space-y-5">
|
||||
<div className="bg-muted/30 border rounded-lg p-5">
|
||||
<h4 className="text-sm font-semibold mb-4">기본 정보</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div className="space-y-1.5"><Label className="text-xs">상태</Label><Select value={editStatus} onValueChange={setEditStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반">일반</SelectItem><SelectItem value="긴급">긴급</SelectItem></SelectContent></Select></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">시작일</Label><FormDatePicker value={editStartDate} onChange={setEditStartDate} placeholder="시작일" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">완료예정일</Label><FormDatePicker value={editEndDate} onChange={setEditEndDate} placeholder="완료예정일" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">설비</Label><Select value={nv(editEquipmentId)} onValueChange={v => setEditEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">작업조</Label><Select value={nv(editWorkTeam)} onValueChange={v => setEditWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem><SelectItem value="주간">주간</SelectItem><SelectItem value="야간">야간</SelectItem></SelectContent></Select></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">작업자</Label>
|
||||
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground">기본 정보</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">상태</Label><Select value={editStatus} onValueChange={setEditStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반">일반</SelectItem><SelectItem value="긴급">긴급</SelectItem></SelectContent></Select></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">시작일</Label><Input type="date" value={editStartDate} onChange={(e) => setEditStartDate(e.target.value)} className="h-9" /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">완료예정일</Label><Input type="date" value={editEndDate} onChange={(e) => setEditEndDate(e.target.value)} className="h-9" /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">설비</Label><Select value={nv(editEquipmentId)} onValueChange={v => setEditEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">작업조</Label><Select value={nv(editWorkTeam)} onValueChange={v => setEditWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem><SelectItem value="주간">주간</SelectItem><SelectItem value="야간">야간</SelectItem></SelectContent></Select></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">작업자</Label>
|
||||
<WorkerCombobox value={editWorker} onChange={setEditWorker} open={editWorkerOpen} onOpenChange={setEditWorkerOpen} />
|
||||
</div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">라우팅</Label>
|
||||
<Select value={nv(editRouting)} onValueChange={v => setEditRouting(fromNv(v))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="라우팅 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안 함</SelectItem>
|
||||
{editRoutingOptions.map(rv => (
|
||||
<SelectItem key={rv.id} value={rv.id}>
|
||||
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}공정
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">공정작업기준</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-9 w-full text-xs"
|
||||
disabled={!editRouting}
|
||||
onClick={() => {
|
||||
if (!editOrder || !editRouting) return;
|
||||
const rv = editRoutingOptions.find(r => r.id === editRouting);
|
||||
openWorkStandardModal(
|
||||
editOrder.work_instruction_no,
|
||||
editRouting,
|
||||
rv?.version_name || "",
|
||||
editOrder.item_name || editOrder.item_number || "",
|
||||
editOrder.item_number || ""
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ClipboardCheck className="w-3.5 h-3.5 mr-1.5" /> 작업기준 수정
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2"><Label className="text-xs">비고</Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고" /></div>
|
||||
<div className="space-y-1 col-span-2"><Label className="text-[11px] font-semibold text-muted-foreground">비고</Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 테이블 */}
|
||||
{/* 품목 테이블 — 라우팅/공정작업기준을 품목별로 표시 */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between p-3 bg-muted/20 border-b">
|
||||
<span className="text-sm font-semibold">작업지시 항목</span>
|
||||
<span className="text-xs text-muted-foreground">{editItems.length}건</span>
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b">
|
||||
<span className="text-[13px] font-bold text-foreground">작업지시 항목</span>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/8 border border-primary/15 px-2 py-0.5 rounded-full font-mono">{editItems.length}건</span>
|
||||
</div>
|
||||
<div className="max-h-[280px] overflow-auto">
|
||||
<div className="overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow><TableHead className="w-[60px]">순번</TableHead><TableHead className="w-[120px]">품목코드</TableHead><TableHead>품목명</TableHead><TableHead className="w-[100px]">규격</TableHead><TableHead className="w-[100px] text-right">수량</TableHead><TableHead>비고</TableHead><TableHead className="w-[60px]" /></TableRow>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">순번</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
||||
<TableHead className="w-[180px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">라우팅</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공정작업기준</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>
|
||||
<TableHead className="w-[40px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{editItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={7} className="text-center py-8 text-muted-foreground text-sm">품목이 없습니다</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={9} className="text-center py-8 text-sm text-muted-foreground">등록된 품목이 없어요</TableCell></TableRow>
|
||||
) : editItems.map((item, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className="text-xs text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-xs font-medium">{item.itemCode}</TableCell>
|
||||
<TableCell className="text-xs max-w-[180px] truncate" title={item.itemName}>{item.itemName || "-"}</TableCell>
|
||||
<TableCell className="text-xs max-w-[100px] truncate" title={item.spec}>{item.spec || "-"}</TableCell>
|
||||
<TableCell className="text-right"><Input type="number" className="h-7 text-xs w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
||||
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
|
||||
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
|
||||
<TableCell className="text-[13px] max-w-[150px] truncate" title={item.itemName}>{item.itemName || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] truncate" title={item.spec}>{item.spec || "-"}</TableCell>
|
||||
<TableCell className="text-right"><Input type="number" className="h-7 text-[13px] w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={nv(item.routing || "")}
|
||||
onValueChange={v => {
|
||||
const val = fromNv(v);
|
||||
setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, routing: val } : it));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="라우팅" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안 함</SelectItem>
|
||||
{(item.routingOptions || []).map((rv: RoutingVersionData) => (
|
||||
<SelectItem key={rv.id} value={rv.id}>
|
||||
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}공정
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={!item.routing}
|
||||
onClick={() => {
|
||||
if (!editOrder || !item.routing) return;
|
||||
const rv = (item.routingOptions || []).find((r: RoutingVersionData) => r.id === item.routing);
|
||||
openWorkStandardModal(
|
||||
editOrder.work_instruction_no,
|
||||
item.routing,
|
||||
rv?.version_name || "",
|
||||
item.itemName || item.itemCode || "",
|
||||
item.itemCode || ""
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ClipboardCheck className="w-3 h-3 mr-1" /> 수정
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell><Input className="h-7 text-[13px]" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
|
||||
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -757,14 +804,15 @@ export default function WorkInstructionPage() {
|
||||
</Table>
|
||||
</div>
|
||||
{editItems.length > 0 && (
|
||||
<div className="p-3 border-t bg-muted/20 flex items-center justify-between">
|
||||
<span className="text-sm font-semibold">총 수량</span>
|
||||
<span className="text-lg font-bold text-primary">{editItems.reduce((s, i) => s + i.qty, 0).toLocaleString()} EA</span>
|
||||
<div className="px-4 py-2.5 border-t bg-muted/30 flex items-center justify-between">
|
||||
<span className="text-[13px] font-bold text-foreground">총 수량</span>
|
||||
<span className="text-lg font-bold font-mono text-primary">{editItems.reduce((s, i) => s + i.qty, 0).toLocaleString()} EA</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="px-6 py-3 border-t shrink-0">
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsEditModalOpen(false)}>취소</Button>
|
||||
<Button onClick={saveEdit} disabled={editSaving}>{editSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장</Button>
|
||||
</DialogFooter>
|
||||
@@ -781,6 +829,15 @@ export default function WorkInstructionPage() {
|
||||
itemName={wsModalItemName}
|
||||
itemCode={wsModalItemCode}
|
||||
/>
|
||||
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,721 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
|
||||
const TABLE_NAME = "item_inspection_info";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", label: "품목명" },
|
||||
{ key: "inspection_type", label: "검사유형" },
|
||||
{ key: "is_active", label: "사용여부" },
|
||||
];
|
||||
const ITEM_TABLE = "item_info";
|
||||
const INSPECTION_TABLE = "inspection_standard";
|
||||
|
||||
const INSPECTION_TYPES = [
|
||||
{ key: "incoming_inspection", label: "수입검사", matchLabels: ["수입검사", "입고검사", "수입", "입고"] },
|
||||
{ key: "outgoing_inspection", label: "출하검사", matchLabels: ["출하검사", "출고검사", "출하", "출고"] },
|
||||
{ key: "process_inspection", label: "공정검사", matchLabels: ["공정검사", "공정"] },
|
||||
{ key: "final_inspection", label: "최종검사", matchLabels: ["최종검사", "최종", "완제품검사"] },
|
||||
] as const;
|
||||
|
||||
type InspectionRow = {
|
||||
id: string;
|
||||
inspection_standard_id: string;
|
||||
inspection_detail: string;
|
||||
inspection_method: string;
|
||||
apply_process: string;
|
||||
acceptance_criteria: string;
|
||||
is_required: boolean;
|
||||
};
|
||||
|
||||
export default function ItemInspectionInfoPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
const ts = useTableSettings("c16-item-inspection", TABLE_NAME, GRID_COLUMNS);
|
||||
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [form, setForm] = useState<Record<string, any>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
/* FK 옵션 */
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; types: string[] }[]>([]);
|
||||
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
/* 검사유형별 검사항목 rows */
|
||||
const [inspectionRows, setInspectionRows] = useState<Record<string, InspectionRow[]>>({});
|
||||
const [collapsedTypes, setCollapsedTypes] = useState<Record<string, boolean>>({});
|
||||
|
||||
/* 품목 선택 모달 */
|
||||
const [itemModalOpen, setItemModalOpen] = useState(false);
|
||||
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
|
||||
const [filteredItems, setFilteredItems] = useState<typeof itemOptions>([]);
|
||||
|
||||
/* ═══════════════════ FK 옵션 로드 ═══════════════════ */
|
||||
useEffect(() => {
|
||||
const loadOptions = async () => {
|
||||
try {
|
||||
const [itemRes, inspRes, userRes] = await Promise.all([
|
||||
apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { page: 1, size: 500, autoFilter: true }),
|
||||
apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, { page: 1, size: 500, autoFilter: true }),
|
||||
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true }),
|
||||
]);
|
||||
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||
setItemOptions(items.map((r: any) => ({
|
||||
code: r.item_number || r.item_code || "",
|
||||
name: r.item_name || "",
|
||||
item_type: r.type || r.item_type || "",
|
||||
unit: r.unit || "",
|
||||
})));
|
||||
|
||||
const insps = inspRes.data?.data?.data || inspRes.data?.data?.rows || [];
|
||||
setInspOptions(insps.map((r: any) => ({
|
||||
code: r.id,
|
||||
label: r.inspection_criteria || r.inspection_standard || r.id,
|
||||
detail: r.inspection_item || r.inspection_criteria || "",
|
||||
method: r.inspection_method || "",
|
||||
types: r.inspection_type ? r.inspection_type.split(",").filter(Boolean) : [],
|
||||
})));
|
||||
|
||||
// 검사유형 카테고리 값 로드 (코드→라벨 매핑용)
|
||||
try {
|
||||
const catRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_type/values`);
|
||||
const flatCats: { code: string; label: string }[] = [];
|
||||
const flatten = (arr: any[]) => { for (const v of arr) { flatCats.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flatten(v.children); } };
|
||||
if (catRes.data?.data?.length) flatten(catRes.data.data);
|
||||
setInspTypeCatOptions(flatCats);
|
||||
} catch { /* skip */ }
|
||||
|
||||
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||
setUserOptions(users.map((u: any) => ({
|
||||
code: u.user_id || u.id,
|
||||
label: `${u.user_name || u.name || u.user_id}${u.dept_name ? ` (${u.dept_name})` : ""}`,
|
||||
})));
|
||||
} catch { /* skip */ }
|
||||
};
|
||||
loadOptions();
|
||||
}, []);
|
||||
|
||||
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
|
||||
const openItemModal = () => {
|
||||
setItemSearchKeyword("");
|
||||
setFilteredItems(itemOptions);
|
||||
setItemModalOpen(true);
|
||||
};
|
||||
|
||||
const handleItemSearch = () => {
|
||||
const kw = itemSearchKeyword.trim().toLowerCase();
|
||||
if (!kw) { setFilteredItems(itemOptions); return; }
|
||||
setFilteredItems(itemOptions.filter(o =>
|
||||
o.code.toLowerCase().includes(kw) || o.name.toLowerCase().includes(kw)
|
||||
));
|
||||
};
|
||||
|
||||
const selectItem = (item: typeof itemOptions[0]) => {
|
||||
setForm(p => ({ ...p, item_code: item.code, item_name: item.name }));
|
||||
setItemModalOpen(false);
|
||||
};
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setData(rows);
|
||||
setTotalCount(rows.length);
|
||||
} catch {
|
||||
toast.error("품목검사정보 조회에 실패했어요");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
// item_code별 그룹핑
|
||||
const groupedData = useMemo(() => {
|
||||
const map: Record<string, { item_code: string; item_name: string; is_active: string; types: string[]; rows: any[] }> = {};
|
||||
for (const row of data) {
|
||||
const key = row.item_code || row.id;
|
||||
if (!map[key]) {
|
||||
map[key] = { item_code: row.item_code, item_name: row.item_name, is_active: row.is_active || "", types: [], rows: [] };
|
||||
}
|
||||
map[key].rows.push(row);
|
||||
if (row.inspection_type && !map[key].types.includes(row.inspection_type)) {
|
||||
map[key].types.push(row.inspection_type);
|
||||
}
|
||||
}
|
||||
return Object.values(map);
|
||||
}, [data]);
|
||||
|
||||
// 검사기준 ID → 라벨 resolve
|
||||
const resolveInspLabel = useCallback((id: string) => {
|
||||
const opt = inspOptions.find(o => o.code === id);
|
||||
return opt?.label || id || "-";
|
||||
}, [inspOptions]);
|
||||
|
||||
/* ═══════════════════ CRUD ═══════════════════ */
|
||||
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
|
||||
const openEdit = async (row: any) => {
|
||||
setForm({ ...row });
|
||||
setEditMode(true);
|
||||
setCollapsedTypes({});
|
||||
|
||||
// 같은 item_code의 모든 검사항목 행을 조회하여 유형별로 분류
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: row.item_code }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const rowMap: Record<string, InspectionRow[]> = {};
|
||||
const typeFlags: Record<string, boolean> = {};
|
||||
|
||||
for (const r of allRows) {
|
||||
const inspType = r.inspection_type || "";
|
||||
// 카테고리 코드/라벨로 INSPECTION_TYPES 키 매칭
|
||||
const matched = INSPECTION_TYPES.find(t =>
|
||||
t.matchLabels.some(ml => inspType.includes(ml)) ||
|
||||
inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml)))
|
||||
);
|
||||
const typeKey = matched?.key || "";
|
||||
if (!typeKey) continue;
|
||||
typeFlags[typeKey] = true;
|
||||
if (!rowMap[typeKey]) rowMap[typeKey] = [];
|
||||
rowMap[typeKey].push({
|
||||
id: r.id,
|
||||
inspection_standard_id: r.inspection_standard_id || "",
|
||||
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
|
||||
inspection_method: r.inspection_method || "",
|
||||
apply_process: "",
|
||||
acceptance_criteria: r.pass_criteria || "",
|
||||
is_required: r.is_required === "true" || r.is_required === true,
|
||||
});
|
||||
}
|
||||
|
||||
setInspectionRows(rowMap);
|
||||
setForm(p => ({ ...p, ...typeFlags }));
|
||||
} catch {
|
||||
setInspectionRows({});
|
||||
}
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
/* ═══════════════════ 검사항목 행 관리 ═══════════════════ */
|
||||
const addInspRow = (typeKey: string) => {
|
||||
setInspectionRows(prev => ({
|
||||
...prev,
|
||||
[typeKey]: [...(prev[typeKey] || []), {
|
||||
id: crypto.randomUUID(),
|
||||
inspection_standard_id: "",
|
||||
inspection_detail: "",
|
||||
inspection_method: "",
|
||||
apply_process: "",
|
||||
acceptance_criteria: "",
|
||||
is_required: false,
|
||||
}],
|
||||
}));
|
||||
};
|
||||
|
||||
const removeInspRow = (typeKey: string, rowId: string) => {
|
||||
setInspectionRows(prev => ({
|
||||
...prev,
|
||||
[typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId),
|
||||
}));
|
||||
};
|
||||
|
||||
const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
|
||||
setInspectionRows(prev => ({
|
||||
...prev,
|
||||
[typeKey]: (prev[typeKey] || []).map(r => {
|
||||
if (r.id !== rowId) return r;
|
||||
if (field === "inspection_standard_id") {
|
||||
const opt = inspOptions.find(o => o.code === value);
|
||||
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: opt?.method || "" };
|
||||
}
|
||||
return { ...r, [field]: value };
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
/** 검사유형 키에 매칭되는 검사기준만 필터링 */
|
||||
const getFilteredInspOptions = (typeKey: string) => {
|
||||
const typeDef = INSPECTION_TYPES.find(t => t.key === typeKey);
|
||||
if (!typeDef) return inspOptions;
|
||||
// matchLabels와 카테고리 라벨을 비교하여 해당 카테고리 코드를 찾음
|
||||
const matchCodes = inspTypeCatOptions
|
||||
.filter(cat => typeDef.matchLabels.some(ml => cat.label.includes(ml)))
|
||||
.map(cat => cat.code);
|
||||
if (matchCodes.length === 0) return inspOptions;
|
||||
return inspOptions.filter(opt => opt.types.some(t => matchCodes.includes(t)));
|
||||
};
|
||||
|
||||
const toggleCollapse = (typeKey: string) => {
|
||||
setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
// 기존 행 삭제 (수정 모드)
|
||||
if (editMode) {
|
||||
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: form.item_code }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
|
||||
data: existing.map((r: any) => ({ id: r.id })),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 검사유형별 항목을 개별 행으로 INSERT
|
||||
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
|
||||
const rows: any[] = [];
|
||||
for (const t of enabledTypes) {
|
||||
const typeLabel = t.label;
|
||||
const typeRows = inspectionRows[t.key] || [];
|
||||
if (typeRows.length === 0) {
|
||||
// 유형만 체크하고 항목 없는 경우에도 1행 생성
|
||||
rows.push({
|
||||
id: crypto.randomUUID(),
|
||||
item_code: form.item_code,
|
||||
item_name: form.item_name,
|
||||
inspection_type: typeLabel,
|
||||
is_active: form.is_active || "사용",
|
||||
manager_id: form.manager_id || "",
|
||||
memo: form.remarks || "",
|
||||
});
|
||||
} else {
|
||||
for (const r of typeRows) {
|
||||
rows.push({
|
||||
id: crypto.randomUUID(),
|
||||
item_code: form.item_code,
|
||||
item_name: form.item_name,
|
||||
inspection_type: typeLabel,
|
||||
inspection_standard_id: r.inspection_standard_id || "",
|
||||
inspection_item_name: r.inspection_detail || "",
|
||||
inspection_method: r.inspection_method || "",
|
||||
pass_criteria: r.acceptance_criteria || "",
|
||||
is_required: r.is_required ? "true" : "false",
|
||||
is_active: form.is_active || "사용",
|
||||
manager_id: form.manager_id || "",
|
||||
memo: form.remarks || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row);
|
||||
}
|
||||
|
||||
toast.success(editMode ? "품목검사정보를 수정했어요" : "품목검사정보를 등록했어요");
|
||||
setModalOpen(false);
|
||||
fetchData();
|
||||
} catch { toast.error("저장에 실패했어요"); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
|
||||
const ok = await confirm("품목검사정보 삭제", {
|
||||
description: `선택한 ${checkedIds.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
|
||||
data: checkedIds.map(id => ({ id })),
|
||||
});
|
||||
toast.success(`${checkedIds.length}건을 삭제했어요`);
|
||||
setCheckedIds([]);
|
||||
fetchData();
|
||||
} catch { toast.error("삭제에 실패했어요"); }
|
||||
};
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="px-3 py-2.5 border-b bg-muted/50">
|
||||
<DynamicSearchFilter
|
||||
tableName={TABLE_NAME}
|
||||
filterId="c16-item-inspection"
|
||||
onFilterChange={setSearchFilters}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
dataCount={totalCount}
|
||||
extraActions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={openCreate}><Plus className="w-4 h-4 mr-1" />등록</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => {
|
||||
const sel = data.find(r => checkedIds.includes(r.id));
|
||||
if (sel) {
|
||||
const group = groupedData.find(g => g.item_code === sel.item_code);
|
||||
openEdit(group?.rows[0] || sel);
|
||||
} else toast.error("수정할 항목을 선택해주세요");
|
||||
}}><Pencil className="w-4 h-4 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" onClick={handleDelete}><Trash2 className="w-4 h-4 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
|
||||
) : groupedData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<Inbox className="h-10 w-10 mb-2 opacity-30" />
|
||||
<p className="text-sm">등록된 품목검사정보가 없어요</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-10" />
|
||||
<TableHead className="w-10"><Checkbox checked={groupedData.length > 0 && checkedIds.length === data.length} onCheckedChange={(v) => setCheckedIds(v ? data.map(r => r.id) : [])} /></TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">검사유형</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">항목수</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">사용여부</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupedData.map((group) => {
|
||||
const isExpanded = expandedItems.has(group.item_code);
|
||||
const groupIds = group.rows.map(r => r.id);
|
||||
const allChecked = groupIds.every(id => checkedIds.includes(id));
|
||||
return (
|
||||
<React.Fragment key={group.item_code}>
|
||||
<TableRow
|
||||
className={cn("cursor-pointer border-l-2 border-l-transparent", allChecked && "border-l-primary bg-primary/5")}
|
||||
onClick={() => setExpandedItems(prev => { const next = new Set(prev); if (next.has(group.item_code)) next.delete(group.item_code); else next.add(group.item_code); return next; })}
|
||||
onDoubleClick={() => openEdit(group.rows[0])}
|
||||
>
|
||||
<TableCell className="text-center p-2">
|
||||
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !groupIds.includes(id)) : [...new Set([...prev, ...groupIds])]); }}>
|
||||
<Checkbox checked={allChecked} onCheckedChange={() => {}} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-medium text-primary">{group.item_code}</TableCell>
|
||||
<TableCell className="text-sm">{group.item_name}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{group.types.map(t => <Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-center">{group.rows.filter(r => r.inspection_standard_id).length}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
|
||||
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isExpanded && group.rows.filter(r => r.inspection_standard_id).map((row, i) => (
|
||||
<TableRow key={row.id} className="bg-muted/30 text-xs">
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell className="pl-6 text-muted-foreground">{row.inspection_type}</TableCell>
|
||||
<TableCell>{resolveInspLabel(row.inspection_standard_id)}</TableCell>
|
||||
<TableCell>{row.inspection_item_name || "-"}</TableCell>
|
||||
<TableCell>{row.inspection_method || "-"}</TableCell>
|
||||
<TableCell>{row.pass_criteria || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<div className="mt-2 text-xs text-muted-foreground">전체 {groupedData.length}건 (품목 기준)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════ 등록/수정 모달 (품목선택 뷰 포함) ═══════════════════ */}
|
||||
<Dialog open={modalOpen} onOpenChange={(open) => { if (!open) setItemModalOpen(false); setModalOpen(open); }}>
|
||||
<DialogContent className={cn("max-w-[95vw] max-h-[85vh] overflow-y-auto", itemModalOpen ? "sm:max-w-xl" : "sm:max-w-4xl")}>
|
||||
{itemModalOpen ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>품목 선택</DialogTitle>
|
||||
<DialogDescription>품목코드 또는 품목명으로 검색</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="h-9 flex-1"
|
||||
placeholder="품목코드 또는 품목명으로 검색"
|
||||
value={itemSearchKeyword}
|
||||
onChange={(e) => setItemSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleItemSearch(); }}
|
||||
/>
|
||||
<Button size="sm" className="h-9" onClick={handleItemSearch}>
|
||||
<Search className="w-4 h-4 mr-1" />검색
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-auto max-h-[50vh]">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="text-[11px] font-bold">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold">품목명</TableHead>
|
||||
<TableHead className="text-[11px] font-bold">품목유형</TableHead>
|
||||
<TableHead className="text-[11px] font-bold">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">검색 결과가 없어요</TableCell></TableRow>
|
||||
) : filteredItems.map((item) => (
|
||||
<TableRow
|
||||
key={item.code}
|
||||
className="cursor-pointer hover:bg-primary/5"
|
||||
onClick={() => selectItem(item)}
|
||||
>
|
||||
<TableCell className="text-sm">{item.code}</TableCell>
|
||||
<TableCell className="text-sm">{item.name}</TableCell>
|
||||
<TableCell className="text-sm">{item.item_type}</TableCell>
|
||||
<TableCell className="text-sm">{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setItemModalOpen(false)}>취소</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editMode ? "품목검사정보 수정" : "품목검사정보 등록"}</DialogTitle>
|
||||
<DialogDescription className="sr-only">품목검사정보를 등록합니다</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{/* 품목 정보 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold flex items-center gap-1.5">📦 품목 정보</h4>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">품목코드 <span className="text-destructive">*</span></Label>
|
||||
<Input className="h-9 bg-muted" value={form.item_code || ""} readOnly placeholder="품목코드" />
|
||||
</div>
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">품목명 <span className="text-destructive">*</span></Label>
|
||||
<Input className="h-9 bg-muted" value={form.item_name || ""} readOnly placeholder="품목명" />
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" className="h-9 px-3 shrink-0" onClick={openItemModal}>
|
||||
<Search className="w-4 h-4 mr-1" />품목선택
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">사용여부</Label>
|
||||
<Select value={form.is_active === false ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Y">사용</SelectItem>
|
||||
<SelectItem value="N">미사용</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">관리자</Label>
|
||||
<Select value={form.manager || ""} onValueChange={(v) => setForm(p => ({ ...p, manager: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="관리자 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{userOptions.map(o => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">비고</Label>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm min-h-[70px] resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
value={form.remarks || ""}
|
||||
onChange={(e) => setForm(p => ({ ...p, remarks: e.target.value }))}
|
||||
placeholder="비고 사항"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 검사유형 선택 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold flex items-center gap-1.5">✅ 검사유형 선택</h4>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{INSPECTION_TYPES.map(({ key, label }) => (
|
||||
<div key={key} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={!!form[key]}
|
||||
onCheckedChange={(v) => setForm(p => ({ ...p, [key]: !!v }))}
|
||||
/>
|
||||
<Label className="text-sm cursor-pointer">{label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* 검사유형별 검사항목 설정 */}
|
||||
{INSPECTION_TYPES.filter(t => !!form[t.key]).map(({ key, label }) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 py-2 px-3 rounded-md border bg-muted/50 hover:bg-muted text-left"
|
||||
onClick={() => toggleCollapse(key)}
|
||||
>
|
||||
<Badge variant="default" className="text-xs">{label}</Badge>
|
||||
<span className="text-sm font-medium">검사항목 설정</span>
|
||||
<ChevronDown className={cn("w-4 h-4 ml-auto transition-transform", collapsedTypes[key] && "-rotate-90")} />
|
||||
</button>
|
||||
{!collapsedTypes[key] && (
|
||||
<div className="space-y-2 pl-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-muted-foreground">검사항목 목록</span>
|
||||
<Button type="button" size="sm" variant="outline" className="h-7 text-xs" onClick={() => addInspRow(key)}>
|
||||
<Plus className="w-3 h-3 mr-1" />항목추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
<TableHead className="text-[10px] font-bold w-[170px]">검사기준 선택</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[130px]">검사기준 상세</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[90px]">검사방법</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[100px]">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold">합격기준 (판단기준별)</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
|
||||
<TableRow><TableCell colSpan={7} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
) : inspectionRows[key].map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="p-1">
|
||||
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateInspRow(key, row.id, "inspection_standard_id", v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="검사기준 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{getFilteredInspOptions(key).map(o => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="검사기준 상세" />
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="선택" />
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Select value={row.apply_process || ""} onValueChange={(v) => updateInspRow(key, row.id, "apply_process", v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공정 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="incoming">입고</SelectItem>
|
||||
<SelectItem value="process">공정</SelectItem>
|
||||
<SelectItem value="outgoing">출고</SelectItem>
|
||||
<SelectItem value="final">최종</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={row.acceptance_criteria}
|
||||
onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)}
|
||||
placeholder={row.inspection_standard_id ? "합격기준 입력" : "검사기준을 먼저 선택하세요"}
|
||||
disabled={!row.inspection_standard_id}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center">
|
||||
<Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} />
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Button type="button" variant="destructive" size="sm" className="h-7 w-7 p-0" onClick={() => removeInspRow(key, row.id)}>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,995 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, FileText,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
import {
|
||||
ResizablePanelGroup, ResizablePanel, ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import { ReportInlineViewer } from "@/components/report/ReportInlineViewer";
|
||||
import { ReportMaster, ComponentConfig } from "@/types/report";
|
||||
|
||||
const MASTER_TABLE = "quote_mng";
|
||||
|
||||
const fmt = (val: string) => {
|
||||
const num = val.replace(/[^\d.-]/g, "");
|
||||
if (!num) return "";
|
||||
const parts = num.split(".");
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
return parts.join(".");
|
||||
};
|
||||
const pn = (val: string) => val.replace(/,/g, "");
|
||||
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "quote_no", label: "견적번호", width: "w-[120px]" },
|
||||
{ key: "customer_name", label: "거래처명", width: "w-[150px]" },
|
||||
{ key: "quote_date", label: "견적일자", width: "w-[110px]" },
|
||||
{ key: "valid_until", label: "유효기한", width: "w-[110px]" },
|
||||
{ key: "total_amount", label: "견적금액", width: "w-[120px]", formatNumber: true, align: "right" },
|
||||
{ key: "status_label", label: "상태", width: "w-[90px]" },
|
||||
{ key: "manager", label: "담당자", width: "w-[100px]" },
|
||||
{ key: "domestic_type", label: "국내/국외", width: "w-[90px]" },
|
||||
];
|
||||
|
||||
const STATUS_MAP: Record<string, string> = {
|
||||
draft: "작성중", pending: "검토중", approved: "승인", rejected: "반려", converted: "수주전환",
|
||||
};
|
||||
|
||||
const EMPTY_ITEM = {
|
||||
item_code: "", item_name: "", spec: "", qty: "1", unit: "EA",
|
||||
request_length: "", unit_price: "0", supply_amount: "0", vat_amount: "0", total_amount: "0", notes: "",
|
||||
};
|
||||
|
||||
export default function QuoteManagementPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
const [quotes, setQuotes] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
const [selectedRow, setSelectedRow] = useState<any | null>(null);
|
||||
|
||||
// 컴포넌트 클릭 편집 모달
|
||||
const [editComp, setEditComp] = useState<ComponentConfig | null>(null);
|
||||
const [editValues, setEditValues] = useState<Record<string, string>>({});
|
||||
const [items, setItems] = useState<(typeof EMPTY_ITEM)[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 기본정보 모달
|
||||
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
|
||||
const [basicForm, setBasicForm] = useState({ quote_date: "", valid_until: "", status: "draft" });
|
||||
|
||||
// 엑셀 / 리포트
|
||||
const [excelOpen, setExcelOpen] = useState(false);
|
||||
const [reportList, setReportList] = useState<ReportMaster[]>([]);
|
||||
const [selectedReportId, setSelectedReportId] = useState<string | null>(null);
|
||||
const [reportKey, setReportKey] = useState(0);
|
||||
|
||||
// 품목 검색
|
||||
const [itemSearchOpen, setItemSearchOpen] = useState(false);
|
||||
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
|
||||
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
|
||||
const [itemSearchLoading, setItemSearchLoading] = useState(false);
|
||||
const [itemSelectedMap, setItemSelectedMap] = useState<Map<string, any>>(new Map());
|
||||
|
||||
// 거래처 검색
|
||||
const [custSearchOpen, setCustSearchOpen] = useState(false);
|
||||
const [custSearchKeyword, setCustSearchKeyword] = useState("");
|
||||
const [custSearchResults, setCustSearchResults] = useState<any[]>([]);
|
||||
const [custSearchLoading, setCustSearchLoading] = useState(false);
|
||||
|
||||
// 사원(담당자) 검색
|
||||
const [userSearchOpen, setUserSearchOpen] = useState(false);
|
||||
const [userSearchKeyword, setUserSearchKeyword] = useState("");
|
||||
const [userSearchResults, setUserSearchResults] = useState<any[]>([]);
|
||||
const [userSearchLoading, setUserSearchLoading] = useState(false);
|
||||
|
||||
// ── 데이터 로드 ──
|
||||
|
||||
const mapRow = (r: any) => ({
|
||||
...r, id: String(r.objid), status_label: STATUS_MAP[r.status] ?? r.status, total_amount: Number(r.total_amount || 0),
|
||||
});
|
||||
|
||||
const fetchQuotes = useCallback(async () => {
|
||||
if (!user) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
searchFilters.forEach((f) => { if (f.value) params[f.columnName] = f.value; });
|
||||
const res = await apiClient.get("/quotes/list", { params });
|
||||
const mapped = (res.data?.data ?? []).map(mapRow);
|
||||
setQuotes(mapped);
|
||||
setTotalCount(mapped.length);
|
||||
} catch { toast.error("견적 목록 조회 실패"); }
|
||||
finally { setLoading(false); }
|
||||
}, [user, searchFilters]);
|
||||
|
||||
useEffect(() => { fetchQuotes(); }, [fetchQuotes]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await reportApi.getReports({ page: 1, limit: 100 });
|
||||
if (res.success) {
|
||||
const items = res.data.items ?? [];
|
||||
setReportList(items);
|
||||
if (items.length > 0 && !selectedReportId) setSelectedReportId(items[0].report_id);
|
||||
}
|
||||
} catch { /* 무시 */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// ── "신규" → DB에 draft 즉시 생성 → 자동 선택 ──
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const numRes = await apiClient.get("/quotes/generate-number");
|
||||
const quoteNo = numRes.data?.data?.quoteNo ?? "";
|
||||
const createRes = await apiClient.post("/quotes", {
|
||||
quote_no: quoteNo,
|
||||
quote_date: new Date().toISOString().split("T")[0],
|
||||
status: "draft",
|
||||
customer_name: "",
|
||||
items: [],
|
||||
});
|
||||
toast.success("신규 견적이 생성되었습니다. 우측 양식에서 각 영역을 클릭하여 입력하세요.");
|
||||
|
||||
const listRes = await apiClient.get("/quotes/list");
|
||||
const mapped = (listRes.data?.data ?? []).map(mapRow);
|
||||
setQuotes(mapped);
|
||||
setTotalCount(mapped.length);
|
||||
|
||||
const newObjid = createRes.data?.data?.objid;
|
||||
const newRow = newObjid ? mapped.find((r: any) => r.objid === newObjid) : mapped[0];
|
||||
if (newRow) setSelectedRow(newRow);
|
||||
} catch { toast.error("견적 생성 실패"); }
|
||||
};
|
||||
|
||||
// ── 삭제 ──
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (checkedIds.length === 0) { toast.info("삭제할 견적을 선택하세요."); return; }
|
||||
const ok = await confirm(`${checkedIds.length}건의 견적을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
for (const id of checkedIds) await apiClient.delete(`/quotes/${id}`);
|
||||
toast.success("삭제 완료");
|
||||
setCheckedIds([]);
|
||||
setSelectedRow(null);
|
||||
fetchQuotes();
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// ── 컴포넌트 클릭 → 편집 모달 ──
|
||||
|
||||
const handleComponentClick = async (comp: ComponentConfig) => {
|
||||
if (!selectedRow) return;
|
||||
setEditComp(comp);
|
||||
|
||||
if (comp.type === "table") {
|
||||
// 테이블 → 품목 편집
|
||||
try {
|
||||
const res = await apiClient.get(`/quotes/${selectedRow.objid}`);
|
||||
const d = res.data?.data;
|
||||
setItems(
|
||||
(d?.items || []).length > 0
|
||||
? d.items.map((it: any) => ({
|
||||
item_code: it.item_code || "", item_name: it.item_name || "", spec: it.spec || "",
|
||||
qty: String(it.qty ?? 1), unit: it.unit || "EA",
|
||||
request_length: it.request_length ? String(it.request_length) : "",
|
||||
unit_price: String(it.unit_price ?? 0), supply_amount: String(it.supply_amount ?? 0),
|
||||
vat_amount: String(it.vat_amount ?? 0), total_amount: String(it.total_amount ?? 0),
|
||||
notes: it.notes || "",
|
||||
}))
|
||||
: [{ ...EMPTY_ITEM }],
|
||||
);
|
||||
} catch { setItems([{ ...EMPTY_ITEM }]); }
|
||||
} else if (comp.type === "card") {
|
||||
const cardItems = (comp as any).cardItems ?? [];
|
||||
// 우측 카드 (회사정보 + 담당자) → 담당자 선택
|
||||
const hasCompanyField = cardItems.some((ci: any) =>
|
||||
["company_name_self", "ceo_self", "biz_no_self", "address_self"].includes(ci.fieldName || "")
|
||||
);
|
||||
if (hasCompanyField) {
|
||||
setUserSearchKeyword("");
|
||||
setUserSearchResults([]);
|
||||
setUserSearchOpen(true);
|
||||
searchUsers();
|
||||
return;
|
||||
}
|
||||
// 좌측 카드 (거래처) → 거래처 검색
|
||||
const hasCustomerField = cardItems.some((ci: any) =>
|
||||
["customer_name", "customer_ceo", "customer_biz_no"].includes(ci.fieldName || "")
|
||||
);
|
||||
if (hasCustomerField) {
|
||||
setCustSearchKeyword("");
|
||||
setCustSearchResults([]);
|
||||
setCustSearchOpen(true);
|
||||
searchCustomers();
|
||||
return;
|
||||
}
|
||||
// 기타 카드
|
||||
const vals: Record<string, string> = {};
|
||||
cardItems.forEach((ci: any) => {
|
||||
if (ci.fieldName) vals[ci.fieldName] = selectedRow[ci.fieldName] ?? ci.value ?? "";
|
||||
});
|
||||
setEditValues(vals);
|
||||
} else if (comp.type === "text" || comp.type === "label") {
|
||||
// 텍스트 → 기본정보 모달 하나로 통합
|
||||
const basicFields = ["quote_no", "quote_date", "valid_until", "status"];
|
||||
if (comp.fieldName && basicFields.includes(comp.fieldName)) {
|
||||
// 기본정보 모달
|
||||
const detail = await apiClient.get(`/quotes/${selectedRow.objid}`).then(r => r.data?.data).catch(() => null);
|
||||
setBasicForm({
|
||||
quote_date: detail?.quote_date || "",
|
||||
valid_until: detail?.valid_until || "",
|
||||
status: detail?.status || "draft",
|
||||
});
|
||||
setBasicInfoOpen(true);
|
||||
setEditComp(null);
|
||||
return;
|
||||
}
|
||||
// 기타 텍스트 (제목 등)
|
||||
toast.info("이 영역은 리포트 디자이너에서 수정하세요.");
|
||||
setEditComp(null);
|
||||
return;
|
||||
} else if (comp.type === "calculation") {
|
||||
// 계산은 읽기 전용 안내
|
||||
toast.info("계산 컴포넌트는 품목 데이터에서 자동 계산됩니다.");
|
||||
setEditComp(null);
|
||||
} else if (comp.type === "signature" || comp.type === "stamp") {
|
||||
toast.info("서명/도장은 리포트 디자이너에서 설정하세요.");
|
||||
setEditComp(null);
|
||||
} else {
|
||||
setEditValues({});
|
||||
}
|
||||
};
|
||||
|
||||
// ── 컴포넌트 편집 저장 ──
|
||||
|
||||
const handleEditSave = async () => {
|
||||
if (!selectedRow || !editComp) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editComp.type === "table") {
|
||||
// 품목 저장 — 기존 견적 데이터 불러와서 품목만 교체
|
||||
const detailRes = await apiClient.get(`/quotes/${selectedRow.objid}`);
|
||||
const existing = detailRes.data?.data ?? {};
|
||||
const payload = {
|
||||
...existing,
|
||||
items: items.filter((it) => it.item_name).map((it) => ({
|
||||
item_code: it.item_code, item_name: it.item_name, spec: it.spec,
|
||||
qty: Number(pn(it.qty)) || 0, unit: it.unit,
|
||||
request_length: it.request_length ? Number(it.request_length) : null,
|
||||
unit_price: Number(pn(it.unit_price)) || 0,
|
||||
supply_amount: Number(pn(it.supply_amount)) || 0,
|
||||
vat_amount: Number(pn(it.vat_amount)) || 0,
|
||||
total_amount: Number(pn(it.total_amount)) || 0,
|
||||
notes: it.notes,
|
||||
})),
|
||||
};
|
||||
await apiClient.put(`/quotes/${selectedRow.objid}`, payload);
|
||||
} else {
|
||||
// 텍스트/카드 → 해당 필드만 업데이트
|
||||
const detailRes = await apiClient.get(`/quotes/${selectedRow.objid}`);
|
||||
const existing = detailRes.data?.data ?? {};
|
||||
const payload = { ...existing, ...editValues };
|
||||
// items는 기존 유지
|
||||
payload.items = (existing.items || []).map((it: any) => ({
|
||||
item_code: it.item_code, item_name: it.item_name, spec: it.spec,
|
||||
qty: Number(it.qty ?? 0), unit: it.unit,
|
||||
request_length: it.request_length || null,
|
||||
unit_price: Number(it.unit_price ?? 0),
|
||||
supply_amount: Number(it.supply_amount ?? 0),
|
||||
vat_amount: Number(it.vat_amount ?? 0),
|
||||
total_amount: Number(it.total_amount ?? 0),
|
||||
notes: it.notes,
|
||||
}));
|
||||
await apiClient.put(`/quotes/${selectedRow.objid}`, payload);
|
||||
}
|
||||
|
||||
toast.success("저장되었습니다.");
|
||||
setEditComp(null);
|
||||
|
||||
// 목록 + 리포트 갱신
|
||||
const listRes = await apiClient.get("/quotes/list");
|
||||
const mapped = (listRes.data?.data ?? []).map(mapRow);
|
||||
setQuotes(mapped);
|
||||
setTotalCount(mapped.length);
|
||||
const updated = mapped.find((r: any) => r.objid === selectedRow.objid);
|
||||
if (updated) setSelectedRow(updated);
|
||||
setReportKey((k) => k + 1);
|
||||
} catch { toast.error("저장 실패"); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
// ── 거래처 검색 ──
|
||||
|
||||
const searchCustomers = async () => {
|
||||
setCustSearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (custSearchKeyword) {
|
||||
filters.push({ columnName: "customer_name", operator: "contains", value: custSearchKeyword });
|
||||
}
|
||||
const res = await apiClient.post("/table-management/tables/customer_mng/data", {
|
||||
page: 1, size: 50,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
setCustSearchResults(resData?.data || resData?.rows || []);
|
||||
} catch { toast.error("거래처 조회 실패"); }
|
||||
finally { setCustSearchLoading(false); }
|
||||
};
|
||||
|
||||
const selectCustomer = async (cust: any) => {
|
||||
if (!selectedRow) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const detailRes = await apiClient.get(`/quotes/${selectedRow.objid}`);
|
||||
const existing = detailRes.data?.data ?? {};
|
||||
const payload = {
|
||||
...existing,
|
||||
customer_objid: cust.objid || null,
|
||||
customer_name: cust.customer_name || "",
|
||||
customer_ceo: cust.contact_person || "",
|
||||
customer_biz_no: cust.business_number || "",
|
||||
customer_address: cust.address || "",
|
||||
customer_contact: cust.contact_person || "",
|
||||
customer_phone: cust.contact_phone || "",
|
||||
items: (existing.items || []).map((it: any) => ({
|
||||
item_code: it.item_code, item_name: it.item_name, spec: it.spec,
|
||||
qty: Number(it.qty ?? 0), unit: it.unit,
|
||||
request_length: it.request_length || null,
|
||||
unit_price: Number(it.unit_price ?? 0),
|
||||
supply_amount: Number(it.supply_amount ?? 0),
|
||||
vat_amount: Number(it.vat_amount ?? 0),
|
||||
total_amount: Number(it.total_amount ?? 0), notes: it.notes,
|
||||
})),
|
||||
};
|
||||
await apiClient.put(`/quotes/${selectedRow.objid}`, payload);
|
||||
toast.success(`${payload.customer_name} 거래처 적용 완료`);
|
||||
setCustSearchOpen(false);
|
||||
setEditComp(null);
|
||||
|
||||
// 목록 + 리포트 갱신
|
||||
const listRes = await apiClient.get("/quotes/list");
|
||||
const mapped = (listRes.data?.data ?? []).map(mapRow);
|
||||
setQuotes(mapped);
|
||||
setTotalCount(mapped.length);
|
||||
const updated = mapped.find((r: any) => r.objid === selectedRow.objid);
|
||||
if (updated) setSelectedRow(updated);
|
||||
setReportKey((k) => k + 1);
|
||||
} catch { toast.error("저장 실패"); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
// ── 기본정보 저장 ──
|
||||
|
||||
const handleBasicInfoSave = async () => {
|
||||
if (!selectedRow) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const detailRes = await apiClient.get(`/quotes/${selectedRow.objid}`);
|
||||
const existing = detailRes.data?.data ?? {};
|
||||
const payload = {
|
||||
...existing,
|
||||
quote_date: basicForm.quote_date,
|
||||
valid_until: basicForm.valid_until,
|
||||
status: basicForm.status,
|
||||
items: (existing.items || []).map((it: any) => ({
|
||||
item_code: it.item_code, item_name: it.item_name, spec: it.spec,
|
||||
qty: Number(it.qty ?? 0), unit: it.unit,
|
||||
request_length: it.request_length || null,
|
||||
unit_price: Number(it.unit_price ?? 0),
|
||||
supply_amount: Number(it.supply_amount ?? 0),
|
||||
vat_amount: Number(it.vat_amount ?? 0),
|
||||
total_amount: Number(it.total_amount ?? 0), notes: it.notes,
|
||||
})),
|
||||
};
|
||||
await apiClient.put(`/quotes/${selectedRow.objid}`, payload);
|
||||
toast.success("기본정보 저장 완료");
|
||||
setBasicInfoOpen(false);
|
||||
|
||||
const listRes = await apiClient.get("/quotes/list");
|
||||
const mapped = (listRes.data?.data ?? []).map(mapRow);
|
||||
setQuotes(mapped);
|
||||
setTotalCount(mapped.length);
|
||||
const updated = mapped.find((r: any) => r.objid === selectedRow.objid);
|
||||
if (updated) setSelectedRow(updated);
|
||||
setReportKey((k) => k + 1);
|
||||
} catch { toast.error("저장 실패"); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
// ── 사원(담당자) 검색 ──
|
||||
|
||||
const searchUsers = async () => {
|
||||
setUserSearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (userSearchKeyword) {
|
||||
filters.push({ columnName: "user_name", operator: "contains", value: userSearchKeyword });
|
||||
}
|
||||
const res = await apiClient.post("/table-management/tables/user_info/data", {
|
||||
page: 1, size: 50,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
setUserSearchResults(resData?.data || resData?.rows || []);
|
||||
} catch { toast.error("사원 조회 실패"); }
|
||||
finally { setUserSearchLoading(false); }
|
||||
};
|
||||
|
||||
const selectUser = async (usr: any) => {
|
||||
if (!selectedRow) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const detailRes = await apiClient.get(`/quotes/${selectedRow.objid}`);
|
||||
const existing = detailRes.data?.data ?? {};
|
||||
const payload = {
|
||||
...existing,
|
||||
manager: usr.user_name || "",
|
||||
items: (existing.items || []).map((it: any) => ({
|
||||
item_code: it.item_code, item_name: it.item_name, spec: it.spec,
|
||||
qty: Number(it.qty ?? 0), unit: it.unit,
|
||||
request_length: it.request_length || null,
|
||||
unit_price: Number(it.unit_price ?? 0),
|
||||
supply_amount: Number(it.supply_amount ?? 0),
|
||||
vat_amount: Number(it.vat_amount ?? 0),
|
||||
total_amount: Number(it.total_amount ?? 0), notes: it.notes,
|
||||
})),
|
||||
};
|
||||
await apiClient.put(`/quotes/${selectedRow.objid}`, payload);
|
||||
toast.success(`담당자: ${payload.manager} 적용`);
|
||||
setUserSearchOpen(false);
|
||||
setEditComp(null);
|
||||
|
||||
const listRes = await apiClient.get("/quotes/list");
|
||||
const mapped = (listRes.data?.data ?? []).map(mapRow);
|
||||
setQuotes(mapped);
|
||||
setTotalCount(mapped.length);
|
||||
const updated = mapped.find((r: any) => r.objid === selectedRow.objid);
|
||||
if (updated) setSelectedRow(updated);
|
||||
setReportKey((k) => k + 1);
|
||||
} catch { toast.error("저장 실패"); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
// ── 품목 검색 ──
|
||||
|
||||
const searchItemInfo = async () => {
|
||||
setItemSearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (itemSearchKeyword) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
}
|
||||
const res = await apiClient.post("/table-management/tables/item_info/data", {
|
||||
page: 1, size: 50,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
setItemSearchResults(resData?.data || resData?.rows || []);
|
||||
} catch { toast.error("품목 조회 실패"); }
|
||||
finally { setItemSearchLoading(false); }
|
||||
};
|
||||
|
||||
const toggleItemSelect = (row: any) => {
|
||||
const key = row.item_number || row.objid || row.id;
|
||||
setItemSelectedMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(key)) next.delete(key); else next.set(key, row);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const addSelectedItemsToQuote = () => {
|
||||
const selected = Array.from(itemSelectedMap.values());
|
||||
if (selected.length === 0) { toast.info("품목을 선택하세요."); return; }
|
||||
const newItems = selected.map((item) => calcItem({
|
||||
item_code: item.item_number || item.item_code || "",
|
||||
item_name: item.item_name || "",
|
||||
spec: item.spec || item.standard || "",
|
||||
qty: "1",
|
||||
unit: item.unit || "EA",
|
||||
request_length: "",
|
||||
unit_price: String(item.selling_price || item.standard_price || 0),
|
||||
supply_amount: "0",
|
||||
vat_amount: "0",
|
||||
total_amount: "0",
|
||||
notes: "",
|
||||
}));
|
||||
setItems((prev) => [...prev, ...newItems]);
|
||||
setItemSearchOpen(false);
|
||||
setItemSelectedMap(new Map());
|
||||
setItemSearchKeyword("");
|
||||
toast.success(`${selected.length}건 품목 추가`);
|
||||
};
|
||||
|
||||
// ── 품목 계산 ──
|
||||
|
||||
const calcItem = (item: typeof EMPTY_ITEM) => {
|
||||
const qty = Number(pn(item.qty)) || 0;
|
||||
const price = Number(pn(item.unit_price)) || 0;
|
||||
const supply = qty * price;
|
||||
const vat = Math.round(supply * 0.1);
|
||||
return { ...item, supply_amount: String(supply), vat_amount: String(vat), total_amount: String(supply + vat) };
|
||||
};
|
||||
const updateItem = (idx: number, field: string, value: string) => {
|
||||
setItems((prev) => {
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], [field]: value };
|
||||
if (field === "qty" || field === "unit_price") next[idx] = calcItem(next[idx]);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const addItem = () => setItems((prev) => [...prev, { ...EMPTY_ITEM }]);
|
||||
const removeItem = (idx: number) => setItems((prev) => prev.filter((_, i) => i !== idx));
|
||||
const totalSupply = items.reduce((s, it) => s + (Number(pn(it.supply_amount)) || 0), 0);
|
||||
const totalVat = items.reduce((s, it) => s + (Number(pn(it.vat_amount)) || 0), 0);
|
||||
const totalAmount = totalSupply + totalVat;
|
||||
|
||||
// ── 행 클릭 ──
|
||||
|
||||
const handleRowClick = (row: any) => setSelectedRow(row);
|
||||
|
||||
const contextParams = selectedRow
|
||||
? { "$1": selectedRow.objid, "$2": user?.companyCode || "COMPANY_7", objid: selectedRow.objid, quote_no: selectedRow.quote_no, customer_name: selectedRow.customer_name, quote_date: selectedRow.quote_date }
|
||||
: undefined;
|
||||
|
||||
// ── 편집 모달 제목/타입 판별 ──
|
||||
|
||||
const getModalTitle = () => {
|
||||
if (!editComp) return "";
|
||||
if (editComp.type === "table") return "견적 품목";
|
||||
if (editComp.type === "card") {
|
||||
const items = (editComp as any).cardItems ?? [];
|
||||
const title = (editComp as any).headerText || (editComp as any).title || "";
|
||||
if (title) return title;
|
||||
if (items.length > 0) return items[0].label ?? "카드 정보";
|
||||
return "카드 정보";
|
||||
}
|
||||
if (editComp.type === "text" || editComp.type === "label") {
|
||||
if (editComp.fieldName) return editComp.fieldName;
|
||||
return "텍스트 편집";
|
||||
}
|
||||
return editComp.type;
|
||||
};
|
||||
|
||||
// ── JSX ──
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
<DynamicSearchFilter tableName={MASTER_TABLE} filterId="quote-mng-filter"
|
||||
onFilterChange={(filters) => setSearchFilters(filters)} dataCount={totalCount} />
|
||||
|
||||
<ResizablePanelGroup direction="horizontal" className="flex-1 overflow-hidden rounded-lg border">
|
||||
{/* 좌측: 견적 목록 */}
|
||||
<ResizablePanel defaultSize={55} minSize={35}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b bg-white px-3 py-2">
|
||||
<span className="text-sm font-semibold text-gray-700">
|
||||
견적 목록 <span className="text-gray-400">{totalCount}건</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button variant="outline" size="sm" className="gap-1 text-xs" onClick={() => setExcelOpen(true)}>
|
||||
<FileSpreadsheet className="h-3.5 w-3.5" /> 업로드
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="gap-1 text-xs"
|
||||
onClick={() => quotes.length > 0 ? exportToExcel(quotes, "견적목록.xlsx", "견적목록") : toast.info("데이터 없음")}>
|
||||
<Download className="h-3.5 w-3.5" /> 다운로드
|
||||
</Button>
|
||||
<Button size="sm" className="gap-1 text-xs" onClick={handleCreate}>
|
||||
<Plus className="h-3.5 w-3.5" /> 신규
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" className="gap-1 text-xs" onClick={handleDelete}>
|
||||
<Trash2 className="h-3.5 w-3.5" /> 삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<DataGrid gridId="quote-mng" columns={GRID_COLUMNS} data={quotes} showCheckbox tableName={MASTER_TABLE}
|
||||
checkedIds={checkedIds} onCheckedChange={setCheckedIds}
|
||||
onRowClick={handleRowClick} selectedId={selectedRow?.objid ? String(selectedRow.objid) : null} />
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 우측: 리포트 뷰어 — 컴포넌트 클릭 가능 */}
|
||||
<ResizablePanel defaultSize={45} minSize={25}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 border-b bg-white px-3 py-2">
|
||||
<FileText className="h-4 w-4 text-gray-500" />
|
||||
<span className="text-sm font-semibold text-gray-700">견적서</span>
|
||||
<Select value={selectedReportId ?? ""} onValueChange={(v) => setSelectedReportId(v || null)}>
|
||||
<SelectTrigger className="ml-auto h-7 w-[180px] text-xs">
|
||||
<SelectValue placeholder="리포트 양식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{reportList.map((r) => (
|
||||
<SelectItem key={r.report_id} value={r.report_id} className="text-xs">
|
||||
{r.report_name_kor || r.report_name_eng || r.report_id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{!selectedRow ? (
|
||||
<div className="flex h-full items-center justify-center text-center text-gray-400">
|
||||
<div>
|
||||
<FileText className="mx-auto mb-3 h-12 w-12 opacity-30" />
|
||||
<p className="text-sm font-medium">견적을 선택해주세요</p>
|
||||
<p className="mt-1 text-xs text-gray-300">"신규" 버튼으로 생성하거나 좌측에서 선택하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : !selectedReportId ? (
|
||||
<div className="flex h-full items-center justify-center text-center text-gray-400">
|
||||
<div>
|
||||
<FileText className="mx-auto mb-3 h-12 w-12 opacity-30" />
|
||||
<p className="text-sm font-medium">리포트 양식을 선택해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ReportInlineViewer
|
||||
key={reportKey}
|
||||
reportId={selectedReportId}
|
||||
contextParams={contextParams}
|
||||
onComponentClick={handleComponentClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
{/* ═══ 컴포넌트 클릭 편집 모달 (동적) ═══ */}
|
||||
|
||||
{/* 텍스트/카드 편집 모달 */}
|
||||
<Dialog open={!!editComp && editComp.type !== "table"} onOpenChange={(o) => !o && setEditComp(null)}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{getModalTitle()}</DialogTitle>
|
||||
<DialogDescription>값을 수정한 후 저장 버튼을 누르세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
{Object.entries(editValues).map(([key, val]) => (
|
||||
<div key={key}>
|
||||
<Label className="text-xs">{key}</Label>
|
||||
{val.length > 60 ? (
|
||||
<Textarea className="mt-1" rows={3} value={val}
|
||||
onChange={(e) => setEditValues((p) => ({ ...p, [key]: e.target.value }))} />
|
||||
) : (
|
||||
<Input className="mt-1" value={val}
|
||||
onChange={(e) => setEditValues((p) => ({ ...p, [key]: e.target.value }))} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(editValues).length === 0 && (
|
||||
<p className="text-sm text-gray-400">이 컴포넌트에 편집 가능한 필드가 없습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditComp(null)}>취소</Button>
|
||||
<Button onClick={handleEditSave} disabled={saving || Object.keys(editValues).length === 0} className="gap-1.5">
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />} 저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 테이블(품목) 편집 모달 */}
|
||||
<Dialog open={!!editComp && editComp.type === "table"} onOpenChange={(o) => { if (!o && itemSearchOpen) return; if (!o) setEditComp(null); }}>
|
||||
<DialogContent className="flex max-h-[80vh] max-w-4xl flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>견적 품목</DialogTitle>
|
||||
<DialogDescription>품목을 추가/수정합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="mb-3 flex gap-2">
|
||||
<Button size="sm" className="gap-1 text-xs" onClick={() => { setItemSelectedMap(new Map()); setItemSearchResults([]); setItemSearchKeyword(""); setItemSearchOpen(true); searchItemInfo(); }}>
|
||||
<Plus className="h-3.5 w-3.5" /> 품목 검색/추가
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="gap-1 text-xs" onClick={addItem}>
|
||||
<Plus className="h-3.5 w-3.5" /> 빈 행 추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-auto rounded border">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-gray-50">
|
||||
<tr>
|
||||
<th className="w-8 px-2 py-2 text-center">#</th>
|
||||
<th className="px-2 py-2 text-left">품목코드</th>
|
||||
<th className="px-2 py-2 text-left">품목명</th>
|
||||
<th className="px-2 py-2 text-left">규격</th>
|
||||
<th className="w-20 px-2 py-2 text-right">수량</th>
|
||||
<th className="w-16 px-2 py-2 text-center">단위</th>
|
||||
<th className="w-24 px-2 py-2 text-right">단가</th>
|
||||
<th className="w-24 px-2 py-2 text-right">공급가액</th>
|
||||
<th className="w-20 px-2 py-2 text-right">부가세</th>
|
||||
<th className="w-24 px-2 py-2 text-right">합계</th>
|
||||
<th className="w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, idx) => (
|
||||
<tr key={idx} className="border-t">
|
||||
<td className="px-2 py-1 text-center text-gray-400">{idx + 1}</td>
|
||||
<td className="px-1 py-1"><Input className="h-7 text-xs" value={item.item_code} onChange={(e) => updateItem(idx, "item_code", e.target.value)} /></td>
|
||||
<td className="px-1 py-1"><Input className="h-7 text-xs" value={item.item_name} onChange={(e) => updateItem(idx, "item_name", e.target.value)} /></td>
|
||||
<td className="px-1 py-1"><Input className="h-7 text-xs" value={item.spec} onChange={(e) => updateItem(idx, "spec", e.target.value)} /></td>
|
||||
<td className="px-1 py-1"><Input className="h-7 text-right text-xs" value={fmt(item.qty)} onChange={(e) => updateItem(idx, "qty", pn(e.target.value))} /></td>
|
||||
<td className="px-1 py-1"><Input className="h-7 text-center text-xs" value={item.unit} onChange={(e) => updateItem(idx, "unit", e.target.value)} /></td>
|
||||
<td className="px-1 py-1"><Input className="h-7 text-right text-xs" value={fmt(item.unit_price)} onChange={(e) => updateItem(idx, "unit_price", pn(e.target.value))} /></td>
|
||||
<td className="px-2 py-1 text-right">{fmt(item.supply_amount)}</td>
|
||||
<td className="px-2 py-1 text-right">{fmt(item.vat_amount)}</td>
|
||||
<td className="px-2 py-1 text-right font-medium">{fmt(item.total_amount)}</td>
|
||||
<td className="px-1 py-1">
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => removeItem(idx)}>
|
||||
<Trash2 className="h-3 w-3 text-red-400" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-3 rounded bg-gray-50 p-3 text-sm">
|
||||
<div className="flex justify-between"><span>공급가액</span><span>{fmt(String(totalSupply))}원</span></div>
|
||||
<div className="flex justify-between"><span>부가세</span><span>{fmt(String(totalVat))}원</span></div>
|
||||
<div className="flex justify-between border-t pt-2 font-bold"><span>합계금액</span><span>{fmt(String(totalAmount))}원</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditComp(null)}>취소</Button>
|
||||
<Button onClick={handleEditSave} disabled={saving} className="gap-1.5">
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />} 저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══ 기본정보 모달 ═══ */}
|
||||
<Dialog open={basicInfoOpen} onOpenChange={setBasicInfoOpen}>
|
||||
<DialogContent className="max-w-md" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>기본 정보</DialogTitle>
|
||||
<DialogDescription>견적 기본 정보를 수정합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs">견적번호</Label>
|
||||
<Input className="mt-1" value={selectedRow?.quote_no || ""} disabled />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">견적일자 *</Label>
|
||||
<div className="mt-1">
|
||||
<FormDatePicker value={basicForm.quote_date} onChange={(v) => setBasicForm((p) => ({ ...p, quote_date: v }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">유효기한 *</Label>
|
||||
<div className="mt-1">
|
||||
<FormDatePicker value={basicForm.valid_until} onChange={(v) => setBasicForm((p) => ({ ...p, valid_until: v }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">상태</Label>
|
||||
<Select value={basicForm.status} onValueChange={(v) => setBasicForm((p) => ({ ...p, status: v }))}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(STATUS_MAP).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setBasicInfoOpen(false)}>취소</Button>
|
||||
<Button onClick={handleBasicInfoSave} disabled={saving} className="gap-1.5">
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />} 저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══ 담당자(사원) 검색 모달 ═══ */}
|
||||
<Dialog open={userSearchOpen} onOpenChange={setUserSearchOpen}>
|
||||
<DialogContent className="flex max-h-[70vh] max-w-lg flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>담당자 선택</DialogTitle>
|
||||
<DialogDescription>담당자를 검색하여 선택하세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder="이름 검색" value={userSearchKeyword}
|
||||
onChange={(e) => setUserSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && searchUsers()} className="text-sm" />
|
||||
<Button onClick={searchUsers} disabled={userSearchLoading} className="shrink-0">
|
||||
{userSearchLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : "검색"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto rounded border">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">이름</th>
|
||||
<th className="px-3 py-2 text-left">부서</th>
|
||||
<th className="px-3 py-2 text-left">직위</th>
|
||||
<th className="w-16 px-2 py-2 text-center">선택</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{userSearchResults.length === 0 ? (
|
||||
<tr><td colSpan={4} className="px-4 py-8 text-center text-gray-400">
|
||||
{userSearchLoading ? "검색 중..." : "검색 결과가 없습니다."}
|
||||
</td></tr>
|
||||
) : userSearchResults.map((row, i) => (
|
||||
<tr key={row.objid || row.user_id || i} className="cursor-pointer border-t hover:bg-blue-50"
|
||||
onClick={() => selectUser(row)}>
|
||||
<td className="px-3 py-2 font-medium">{row.user_name || "-"}</td>
|
||||
<td className="px-3 py-2">{row.department || row.dept_name || "-"}</td>
|
||||
<td className="px-3 py-2">{row.position || row.rank || "-"}</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
<Button size="sm" variant="outline" className="h-6 text-xs"
|
||||
onClick={(e) => { e.stopPropagation(); selectUser(row); }}>선택</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setUserSearchOpen(false)}>취소</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══ 거래처 검색 모달 ═══ */}
|
||||
<Dialog open={custSearchOpen} onOpenChange={setCustSearchOpen}>
|
||||
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>거래처 검색</DialogTitle>
|
||||
<DialogDescription>거래처를 검색하여 선택하세요. 선택하면 자동으로 적용됩니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder="거래처명 검색" value={custSearchKeyword}
|
||||
onChange={(e) => setCustSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && searchCustomers()} className="text-sm" />
|
||||
<Button onClick={searchCustomers} disabled={custSearchLoading} className="shrink-0 gap-1">
|
||||
{custSearchLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : "검색"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto rounded border">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">거래처명</th>
|
||||
<th className="px-3 py-2 text-left">대표자</th>
|
||||
<th className="px-3 py-2 text-left">사업자번호</th>
|
||||
<th className="px-3 py-2 text-left">연락처</th>
|
||||
<th className="w-16 px-2 py-2 text-center">선택</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{custSearchResults.length === 0 ? (
|
||||
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-400">
|
||||
{custSearchLoading ? "검색 중..." : "검색 결과가 없습니다."}
|
||||
</td></tr>
|
||||
) : custSearchResults.map((row, i) => (
|
||||
<tr key={row.objid || i} className="cursor-pointer border-t hover:bg-blue-50"
|
||||
onClick={() => selectCustomer(row)}>
|
||||
<td className="px-3 py-2 font-medium">{row.customer_name || "-"}</td>
|
||||
<td className="px-3 py-2">{row.contact_person || "-"}</td>
|
||||
<td className="px-3 py-2">{row.business_number || "-"}</td>
|
||||
<td className="px-3 py-2">{row.contact_phone || "-"}</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
<Button size="sm" variant="outline" className="h-6 text-xs"
|
||||
onClick={(e) => { e.stopPropagation(); selectCustomer(row); }}>선택</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCustSearchOpen(false)}>취소</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══ 품목 검색 모달 ═══ */}
|
||||
<Dialog open={itemSearchOpen} onOpenChange={setItemSearchOpen}>
|
||||
<DialogContent className="flex max-h-[80vh] max-w-3xl flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>품목 검색</DialogTitle>
|
||||
<DialogDescription>추가할 품목을 검색하여 선택하세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder="품명/품목코드 검색" value={itemSearchKeyword}
|
||||
onChange={(e) => setItemSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && searchItemInfo()} className="text-sm" />
|
||||
<Button onClick={searchItemInfo} disabled={itemSearchLoading} className="shrink-0 gap-1">
|
||||
{itemSearchLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : "검색"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto rounded border">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-gray-50">
|
||||
<tr>
|
||||
<th className="w-10 px-2 py-2 text-center">선택</th>
|
||||
<th className="px-2 py-2 text-left">품목코드</th>
|
||||
<th className="px-2 py-2 text-left">품명</th>
|
||||
<th className="px-2 py-2 text-left">규격</th>
|
||||
<th className="w-16 px-2 py-2 text-center">단위</th>
|
||||
<th className="w-24 px-2 py-2 text-right">단가</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{itemSearchResults.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-4 py-8 text-center text-gray-400">
|
||||
{itemSearchLoading ? "검색 중..." : "검색 결과가 없습니다."}
|
||||
</td></tr>
|
||||
) : itemSearchResults.map((row, i) => {
|
||||
const key = row.item_number || row.objid || row.id || i;
|
||||
const checked = itemSelectedMap.has(key);
|
||||
return (
|
||||
<tr key={key} className={`cursor-pointer border-t ${checked ? "bg-blue-50" : "hover:bg-gray-50"}`}
|
||||
onClick={() => toggleItemSelect(row)}>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<input type="checkbox" checked={checked} readOnly className="accent-blue-500" />
|
||||
</td>
|
||||
<td className="px-2 py-1.5">{row.item_number || row.item_code || "-"}</td>
|
||||
<td className="px-2 py-1.5">{row.item_name || "-"}</td>
|
||||
<td className="px-2 py-1.5">{row.spec || row.standard || "-"}</td>
|
||||
<td className="px-2 py-1.5 text-center">{row.unit || "EA"}</td>
|
||||
<td className="px-2 py-1.5 text-right">{fmt(String(row.selling_price || row.standard_price || 0))}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DialogFooter className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">선택: {itemSelectedMap.size}건</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setItemSearchOpen(false)}>취소</Button>
|
||||
<Button onClick={addSelectedItemsToQuote} disabled={itemSelectedMap.size === 0} className="gap-1">
|
||||
<Plus className="h-4 w-4" /> 선택 품목 추가
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ExcelUploadModal open={excelOpen} onOpenChange={setExcelOpen} tableName={MASTER_TABLE} />
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||
import React, { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Truck, Search, Loader2, FileSpreadsheet } from "lucide-react";
|
||||
import { Plus, Trash2, Save, X, ChevronDown, ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Search, Loader2, FileSpreadsheet, Inbox, Settings2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||
import {
|
||||
getShippingOrderList,
|
||||
saveShippingOrder,
|
||||
@@ -25,7 +22,25 @@ import {
|
||||
getItemSource,
|
||||
} from "@/lib/api/shipping";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "instruction_no", label: "출하지시번호" },
|
||||
{ key: "ship_date", label: "출하일자" },
|
||||
{ key: "customer_name", label: "거래처명" },
|
||||
{ key: "transport_company", label: "운송업체" },
|
||||
{ key: "vehicle_no", label: "차량번호" },
|
||||
{ key: "driver_name", label: "기사명" },
|
||||
{ key: "status", label: "상태" },
|
||||
{ key: "item_code", label: "품번" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "qty", label: "수량" },
|
||||
{ key: "source_type", label: "소스" },
|
||||
{ key: "remark", label: "비고" },
|
||||
];
|
||||
|
||||
type DataSourceType = "shipmentPlan" | "salesOrder" | "itemInfo";
|
||||
|
||||
@@ -40,19 +55,19 @@ const getStatusLabel = (s: string) => STATUS_OPTIONS.find(o => o.value === s)?.l
|
||||
|
||||
const getStatusColor = (s: string) => {
|
||||
switch (s) {
|
||||
case "READY": return "bg-amber-100 text-amber-800 border-amber-200";
|
||||
case "IN_PROGRESS": return "bg-blue-100 text-blue-800 border-blue-200";
|
||||
case "COMPLETED": return "bg-emerald-100 text-emerald-800 border-emerald-200";
|
||||
default: return "bg-gray-100 text-gray-800 border-gray-200";
|
||||
case "READY": return "bg-warning/10 text-warning";
|
||||
case "IN_PROGRESS": return "bg-primary/10 text-primary";
|
||||
case "COMPLETED": return "bg-success/10 text-success";
|
||||
default: return "bg-muted text-muted-foreground";
|
||||
}
|
||||
};
|
||||
|
||||
const getSourceBadge = (s: string) => {
|
||||
switch (s) {
|
||||
case "shipmentPlan": return { label: "출하계획", cls: "bg-blue-100 text-blue-700" };
|
||||
case "salesOrder": return { label: "수주", cls: "bg-emerald-100 text-emerald-700" };
|
||||
case "itemInfo": return { label: "품목", cls: "bg-purple-100 text-purple-700" };
|
||||
default: return { label: s, cls: "bg-gray-100 text-gray-700" };
|
||||
case "shipmentPlan": return { label: "출하계획", cls: "bg-primary/10 text-primary" };
|
||||
case "salesOrder": return { label: "수주", cls: "bg-success/10 text-success" };
|
||||
case "itemInfo": return { label: "품목", cls: "bg-secondary text-secondary-foreground" };
|
||||
default: return { label: s, cls: "bg-muted text-muted-foreground" };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,18 +88,14 @@ interface SelectedItem {
|
||||
}
|
||||
|
||||
export default function ShippingOrderPage() {
|
||||
const ts = useTableSettings("c16-shipping-order", "shipment_instruction", GRID_COLUMNS);
|
||||
const [orders, setOrders] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checkedIds, setCheckedIds] = useState<number[]>([]);
|
||||
const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null);
|
||||
|
||||
// 검색
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
const [searchCustomer, setSearchCustomer] = useState("");
|
||||
const [debouncedKeyword, setDebouncedKeyword] = useState("");
|
||||
const [debouncedCustomer, setDebouncedCustomer] = useState("");
|
||||
const [searchStatus, setSearchStatus] = useState("all");
|
||||
const [searchDateFrom, setSearchDateFrom] = useState("");
|
||||
const [searchDateTo, setSearchDateTo] = useState("");
|
||||
// 검색 필터 (DynamicSearchFilter에서 관리)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 엑셀 업로드
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
@@ -120,36 +131,25 @@ export default function ShippingOrderPage() {
|
||||
const [sourcePageSize, setSourcePageSize] = useState(20);
|
||||
const [sourceTotalCount, setSourceTotalCount] = useState(0);
|
||||
|
||||
// 텍스트 입력 debounce (500ms)
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedKeyword(searchKeyword), 500);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchKeyword]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedCustomer(searchCustomer), 500);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchCustomer]);
|
||||
|
||||
// 초기 날짜
|
||||
useEffect(() => {
|
||||
const today = new Date();
|
||||
const from = new Date(today);
|
||||
from.setMonth(from.getMonth() - 1);
|
||||
setSearchDateFrom(from.toISOString().split("T")[0]);
|
||||
setSearchDateTo(today.toISOString().split("T")[0]);
|
||||
}, []);
|
||||
|
||||
// 데이터 조회
|
||||
const fetchOrders = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
if (searchDateFrom) params.dateFrom = searchDateFrom;
|
||||
if (searchDateTo) params.dateTo = searchDateTo;
|
||||
if (searchStatus !== "all") params.status = searchStatus;
|
||||
if (debouncedCustomer.trim()) params.customer = debouncedCustomer.trim();
|
||||
if (debouncedKeyword.trim()) params.keyword = debouncedKeyword.trim();
|
||||
for (const f of searchFilters) {
|
||||
if (f.columnName === "ship_date" && f.operator === "between" && f.value) {
|
||||
const [from, to] = f.value.split(",");
|
||||
if (from) params.dateFrom = from;
|
||||
if (to) params.dateTo = to;
|
||||
} else if (f.columnName === "status") {
|
||||
params.status = f.value;
|
||||
} else if (f.columnName === "customer_name") {
|
||||
params.customer = f.value;
|
||||
} else {
|
||||
params.keyword = f.value;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await getShippingOrderList(params);
|
||||
if (result.success) setOrders(result.data || []);
|
||||
@@ -158,10 +158,10 @@ export default function ShippingOrderPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchDateFrom, searchDateTo, searchStatus, debouncedCustomer, debouncedKeyword]);
|
||||
}, [searchFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchDateFrom && searchDateTo) fetchOrders();
|
||||
fetchOrders();
|
||||
}, [fetchOrders]);
|
||||
|
||||
// 소스 데이터 조회
|
||||
@@ -202,24 +202,6 @@ export default function ShippingOrderPage() {
|
||||
}
|
||||
}, [isModalOpen, dataSource]);
|
||||
|
||||
// 핸들러
|
||||
const handleResetSearch = () => {
|
||||
setSearchKeyword("");
|
||||
setSearchCustomer("");
|
||||
setDebouncedKeyword("");
|
||||
setDebouncedCustomer("");
|
||||
setSearchStatus("all");
|
||||
const today = new Date();
|
||||
const from = new Date(today);
|
||||
from.setMonth(from.getMonth() - 1);
|
||||
setSearchDateFrom(from.toISOString().split("T")[0]);
|
||||
setSearchDateTo(today.toISOString().split("T")[0]);
|
||||
};
|
||||
|
||||
const handleCheckAll = (checked: boolean) => {
|
||||
setCheckedIds(checked ? orders.map((o: any) => o.id) : []);
|
||||
};
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
if (checkedIds.length === 0) return;
|
||||
if (!confirm(`선택한 ${checkedIds.length}개의 출하지시를 삭제하시겠습니까?`)) return;
|
||||
@@ -228,7 +210,7 @@ export default function ShippingOrderPage() {
|
||||
if (result.success) {
|
||||
setCheckedIds([]);
|
||||
fetchOrders();
|
||||
alert("삭제되었습니다.");
|
||||
alert("삭제되었어요.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert(err.message || "삭제 실패");
|
||||
@@ -256,7 +238,6 @@ export default function ShippingOrderPage() {
|
||||
const items = order.items || [];
|
||||
setSelectedItems(items.filter((it: any) => it.id).map((it: any) => {
|
||||
const srcType = it.source_type || "shipmentPlan";
|
||||
// 소스 데이터와 매칭할 수 있도록 원래 소스 id를 사용
|
||||
let sourceId: string | number = it.id;
|
||||
if (srcType === "shipmentPlan" && it.shipment_plan_id) sourceId = it.shipment_plan_id;
|
||||
else if (srcType === "salesOrder" && it.detail_id) sourceId = it.detail_id;
|
||||
@@ -313,12 +294,10 @@ export default function ShippingOrderPage() {
|
||||
: item.item_code;
|
||||
|
||||
const exists = selectedItems.findIndex(s => {
|
||||
// 같은 소스 타입에서 id 매칭
|
||||
if (s.sourceType === dataSource) {
|
||||
if (dataSource === "itemInfo") return s.itemCode === key;
|
||||
return String(s.id) === String(key);
|
||||
}
|
||||
// 다른 소스 타입이라도 원래 소스 id로 매칭
|
||||
if (dataSource === "shipmentPlan" && s.shipmentPlanId) return String(s.shipmentPlanId) === String(item.id);
|
||||
if (dataSource === "salesOrder" && s.detailId) return String(s.detailId) === String(item.id);
|
||||
return false;
|
||||
@@ -397,7 +376,7 @@ export default function ShippingOrderPage() {
|
||||
if (result.success) {
|
||||
setIsModalOpen(false);
|
||||
fetchOrders();
|
||||
alert(isEditMode ? "출하지시가 수정되었습니다." : "출하지시가 등록되었습니다.");
|
||||
alert(isEditMode ? "출하지시가 수정되었어요." : "출하지시가 등록되었어요.");
|
||||
} else {
|
||||
alert(result.message || "저장 실패");
|
||||
}
|
||||
@@ -410,6 +389,70 @@ export default function ShippingOrderPage() {
|
||||
|
||||
const formatDate = (d: string) => d ? d.split("T")[0] : "-";
|
||||
|
||||
// 출하지시 데이터를 플랫한 행 목록으로 변환 (EDataTable용)
|
||||
const flattenedOrders = useMemo(() => {
|
||||
const rows: any[] = [];
|
||||
for (const order of orders) {
|
||||
const items = Array.isArray(order.items) ? order.items.filter((it: any) => it.id) : [];
|
||||
if (items.length === 0) {
|
||||
rows.push({
|
||||
_rowId: String(order.id),
|
||||
_orderId: order.id,
|
||||
_order: order,
|
||||
instruction_no: order.instruction_no,
|
||||
ship_date: formatDate(order.instruction_date),
|
||||
customer_name: order.customer_name || "-",
|
||||
transport_company: order.carrier_name || "-",
|
||||
vehicle_no: order.vehicle_no || "-",
|
||||
driver_name: order.driver_name || "-",
|
||||
status: order.status,
|
||||
item_code: "-",
|
||||
item_name: "-",
|
||||
qty: 0,
|
||||
source_type: "-",
|
||||
remark: order.memo || "-",
|
||||
});
|
||||
} else {
|
||||
items.forEach((item: any, idx: number) => {
|
||||
rows.push({
|
||||
_rowId: `${order.id}-${item.id}`,
|
||||
_orderId: order.id,
|
||||
_order: order,
|
||||
instruction_no: idx === 0 ? order.instruction_no : "",
|
||||
ship_date: idx === 0 ? formatDate(order.instruction_date) : "",
|
||||
customer_name: idx === 0 ? (order.customer_name || "-") : "",
|
||||
transport_company: idx === 0 ? (order.carrier_name || "-") : "",
|
||||
vehicle_no: idx === 0 ? (order.vehicle_no || "-") : "",
|
||||
driver_name: idx === 0 ? (order.driver_name || "-") : "",
|
||||
status: idx === 0 ? order.status : "",
|
||||
item_code: item.item_code || "",
|
||||
item_name: item.item_name || "",
|
||||
qty: Number(item.order_qty || 0),
|
||||
source_type: item.source_type || "",
|
||||
remark: idx === 0 ? (order.memo || "-") : "",
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}, [orders]);
|
||||
|
||||
// checkedIds를 order.id 기준으로 관리하므로 _orderId로 매핑
|
||||
const flatCheckedRowIds = useMemo(() => {
|
||||
return flattenedOrders
|
||||
.filter((r) => checkedIds.includes(r._orderId))
|
||||
.map((r) => r._rowId);
|
||||
}, [flattenedOrders, checkedIds]);
|
||||
|
||||
const handleFlatCheckedChange = useCallback((rowIds: string[]) => {
|
||||
const orderIds = new Set<number>();
|
||||
for (const rowId of rowIds) {
|
||||
const row = flattenedOrders.find((r) => r._rowId === rowId);
|
||||
if (row) orderIds.add(row._orderId);
|
||||
}
|
||||
setCheckedIds(Array.from(orderIds));
|
||||
}, [flattenedOrders]);
|
||||
|
||||
const dataSourceTitle: Record<DataSourceType, string> = {
|
||||
shipmentPlan: "출하계획 목록",
|
||||
salesOrder: "수주정보 목록",
|
||||
@@ -417,272 +460,213 @@ export default function ShippingOrderPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-4rem)] bg-muted/30 p-4 gap-4">
|
||||
{/* 검색 */}
|
||||
<Card className="shrink-0">
|
||||
<CardContent className="p-4 flex flex-wrap items-end gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">출하지시번호</Label>
|
||||
<Input placeholder="검색" className="w-[160px] h-9" value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">거래처</Label>
|
||||
<Input placeholder="거래처 검색" className="w-[140px] h-9" value={searchCustomer}
|
||||
onChange={(e) => setSearchCustomer(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">상태</Label>
|
||||
<Select value={searchStatus} onValueChange={setSearchStatus}>
|
||||
<SelectTrigger className="w-[110px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">출하일자</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-[160px]">
|
||||
<FormDatePicker value={searchDateFrom} onChange={setSearchDateFrom} placeholder="시작일" />
|
||||
</div>
|
||||
<span className="text-muted-foreground">~</span>
|
||||
<div className="w-[160px]">
|
||||
<FormDatePicker value={searchDateTo} onChange={setSearchDateTo} placeholder="종료일" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-2">
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={handleResetSearch}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" /> 초기화
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex flex-col h-full p-4 gap-3">
|
||||
{/* 브레드크럼 */}
|
||||
<nav className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
|
||||
<span>영업관리</span>
|
||||
<span className="text-muted-foreground/40">/</span>
|
||||
<span className="font-semibold text-foreground">출하지시</span>
|
||||
</nav>
|
||||
|
||||
{/* 메인 테이블 */}
|
||||
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm flex flex-col">
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
<Truck className="w-5 h-5" /> 출하지시 관리
|
||||
<Badge variant="secondary" className="font-normal">{orders.length}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-4 h-4 mr-1.5" /> 엑셀 업로드
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => openModal()}>
|
||||
<Plus className="w-4 h-4 mr-1.5" /> 출하지시 등록
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" disabled={checkedIds.length === 0} onClick={handleDeleteSelected}>
|
||||
<Trash2 className="w-4 h-4 mr-1.5" /> 선택삭제 {checkedIds.length > 0 && `(${checkedIds.length})`}
|
||||
</Button>
|
||||
</div>
|
||||
{/* 검색 필터 (DynamicSearchFilter) */}
|
||||
<DynamicSearchFilter
|
||||
tableName={ts.tableName}
|
||||
filterId="c16-shipping-order"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={orders.length}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
/>
|
||||
|
||||
{/* 액션 바 */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-[15px] font-bold text-foreground">출하지시 관리</h2>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
||||
{orders.length}건
|
||||
</span>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox checked={orders.length > 0 && checkedIds.length === orders.length} onCheckedChange={handleCheckAll} />
|
||||
</TableHead>
|
||||
<TableHead className="w-[140px]">출하지시번호</TableHead>
|
||||
<TableHead className="w-[100px] text-center">출하일자</TableHead>
|
||||
<TableHead className="w-[120px]">거래처명</TableHead>
|
||||
<TableHead className="w-[100px]">운송업체</TableHead>
|
||||
<TableHead className="w-[90px]">차량번호</TableHead>
|
||||
<TableHead className="w-[80px]">기사명</TableHead>
|
||||
<TableHead className="w-[80px] text-center">상태</TableHead>
|
||||
<TableHead className="w-[100px]">품번</TableHead>
|
||||
<TableHead className="w-[130px]">품명</TableHead>
|
||||
<TableHead className="w-[70px] text-right">수량</TableHead>
|
||||
<TableHead className="w-[80px] text-center">소스</TableHead>
|
||||
<TableHead>비고</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{orders.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={13} className="h-40 text-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Truck className="w-12 h-12 text-muted-foreground/30" />
|
||||
<div className="font-medium">등록된 출하지시가 없습니다</div>
|
||||
<div className="text-sm">출하지시 등록 버튼으로 등록하세요</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
orders.map((order: any) => {
|
||||
const items = Array.isArray(order.items) ? order.items.filter((it: any) => it.id) : [];
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<TableRow key={order.id} className="cursor-pointer hover:bg-muted/50" onClick={() => openModal(order)}>
|
||||
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
|
||||
<Checkbox checked={checkedIds.includes(order.id)} onCheckedChange={(c) => {
|
||||
if (c) setCheckedIds(p => [...p, order.id]);
|
||||
else setCheckedIds(p => p.filter(i => i !== order.id));
|
||||
}} />
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{order.instruction_no}</TableCell>
|
||||
<TableCell className="text-center">{formatDate(order.instruction_date)}</TableCell>
|
||||
<TableCell>{order.customer_name || "-"}</TableCell>
|
||||
<TableCell>{order.carrier_name || "-"}</TableCell>
|
||||
<TableCell>{order.vehicle_no || "-"}</TableCell>
|
||||
<TableCell>{order.driver_name || "-"}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("px-2 py-1 rounded-full text-[11px] font-medium border", getStatusColor(order.status))}>{getStatusLabel(order.status)}</span>
|
||||
</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell className="text-right">0</TableCell>
|
||||
<TableCell className="text-center">-</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground truncate max-w-[100px]">{order.memo || "-"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
return items.map((item: any, itemIdx: number) => (
|
||||
<TableRow key={`${order.id}-${item.id}`} className="cursor-pointer hover:bg-muted/50" onClick={() => openModal(order)}>
|
||||
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
|
||||
{itemIdx === 0 && <Checkbox checked={checkedIds.includes(order.id)} onCheckedChange={(c) => {
|
||||
if (c) setCheckedIds(p => [...p, order.id]);
|
||||
else setCheckedIds(p => p.filter(i => i !== order.id));
|
||||
}} />}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{itemIdx === 0 ? order.instruction_no : ""}</TableCell>
|
||||
<TableCell className="text-center">{itemIdx === 0 ? formatDate(order.instruction_date) : ""}</TableCell>
|
||||
<TableCell>{itemIdx === 0 ? (order.customer_name || "-") : ""}</TableCell>
|
||||
<TableCell>{itemIdx === 0 ? (order.carrier_name || "-") : ""}</TableCell>
|
||||
<TableCell>{itemIdx === 0 ? (order.vehicle_no || "-") : ""}</TableCell>
|
||||
<TableCell>{itemIdx === 0 ? (order.driver_name || "-") : ""}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{itemIdx === 0 && <span className={cn("px-2 py-1 rounded-full text-[11px] font-medium border", getStatusColor(order.status))}>{getStatusLabel(order.status)}</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{item.item_code}</TableCell>
|
||||
<TableCell className="font-medium text-sm">{item.item_name}</TableCell>
|
||||
<TableCell className="text-right">{Number(item.order_qty || 0).toLocaleString()}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{(() => { const b = getSourceBadge(item.source_type || ""); return <span className={cn("px-2 py-0.5 rounded-full text-[10px]", b.cls)}>{b.label}</span>; })()}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground truncate max-w-[100px]">{itemIdx === 0 ? (order.memo || "-") : ""}</TableCell>
|
||||
</TableRow>
|
||||
));
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5" />
|
||||
엑셀 업로드
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => openModal()}>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
출하지시 등록
|
||||
</Button>
|
||||
<div className="w-px h-5 bg-border" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10"
|
||||
disabled={checkedIds.length === 0}
|
||||
onClick={handleDeleteSelected}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
선택삭제 {checkedIds.length > 0 && `(${checkedIds.length})`}
|
||||
</Button>
|
||||
<div className="w-px h-5 bg-border" />
|
||||
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 테이블 */}
|
||||
<div className="flex-1 overflow-hidden rounded-lg border bg-card flex flex-col">
|
||||
<EDataTable
|
||||
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
align: col.key === "qty" ? "right" : col.key === "status" || col.key === "source_type" || col.key === "ship_date" ? "center" : undefined,
|
||||
formatNumber: col.key === "qty",
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
render: col.key === "status"
|
||||
? (val: any) => val ? (
|
||||
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium", getStatusColor(val))}>
|
||||
{getStatusLabel(val)}
|
||||
</span>
|
||||
) : null
|
||||
: col.key === "source_type"
|
||||
? (val: any) => {
|
||||
if (!val || val === "-") return <span>-</span>;
|
||||
const b = getSourceBadge(val);
|
||||
return <span className={cn("px-2 py-0.5 rounded-full text-[10px] font-medium", b.cls)}>{b.label}</span>;
|
||||
}
|
||||
: undefined,
|
||||
}))}
|
||||
data={ts.groupData(flattenedOrders)}
|
||||
rowKey={(row) => row._rowId}
|
||||
loading={loading}
|
||||
emptyMessage="등록된 출하지시가 없어요"
|
||||
showCheckbox
|
||||
checkedIds={flatCheckedRowIds}
|
||||
onCheckedChange={handleFlatCheckedChange}
|
||||
selectedId={selectedOrderId != null ? String(selectedOrderId) : null}
|
||||
onRowClick={(row) => setSelectedOrderId(row._orderId)}
|
||||
onRowDoubleClick={(row) => openModal(row._order)}
|
||||
showPagination
|
||||
draggableColumns={false}
|
||||
columnOrderKey="c16-shipping-order"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<FullscreenDialog
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
title={isEditMode ? "출하지시 수정" : "출하지시 등록"}
|
||||
description={isEditMode ? "출하지시 정보를 수정합니다." : "왼쪽에서 데이터를 선택하고 오른쪽에서 출하지시 정보를 입력하세요."}
|
||||
defaultMaxWidth="max-w-[90vw]"
|
||||
defaultWidth="w-[1400px]"
|
||||
className="h-[90vh]"
|
||||
contentClassName="overflow-hidden flex flex-col"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-[90vw] w-[1400px] h-[90vh] flex flex-col overflow-hidden p-0 gap-0">
|
||||
<DialogHeader className="px-4 py-3 border-b shrink-0">
|
||||
<DialogTitle className="text-[15px] font-bold">
|
||||
{isEditMode ? "출하지시 수정" : "출하지시 등록"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
|
||||
{isEditMode ? "출하지시 정보를 수정해요." : "왼쪽에서 데이터를 선택하고 오른쪽에서 출하지시 정보를 입력해요."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
{/* 왼쪽: 데이터 소스 */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-3 border-b bg-muted/30 flex flex-wrap items-center gap-2 shrink-0">
|
||||
{/* 소스 검색 헤더 */}
|
||||
<div className="px-4 py-2 border-b bg-muted/50 flex flex-wrap items-center gap-2 shrink-0">
|
||||
<Select value={dataSource} onValueChange={(v) => setDataSource(v as DataSourceType)}>
|
||||
<SelectTrigger className="w-[130px] h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectTrigger className="w-[120px] h-9 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="shipmentPlan">출하계획</SelectItem>
|
||||
<SelectItem value="salesOrder">수주정보</SelectItem>
|
||||
<SelectItem value="itemInfo">품목정보</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input placeholder="품번, 품명 검색" className="flex-1 h-8 text-xs min-w-[120px]"
|
||||
value={sourceKeyword} onChange={(e) => setSourceKeyword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { setSourcePage(1); fetchSourceData(1); }}} />
|
||||
<Button size="sm" className="h-8 text-xs" onClick={() => { setSourcePage(1); fetchSourceData(1); }} disabled={sourceLoading}>
|
||||
<Input
|
||||
placeholder="품번, 품명 검색"
|
||||
className="flex-1 h-9 text-xs min-w-[120px]"
|
||||
value={sourceKeyword}
|
||||
onChange={(e) => setSourceKeyword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") { setSourcePage(1); fetchSourceData(1); }
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => { setSourcePage(1); fetchSourceData(1); }}
|
||||
disabled={sourceLoading}
|
||||
>
|
||||
{sourceLoading ? <Loader2 className="w-3 h-3 animate-spin" /> : <Search className="w-3 h-3" />}
|
||||
<span className="ml-1">조회</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2 flex items-center justify-between border-b shrink-0">
|
||||
<div className="text-sm font-medium">
|
||||
{dataSourceTitle[dataSource]}
|
||||
<span className="text-muted-foreground ml-2 font-normal">
|
||||
선택: <span className="text-primary font-semibold">{selectedItems.length}</span>개
|
||||
</span>
|
||||
</div>
|
||||
{/* 소스 서브 헤더 */}
|
||||
<div className="px-3 py-1.5 flex items-center gap-2 border-b bg-muted/30 shrink-0">
|
||||
<span className="text-[13px] font-bold text-foreground">{dataSourceTitle[dataSource]}</span>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
||||
선택 {selectedItems.length}개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 소스 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{sourceLoading ? (
|
||||
<div className="flex items-center justify-center py-12"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : sourceData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<div className="text-sm">조회 버튼을 눌러 데이터를 불러오세요</div>
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground gap-2">
|
||||
<div className="w-12 h-12 rounded-full border-2 border-dashed border-muted-foreground/20 flex items-center justify-center">
|
||||
<Search className="w-5 h-5 text-muted-foreground/30" />
|
||||
</div>
|
||||
<p className="text-sm">조회 버튼을 눌러 데이터를 불러와주세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px] text-center">선택</TableHead>
|
||||
<TableHead className="w-[100px]">품번</TableHead>
|
||||
<TableHead>품명</TableHead>
|
||||
<TableHead className="w-[100px]">규격</TableHead>
|
||||
<TableHead className="w-[100px]">거래처</TableHead>
|
||||
<TableHead className="w-[70px] text-right">수량</TableHead>
|
||||
{dataSource === "shipmentPlan" && <TableHead className="w-[70px] text-center">상태</TableHead>}
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">선택</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">거래처</TableHead>
|
||||
<TableHead className="w-[70px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
||||
{dataSource === "shipmentPlan" && <TableHead className="w-[70px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sourceData.map((item: any, idx: number) => {
|
||||
const itemId = dataSource === "itemInfo" ? item.item_code : item.id;
|
||||
const isSelected = selectedItems.some(s => {
|
||||
// 같은 소스 타입에서 id 매칭
|
||||
if (s.sourceType === dataSource) {
|
||||
if (dataSource === "itemInfo") return s.itemCode === itemId;
|
||||
return String(s.id) === String(itemId);
|
||||
}
|
||||
// 다른 소스 타입이라도 같은 품번이면 중복 방지
|
||||
if (dataSource === "shipmentPlan" && s.shipmentPlanId) return String(s.shipmentPlanId) === String(item.id);
|
||||
if (dataSource === "salesOrder" && s.detailId) return String(s.detailId) === String(item.id);
|
||||
return false;
|
||||
});
|
||||
return (
|
||||
<TableRow key={`${dataSource}-${itemId}-${idx}`} className={cn("cursor-pointer hover:bg-muted/50", isSelected && "bg-primary/5")} onClick={() => toggleSourceItem(item)}>
|
||||
<TableRow
|
||||
key={`${dataSource}-${itemId}-${idx}`}
|
||||
className={cn("cursor-pointer transition-colors", isSelected && "bg-primary/5")}
|
||||
onClick={() => toggleSourceItem(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
|
||||
<Checkbox checked={isSelected} onCheckedChange={() => toggleSourceItem(item)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{item.item_code || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.item_code || "-"}</TableCell>
|
||||
<TableCell className="text-sm font-medium">{item.item_name || "-"}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{item.spec || "-"}</TableCell>
|
||||
<TableCell className="text-xs">{item.customer_name || "-"}</TableCell>
|
||||
<TableCell className="text-right text-xs">{Number(item.plan_qty || item.qty || item.balance_qty || 0).toLocaleString()}</TableCell>
|
||||
<TableCell className="text-[13px] text-muted-foreground">{item.spec || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.customer_name || "-"}</TableCell>
|
||||
<TableCell className="text-right text-[13px]">
|
||||
{Number(item.plan_qty || item.qty || item.balance_qty || 0).toLocaleString()}
|
||||
</TableCell>
|
||||
{dataSource === "shipmentPlan" && (
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="secondary" className="text-[10px]">{getStatusLabel(item.status)}</Badge>
|
||||
<span className={cn("px-2 py-0.5 rounded-full text-[10px] font-medium", getStatusColor(item.status))}>
|
||||
{getStatusLabel(item.status)}
|
||||
</span>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
@@ -695,7 +679,7 @@ export default function ShippingOrderPage() {
|
||||
|
||||
{/* 페이징 */}
|
||||
{sourceTotalCount > 0 && (
|
||||
<div className="px-4 py-2 border-t bg-muted/10 flex items-center justify-between shrink-0">
|
||||
<div className="px-3 py-1.5 border-t bg-muted/30 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-[11px]">표시:</span>
|
||||
<Input
|
||||
@@ -709,9 +693,7 @@ export default function ShippingOrderPage() {
|
||||
}}
|
||||
className="h-7 w-[60px] text-center text-[11px]"
|
||||
/>
|
||||
<span className="text-muted-foreground text-[11px]">
|
||||
총 {sourceTotalCount}건
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[11px]">총 {sourceTotalCount}건</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage <= 1}
|
||||
@@ -722,12 +704,16 @@ export default function ShippingOrderPage() {
|
||||
onClick={() => { const p = sourcePage - 1; setSourcePage(p); fetchSourceData(p); }}>
|
||||
<ChevronLeft className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<span className="text-xs font-medium px-2">{sourcePage} / {Math.max(1, Math.ceil(sourceTotalCount / sourcePageSize))}</span>
|
||||
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage >= Math.ceil(sourceTotalCount / sourcePageSize)}
|
||||
<span className="text-xs font-medium px-2">
|
||||
{sourcePage} / {Math.max(1, Math.ceil(sourceTotalCount / sourcePageSize))}
|
||||
</span>
|
||||
<Button variant="outline" size="icon" className="h-7 w-7"
|
||||
disabled={sourcePage >= Math.ceil(sourceTotalCount / sourcePageSize)}
|
||||
onClick={() => { const p = sourcePage + 1; setSourcePage(p); fetchSourceData(p); }}>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage >= Math.ceil(sourceTotalCount / sourcePageSize)}
|
||||
<Button variant="outline" size="icon" className="h-7 w-7"
|
||||
disabled={sourcePage >= Math.ceil(sourceTotalCount / sourcePageSize)}
|
||||
onClick={() => { const p = Math.ceil(sourceTotalCount / sourcePageSize); setSourcePage(p); fetchSourceData(p); }}>
|
||||
<ChevronsRight className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
@@ -741,25 +727,32 @@ export default function ShippingOrderPage() {
|
||||
|
||||
{/* 오른쪽: 폼 */}
|
||||
<ResizablePanel defaultSize={45} minSize={30}>
|
||||
<div className="flex flex-col h-full overflow-auto p-5 bg-muted/20 gap-5">
|
||||
<div className="flex flex-col h-full overflow-auto p-4 gap-4">
|
||||
{/* 기본 정보 */}
|
||||
<div className="bg-background border rounded-lg p-5 shrink-0">
|
||||
<h3 className="text-sm font-semibold mb-4">기본 정보</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">출하지시번호</Label>
|
||||
<div className="border rounded-lg p-4 shrink-0">
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide mb-3">기본 정보</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">출하지시번호</Label>
|
||||
<Input value={formOrderNumber} readOnly className="h-9 bg-muted/50 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">출하지시일 <span className="text-destructive">*</span></Label>
|
||||
<FormDatePicker value={formOrderDate} onChange={setFormOrderDate} placeholder="날짜 선택" />
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
출하지시일 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="date"
|
||||
className="h-9"
|
||||
value={formOrderDate}
|
||||
onChange={(e) => setFormOrderDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">거래처</Label>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">거래처</Label>
|
||||
<Input value={formCustomer} readOnly placeholder="품목 선택 시 자동" className="h-9 bg-muted/50" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">상태</Label>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">상태</Label>
|
||||
<Select value={formStatus} onValueChange={setFormStatus}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -773,45 +766,80 @@ export default function ShippingOrderPage() {
|
||||
</div>
|
||||
|
||||
{/* 운송 정보 */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden shrink-0">
|
||||
<button className="w-full px-5 py-3 flex items-center justify-between text-left" onClick={() => setIsTransportCollapsed(!isTransportCollapsed)}>
|
||||
<h3 className="text-sm font-semibold text-amber-900 flex items-center gap-2">
|
||||
<Truck className="w-4 h-4" /> 운송 정보 <span className="text-[11px] font-normal text-muted-foreground">(선택사항)</span>
|
||||
</h3>
|
||||
{isTransportCollapsed ? <ChevronRight className="w-4 h-4 text-amber-700" /> : <ChevronDown className="w-4 h-4 text-amber-700" />}
|
||||
<div className="bg-muted/50 border rounded-lg overflow-hidden shrink-0">
|
||||
<button
|
||||
className="w-full px-4 py-2.5 flex items-center justify-between text-left"
|
||||
onClick={() => setIsTransportCollapsed(!isTransportCollapsed)}
|
||||
>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
운송 정보 <span className="text-[10px] font-normal">(선택사항)</span>
|
||||
</p>
|
||||
{isTransportCollapsed
|
||||
? <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
: <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
}
|
||||
</button>
|
||||
{!isTransportCollapsed && (
|
||||
<div className="px-5 pb-4 grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5"><Label className="text-xs">운송업체</Label><Input value={formCarrier} onChange={(e) => setFormCarrier(e.target.value)} className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">차량번호</Label><Input value={formVehicle} onChange={(e) => setFormVehicle(e.target.value)} className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">기사명</Label><Input value={formDriver} onChange={(e) => setFormDriver(e.target.value)} className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">연락처</Label><Input value={formDriverPhone} onChange={(e) => setFormDriverPhone(e.target.value)} className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">도착예정일시</Label><FormDatePicker value={formArrival} onChange={setFormArrival} placeholder="도착예정일시" includeTime /></div>
|
||||
<div className="space-y-1.5"><Label className="text-xs">배송지</Label><Input value={formAddress} onChange={(e) => setFormAddress(e.target.value)} className="h-9" /></div>
|
||||
<div className="px-4 pb-3 grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">운송업체</Label>
|
||||
<Input value={formCarrier} onChange={(e) => setFormCarrier(e.target.value)} className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">차량번호</Label>
|
||||
<Input value={formVehicle} onChange={(e) => setFormVehicle(e.target.value)} className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">기사명</Label>
|
||||
<Input value={formDriver} onChange={(e) => setFormDriver(e.target.value)} className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">연락처</Label>
|
||||
<Input value={formDriverPhone} onChange={(e) => setFormDriverPhone(e.target.value)} className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">도착예정일시</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
className="h-9"
|
||||
value={formArrival}
|
||||
onChange={(e) => setFormArrival(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">배송지</Label>
|
||||
<Input value={formAddress} onChange={(e) => setFormAddress(e.target.value)} className="h-9" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 선택된 품목 */}
|
||||
<div className="bg-background border rounded-lg p-5 flex-1 flex flex-col min-h-[200px]">
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
선택된 품목 <Badge variant="default" className="text-[10px]">{selectedItems.length}</Badge>
|
||||
</h3>
|
||||
<div className="border rounded-lg p-4 flex-1 flex flex-col min-h-[200px]">
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide mb-2 flex items-center gap-2">
|
||||
선택된 품목
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
||||
{selectedItems.length}
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
{selectedItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<div className="text-sm">왼쪽에서 데이터를 선택하세요</div>
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground border-2 border-dashed rounded-lg gap-2">
|
||||
<div className="w-10 h-10 rounded-full border-2 border-dashed border-muted-foreground/20 flex items-center justify-center">
|
||||
<Inbox className="w-4 h-4 text-muted-foreground/30" />
|
||||
</div>
|
||||
<p className="text-sm">왼쪽에서 데이터를 선택해주세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px] text-center">소스</TableHead>
|
||||
<TableHead className="w-[90px]">품번</TableHead>
|
||||
<TableHead>품명</TableHead>
|
||||
<TableHead className="w-[90px] text-center">출하수량</TableHead>
|
||||
<TableHead className="w-[70px] text-right">계획수량</TableHead>
|
||||
<TableHead className="w-[40px] text-center">삭제</TableHead>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">소스</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
|
||||
<TableHead className="w-[90px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">출하수량</TableHead>
|
||||
<TableHead className="w-[70px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">계획수량</TableHead>
|
||||
<TableHead className="w-[40px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">삭제</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -820,15 +848,24 @@ export default function ShippingOrderPage() {
|
||||
return (
|
||||
<TableRow key={`${item.sourceType}-${item.id}-${idx}`}>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("px-1.5 py-0.5 rounded text-[10px]", b.cls)}>{b.label.charAt(0)}</span>
|
||||
<span className={cn("px-1.5 py-0.5 rounded text-[10px] font-medium", b.cls)}>
|
||||
{b.label.charAt(0)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{item.itemCode}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.itemCode}</TableCell>
|
||||
<TableCell className="text-sm font-medium">{item.itemName}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Input type="number" value={item.orderQty} onChange={(e) => updateOrderQty(idx, parseInt(e.target.value) || 0)}
|
||||
min={1} className="h-7 w-[70px] text-xs text-right mx-auto" />
|
||||
<Input
|
||||
type="number"
|
||||
value={item.orderQty}
|
||||
onChange={(e) => updateOrderQty(idx, parseInt(e.target.value) || 0)}
|
||||
min={1}
|
||||
className="h-7 w-[70px] text-xs text-right mx-auto"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-[13px]">
|
||||
{item.planQty ? item.planQty.toLocaleString() : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs">{item.planQty ? item.planQty.toLocaleString() : "-"}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => removeSelectedItem(idx)}>
|
||||
<X className="w-3.5 h-3.5 text-destructive" />
|
||||
@@ -844,25 +881,47 @@ export default function ShippingOrderPage() {
|
||||
</div>
|
||||
|
||||
{/* 메모 */}
|
||||
<div className="bg-background border rounded-lg p-5 shrink-0">
|
||||
<h3 className="text-sm font-semibold mb-3">메모</h3>
|
||||
<Textarea value={formMemo} onChange={(e) => setFormMemo(e.target.value)} placeholder="출하지시 관련 메모" rows={2} className="resize-y" />
|
||||
<div className="border rounded-lg p-4 shrink-0">
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide mb-2">메모</p>
|
||||
<Textarea
|
||||
value={formMemo}
|
||||
onChange={(e) => setFormMemo(e.target.value)}
|
||||
placeholder="출하지시 관련 메모를 입력해주세요"
|
||||
rows={2}
|
||||
className="resize-y"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
</FullscreenDialog>
|
||||
{/* 모달 하단 버튼 */}
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t shrink-0">
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 엑셀 업로드 모달 */}
|
||||
<ExcelUploadModal
|
||||
open={excelUploadOpen}
|
||||
onOpenChange={setExcelUploadOpen}
|
||||
tableName="shipment_instruction"
|
||||
onSuccess={() => {
|
||||
fetchOrders();
|
||||
}}
|
||||
tableName="shipping-order"
|
||||
onSuccess={() => { fetchOrders(); }}
|
||||
/>
|
||||
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,21 +3,34 @@
|
||||
import React, { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import { Search, Download, X, Save, Ban, RotateCcw, Loader2 } from "lucide-react";
|
||||
import { X, Save, Loader2, Inbox, Settings2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getShipmentPlanList,
|
||||
updateShipmentPlan,
|
||||
type ShipmentPlanListItem,
|
||||
} from "@/lib/api/shipping";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "order_no", label: "수주번호" },
|
||||
{ key: "due_date", label: "납기일" },
|
||||
{ key: "customer_name", label: "거래처" },
|
||||
{ key: "part_code", label: "품목코드" },
|
||||
{ key: "part_name", label: "품목명" },
|
||||
{ key: "order_qty", label: "수주수량" },
|
||||
{ key: "plan_qty", label: "계획수량" },
|
||||
{ key: "plan_date", label: "계획일" },
|
||||
{ key: "status", label: "상태" },
|
||||
];
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "all", label: "전체" },
|
||||
@@ -36,28 +49,25 @@ const getStatusLabel = (status: string) => {
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "READY": return "bg-blue-100 text-blue-800 border-blue-200";
|
||||
case "CONFIRMED": return "bg-indigo-100 text-indigo-800 border-indigo-200";
|
||||
case "SHIPPING": return "bg-amber-100 text-amber-800 border-amber-200";
|
||||
case "COMPLETED": return "bg-emerald-100 text-emerald-800 border-emerald-200";
|
||||
case "CANCEL_REQUEST": return "bg-rose-100 text-rose-800 border-rose-200";
|
||||
case "CANCELLED": return "bg-slate-100 text-slate-800 border-slate-200";
|
||||
default: return "bg-gray-100 text-gray-800 border-gray-200";
|
||||
case "READY": return "bg-primary/10 text-primary";
|
||||
case "CONFIRMED": return "bg-secondary text-secondary-foreground";
|
||||
case "SHIPPING": return "bg-warning/10 text-warning";
|
||||
case "COMPLETED": return "bg-success/10 text-success";
|
||||
case "CANCEL_REQUEST": return "bg-destructive/10 text-destructive";
|
||||
case "CANCELLED": return "bg-muted text-muted-foreground";
|
||||
default: return "bg-muted text-muted-foreground";
|
||||
}
|
||||
};
|
||||
|
||||
export default function ShippingPlanPage() {
|
||||
const ts = useTableSettings("c16-shipping-plan", "shipment_plan", GRID_COLUMNS);
|
||||
const [data, setData] = useState<ShipmentPlanListItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [checkedIds, setCheckedIds] = useState<number[]>([]);
|
||||
|
||||
// 검색
|
||||
const [searchDateFrom, setSearchDateFrom] = useState("");
|
||||
const [searchDateTo, setSearchDateTo] = useState("");
|
||||
const [searchStatus, setSearchStatus] = useState("all");
|
||||
const [searchCustomer, setSearchCustomer] = useState("");
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
// 검색 필터 (DynamicSearchFilter에서 관리)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 상세 패널 편집
|
||||
const [editPlanQty, setEditPlanQty] = useState("");
|
||||
@@ -66,28 +76,25 @@ export default function ShippingPlanPage() {
|
||||
const [isDetailChanged, setIsDetailChanged] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 날짜 초기화
|
||||
useEffect(() => {
|
||||
const today = new Date();
|
||||
const threeMonthsAgo = new Date(today);
|
||||
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
|
||||
const oneMonthLater = new Date(today);
|
||||
oneMonthLater.setMonth(oneMonthLater.getMonth() + 1);
|
||||
|
||||
setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]);
|
||||
setSearchDateTo(oneMonthLater.toISOString().split("T")[0]);
|
||||
}, []);
|
||||
|
||||
// 데이터 조회
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
if (searchDateFrom) params.dateFrom = searchDateFrom;
|
||||
if (searchDateTo) params.dateTo = searchDateTo;
|
||||
if (searchStatus !== "all") params.status = searchStatus;
|
||||
if (searchCustomer.trim()) params.customer = searchCustomer.trim();
|
||||
if (searchKeyword.trim()) params.keyword = searchKeyword.trim();
|
||||
for (const f of searchFilters) {
|
||||
if (f.columnName === "plan_date" && f.operator === "between" && f.value) {
|
||||
const [from, to] = f.value.split(",");
|
||||
if (from) params.dateFrom = from;
|
||||
if (to) params.dateTo = to;
|
||||
} else if (f.columnName === "status") {
|
||||
params.status = f.value;
|
||||
} else if (f.columnName === "customer_name") {
|
||||
params.customer = f.value;
|
||||
} else {
|
||||
params.keyword = f.value;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await getShipmentPlanList(params);
|
||||
if (result.success) {
|
||||
@@ -98,37 +105,21 @@ export default function ShippingPlanPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchDateFrom, searchDateTo, searchStatus, searchCustomer, searchKeyword]);
|
||||
}, [searchFilters]);
|
||||
|
||||
// 초기 로드 + 검색 시 자동 조회
|
||||
// searchFilters 변경 시 자동 조회
|
||||
useEffect(() => {
|
||||
if (searchDateFrom && searchDateTo) {
|
||||
fetchData();
|
||||
}
|
||||
}, [searchDateFrom, searchDateTo]);
|
||||
|
||||
const handleSearch = () => fetchData();
|
||||
|
||||
const handleResetSearch = () => {
|
||||
setSearchStatus("all");
|
||||
setSearchCustomer("");
|
||||
setSearchKeyword("");
|
||||
const today = new Date();
|
||||
const threeMonthsAgo = new Date(today);
|
||||
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
|
||||
const oneMonthLater = new Date(today);
|
||||
oneMonthLater.setMonth(oneMonthLater.getMonth() + 1);
|
||||
setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]);
|
||||
setSearchDateTo(oneMonthLater.toISOString().split("T")[0]);
|
||||
};
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const selectedPlan = useMemo(() => data.find(p => p.id === selectedId), [data, selectedId]);
|
||||
|
||||
const groupedData = useMemo(() => {
|
||||
const orderMap = new Map<string, ShipmentPlanListItem[]>();
|
||||
const grouped = ts.groupData(data);
|
||||
const orderMap = new Map<string, any[]>();
|
||||
const orderKeys: string[] = [];
|
||||
data.forEach(plan => {
|
||||
const key = plan.order_no || `_no_order_${plan.id}`;
|
||||
grouped.forEach(plan => {
|
||||
const key = (plan as any)._isGroupSummary ? `_summary_${orderKeys.length}` : (plan.order_no || `_no_order_${plan.id}`);
|
||||
if (!orderMap.has(key)) {
|
||||
orderMap.set(key, []);
|
||||
orderKeys.push(key);
|
||||
@@ -139,7 +130,7 @@ export default function ShippingPlanPage() {
|
||||
orderNo: key,
|
||||
plans: orderMap.get(key)!,
|
||||
}));
|
||||
}, [data]);
|
||||
}, [data, ts.groupData]);
|
||||
|
||||
const handleRowClick = (plan: ShipmentPlanListItem) => {
|
||||
if (isDetailChanged && selectedId !== plan.id) {
|
||||
@@ -160,20 +151,12 @@ export default function ShippingPlanPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheck = (id: number, checked: boolean) => {
|
||||
if (checked) {
|
||||
setCheckedIds(prev => [...prev, id]);
|
||||
} else {
|
||||
setCheckedIds(prev => prev.filter(i => i !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveDetail = async () => {
|
||||
if (!selectedId || !selectedPlan) return;
|
||||
|
||||
const qty = Number(editPlanQty);
|
||||
if (qty <= 0) {
|
||||
alert("계획수량은 0보다 커야 합니다.");
|
||||
alert("계획수량은 0보다 커야 해요.");
|
||||
return;
|
||||
}
|
||||
if (!editPlanDate) {
|
||||
@@ -190,7 +173,7 @@ export default function ShippingPlanPage() {
|
||||
});
|
||||
if (result.success) {
|
||||
setIsDetailChanged(false);
|
||||
alert("저장되었습니다.");
|
||||
alert("저장되었어요.");
|
||||
fetchData();
|
||||
} else {
|
||||
alert(result.message || "저장 실패");
|
||||
@@ -213,345 +196,259 @@ export default function ShippingPlanPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-4rem)] bg-muted/30 p-4 gap-4">
|
||||
{/* 검색 영역 */}
|
||||
<Card className="shrink-0">
|
||||
<CardContent className="p-4 flex flex-wrap items-end gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">출하계획일</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
className="w-[140px] h-9"
|
||||
value={searchDateFrom}
|
||||
onChange={(e) => setSearchDateFrom(e.target.value)}
|
||||
/>
|
||||
<span className="text-muted-foreground">~</span>
|
||||
<Input
|
||||
type="date"
|
||||
className="w-[140px] h-9"
|
||||
value={searchDateTo}
|
||||
onChange={(e) => setSearchDateTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col h-full p-4 gap-3">
|
||||
{/* 브레드크럼 */}
|
||||
<nav className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
|
||||
<span>영업관리</span>
|
||||
<span className="text-muted-foreground/40">/</span>
|
||||
<span className="font-semibold text-foreground">출하계획</span>
|
||||
</nav>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">상태</Label>
|
||||
<Select value={searchStatus} onValueChange={setSearchStatus}>
|
||||
<SelectTrigger className="w-[120px] h-9">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map(o => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 검색 필터 (DynamicSearchFilter) */}
|
||||
<DynamicSearchFilter
|
||||
tableName="shipment_plan"
|
||||
filterId="c16-shipping-plan"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={data.length}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">거래처</Label>
|
||||
<Input
|
||||
placeholder="거래처 검색"
|
||||
className="w-[150px] h-9"
|
||||
value={searchCustomer}
|
||||
onChange={(e) => setSearchCustomer(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">수주번호/품목</Label>
|
||||
<Input
|
||||
placeholder="수주번호 / 품목 검색"
|
||||
className="w-[220px] h-9"
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" className="h-9" onClick={handleSearch} disabled={loading}>
|
||||
{loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Search className="w-4 h-4 mr-2" />}
|
||||
조회
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={handleResetSearch}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" /> 초기화
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 테이블 + 상세 패널 */}
|
||||
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
|
||||
{/* 마스터-디테일 */}
|
||||
<div className="flex-1 overflow-hidden rounded-lg border bg-card">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
<ResizablePanel defaultSize={selectedId ? 65 : 100} minSize={30}>
|
||||
{/* 좌측: 출하계획 목록 */}
|
||||
<ResizablePanel defaultSize={60} minSize={30}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
출하계획 목록
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{/* 패널 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-bold text-foreground">출하계획 목록</span>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
||||
{data.length}건
|
||||
</Badge>
|
||||
</span>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox
|
||||
checked={data.length > 0 && checkedIds.length === data.filter(p => p.status !== "CANCELLED").length}
|
||||
onCheckedChange={handleCheckAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[10%]">수주번호</TableHead>
|
||||
<TableHead className="w-[8%] text-center">납기일</TableHead>
|
||||
<TableHead className="w-[12%]">거래처</TableHead>
|
||||
<TableHead className="w-[20%]">품목코드</TableHead>
|
||||
<TableHead className="w-[20%]">품목명</TableHead>
|
||||
<TableHead className="w-[7%] text-right">수주수량</TableHead>
|
||||
<TableHead className="w-[7%] text-right">계획수량</TableHead>
|
||||
<TableHead className="w-[8%] text-center">출하계획일</TableHead>
|
||||
<TableHead className="w-[6%] text-center">상태</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupedData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="h-32 text-center text-muted-foreground">
|
||||
출하계획이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
groupedData.map((group) =>
|
||||
group.plans.map((plan, planIdx) => (
|
||||
<TableRow
|
||||
key={plan.id}
|
||||
className={cn(
|
||||
"cursor-pointer hover:bg-muted/50 transition-colors",
|
||||
selectedId === plan.id && "bg-primary/5",
|
||||
plan.status === "CANCELLED" && "opacity-60 bg-slate-50",
|
||||
planIdx === 0 && "border-t-2 border-t-border"
|
||||
)}
|
||||
onClick={() => handleRowClick(plan)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
|
||||
{planIdx === 0 && (
|
||||
<Checkbox
|
||||
checked={group.plans.every(p => checkedIds.includes(p.id))}
|
||||
onCheckedChange={(c) => {
|
||||
if (c) {
|
||||
setCheckedIds(prev => [...new Set([...prev, ...group.plans.filter(p => p.status !== "CANCELLED").map(p => p.id)])]);
|
||||
} else {
|
||||
setCheckedIds(prev => prev.filter(id => !group.plans.some(p => p.id === id)));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{planIdx === 0 ? (plan.order_no || "-") : ""}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{planIdx === 0 ? formatDate(plan.due_date) : ""}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{planIdx === 0 ? (plan.customer_name || "-") : ""}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">{plan.part_code || "-"}</TableCell>
|
||||
<TableCell className="font-medium">{plan.part_name || "-"}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(plan.order_qty)}</TableCell>
|
||||
<TableCell className="text-right font-semibold text-primary">{formatNumber(plan.plan_qty)}</TableCell>
|
||||
<TableCell className="text-center">{formatDate(plan.plan_date)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("px-2 py-1 rounded-full text-[11px] font-medium border", getStatusColor(plan.status))}>
|
||||
{getStatusLabel(plan.status)}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<EDataTable
|
||||
columns={[
|
||||
{ key: "order_no", label: "수주번호", render: (val: any) => <span className="font-medium text-sm">{val || "-"}</span> },
|
||||
{ key: "due_date", label: "납기일", align: "center" as const, render: (val: any) => <span className="text-sm">{formatDate(val)}</span> },
|
||||
{ key: "customer_name", label: "거래처", render: (val: any) => <span className="text-sm">{val || "-"}</span> },
|
||||
{ key: "part_code", label: "품목코드", render: (val: any) => <span className="text-muted-foreground text-[13px]">{val || "-"}</span> },
|
||||
{ key: "part_name", label: "품목명", render: (val: any) => <span className="font-medium text-sm">{val || "-"}</span> },
|
||||
{ key: "order_qty", label: "수주수량", align: "right" as const, formatNumber: true },
|
||||
{ key: "plan_qty", label: "계획수량", align: "right" as const, render: (val: any) => <span className="font-semibold text-primary text-sm">{formatNumber(val)}</span> },
|
||||
{ key: "plan_date", label: "계획일", align: "center" as const, render: (val: any) => <span className="text-sm">{formatDate(val)}</span> },
|
||||
{ key: "status", label: "상태", align: "center" as const, render: (val: any) => <span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium", getStatusColor(val))}>{getStatusLabel(val)}</span> },
|
||||
] as EDataTableColumn<ShipmentPlanListItem>[]}
|
||||
data={data}
|
||||
rowKey={(row) => String(row.id)}
|
||||
loading={loading}
|
||||
emptyMessage="출하계획이 없어요"
|
||||
selectedId={selectedId !== null ? String(selectedId) : null}
|
||||
onSelect={(id) => {
|
||||
if (id) {
|
||||
const plan = data.find(p => String(p.id) === id);
|
||||
if (plan) handleRowClick(plan);
|
||||
}
|
||||
}}
|
||||
onRowClick={(row) => handleRowClick(row)}
|
||||
showCheckbox
|
||||
checkedIds={checkedIds.map(String)}
|
||||
onCheckedChange={(ids) => setCheckedIds(ids.map(Number))}
|
||||
showPagination={false}
|
||||
draggableColumns={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
{/* 상세 패널 */}
|
||||
{selectedId && selectedPlan && (
|
||||
<>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel defaultSize={35} minSize={25}>
|
||||
<div className="flex flex-col h-full bg-card">
|
||||
<div className="flex items-center justify-between p-3 border-b shrink-0">
|
||||
<span className="font-semibold text-sm">
|
||||
{selectedPlan.shipment_plan_no || `#${selectedPlan.id}`}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveDetail}
|
||||
disabled={!isDetailChanged || saving}
|
||||
className={cn(isDetailChanged ? "bg-primary" : "bg-muted text-muted-foreground")}
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
|
||||
저장
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setSelectedId(null)}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 우측: 상세 패널 */}
|
||||
<ResizablePanel defaultSize={40} minSize={20}>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 패널 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-bold text-foreground">출하계획 상세</span>
|
||||
{selectedPlan && (
|
||||
<span className="text-[11px] font-mono text-muted-foreground">{selectedPlan.shipment_plan_no}</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedPlan && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveDetail}
|
||||
disabled={!isDetailChanged || saving}
|
||||
variant={isDetailChanged ? "default" : "secondary"}
|
||||
>
|
||||
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||
저장
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setSelectedId(null)}>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 상세 컨텐츠 */}
|
||||
{selectedPlan ? (
|
||||
<div className="flex-1 overflow-auto p-4 space-y-4">
|
||||
{/* 기본 정보 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">상태</p>
|
||||
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium inline-block", getStatusColor(selectedPlan.status))}>
|
||||
{getStatusLabel(selectedPlan.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">수주번호</p>
|
||||
<p className="text-sm font-mono text-muted-foreground">{selectedPlan.order_no || "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">거래처</p>
|
||||
<p className="text-sm font-medium text-foreground">{selectedPlan.customer_name || "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">납기일</p>
|
||||
<p className="text-sm text-foreground">{formatDate(selectedPlan.due_date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4 space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold mb-3">기본 정보</h3>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-1">상태</span>
|
||||
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border inline-block", getStatusColor(selectedPlan.status))}>
|
||||
{getStatusLabel(selectedPlan.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-1">수주번호</span>
|
||||
<span>{selectedPlan.order_no || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-1">거래처</span>
|
||||
<span>{selectedPlan.customer_name || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-1">등록일</span>
|
||||
<span>{formatDate(selectedPlan.created_date)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-1">납기일</span>
|
||||
<span>{formatDate(selectedPlan.due_date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div className="border-t" />
|
||||
|
||||
{/* 품목 정보 */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold mb-3">품목 정보</h3>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm bg-muted/30 p-3 rounded-md border border-border/50">
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-1">품목코드</span>
|
||||
<span>{selectedPlan.part_code || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-1">품목명</span>
|
||||
<span className="font-medium">{selectedPlan.part_name || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-1">규격</span>
|
||||
<span>{selectedPlan.spec || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-1">재질</span>
|
||||
<span>{selectedPlan.material || "-"}</span>
|
||||
</div>
|
||||
{/* 품목 정보 */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-2">품목 정보</p>
|
||||
<div className="grid grid-cols-2 gap-3 bg-muted/30 border rounded-md p-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">품목코드</p>
|
||||
<p className="text-sm font-mono text-muted-foreground">{selectedPlan.part_code || "-"}</p>
|
||||
</div>
|
||||
</section>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">품목명</p>
|
||||
<p className="text-sm font-medium text-foreground">{selectedPlan.part_name || "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">규격</p>
|
||||
<p className="text-sm text-foreground">{selectedPlan.spec || "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">재질</p>
|
||||
<p className="text-sm text-foreground">{selectedPlan.material || "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 수량 정보 */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold mb-3">수량 정보</h3>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-1">수주수량</span>
|
||||
<span>{formatNumber(selectedPlan.order_qty)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs block mb-1">계획수량</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="h-8"
|
||||
value={editPlanQty}
|
||||
onChange={(e) => { setEditPlanQty(e.target.value); setIsDetailChanged(true); }}
|
||||
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-1">출하수량</span>
|
||||
<span>{formatNumber(selectedPlan.shipped_qty)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-1">잔여수량</span>
|
||||
<span className={cn("font-semibold",
|
||||
(Number(selectedPlan.plan_qty) - Number(selectedPlan.shipped_qty)) > 0
|
||||
? "text-destructive"
|
||||
: "text-emerald-600"
|
||||
)}>
|
||||
{formatNumber(Number(selectedPlan.plan_qty) - Number(selectedPlan.shipped_qty))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div className="border-t" />
|
||||
|
||||
{/* 출하 정보 */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold mb-3">출하 정보</h3>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-4 text-sm">
|
||||
<div className="col-span-2">
|
||||
<Label className="text-muted-foreground text-xs block mb-1">출하계획일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
className="h-8"
|
||||
value={editPlanDate}
|
||||
onChange={(e) => { setEditPlanDate(e.target.value); setIsDetailChanged(true); }}
|
||||
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-muted-foreground text-xs block mb-1">비고</Label>
|
||||
<Textarea
|
||||
className="min-h-[80px] resize-y"
|
||||
value={editMemo}
|
||||
onChange={(e) => { setEditMemo(e.target.value); setIsDetailChanged(true); }}
|
||||
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
|
||||
placeholder="비고 입력"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/* 수량 정보 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">수주수량</p>
|
||||
<p className="text-sm text-foreground">{formatNumber(selectedPlan.order_qty)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1 block">계획수량</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="h-9"
|
||||
value={editPlanQty}
|
||||
onChange={(e) => { setEditPlanQty(e.target.value); setIsDetailChanged(true); }}
|
||||
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">출하수량</p>
|
||||
<p className="text-sm text-foreground">{formatNumber(selectedPlan.shipped_qty)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">잔여수량</p>
|
||||
<p className={cn(
|
||||
"font-semibold text-sm",
|
||||
(Number(selectedPlan.plan_qty) - Number(selectedPlan.shipped_qty)) > 0
|
||||
? "text-destructive"
|
||||
: "text-success"
|
||||
)}>
|
||||
{formatNumber(Number(selectedPlan.plan_qty) - Number(selectedPlan.shipped_qty))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 등록자 정보 */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold mb-3">등록 정보</h3>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm text-muted-foreground">
|
||||
<div>
|
||||
<span className="text-xs block mb-1">등록자</span>
|
||||
<span className="text-foreground">{selectedPlan.created_by || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs block mb-1">등록일시</span>
|
||||
<span className="text-foreground">{selectedPlan.created_date ? new Date(selectedPlan.created_date).toLocaleString("ko-KR") : "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div className="border-t" />
|
||||
|
||||
{/* 출하 정보 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1 block">출하계획일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
className="h-9"
|
||||
value={editPlanDate}
|
||||
onChange={(e) => { setEditPlanDate(e.target.value); setIsDetailChanged(true); }}
|
||||
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1 block">비고</Label>
|
||||
<Textarea
|
||||
className="min-h-[80px] resize-y text-sm"
|
||||
value={editMemo}
|
||||
onChange={(e) => { setEditMemo(e.target.value); setIsDetailChanged(true); }}
|
||||
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
|
||||
placeholder="비고를 입력해주세요"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t" />
|
||||
|
||||
{/* 등록 정보 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">등록자</p>
|
||||
<p className="text-sm text-foreground">{selectedPlan.created_by || "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">등록일시</p>
|
||||
<p className="text-sm text-foreground">
|
||||
{selectedPlan.created_date ? new Date(selectedPlan.created_date).toLocaleString("ko-KR") : "-"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-8 text-muted-foreground">
|
||||
<div className="w-12 h-12 rounded-full border-2 border-dashed border-muted-foreground/20 flex items-center justify-center mb-4">
|
||||
<Inbox className="w-6 h-6 text-muted-foreground/30" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">좌측에서 출하계획을 선택해주세요</p>
|
||||
<p className="text-xs text-muted-foreground/60 mt-1">선택하면 상세 정보가 표시돼요</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
Settings,
|
||||
List,
|
||||
KeyRound,
|
||||
Send,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getSmartFactoryLogs,
|
||||
@@ -62,10 +63,13 @@ import {
|
||||
getApiKeys,
|
||||
saveApiKey,
|
||||
deleteApiKey,
|
||||
getCompanyUsers,
|
||||
sendNow,
|
||||
SmartFactoryLogEntry,
|
||||
SmartFactoryLogFilters,
|
||||
SmartFactoryLogStats,
|
||||
ApiKeyEntry,
|
||||
CompanyUser,
|
||||
SmartFactorySchedule,
|
||||
SmartFactoryHoliday,
|
||||
TodayPlanEntry,
|
||||
@@ -100,10 +104,19 @@ export default function SmartFactoryLogPage() {
|
||||
timeEnd: "17:30",
|
||||
excludeWeekend: true,
|
||||
excludeHolidays: true,
|
||||
dailyCount: 1,
|
||||
});
|
||||
const [apiKeys, setApiKeys] = useState<ApiKeyEntry[]>([]);
|
||||
const [editingKey, setEditingKey] = useState({ companyCode: "", apiKey: "" });
|
||||
const [keyDialogOpen, setKeyDialogOpen] = useState(false);
|
||||
// 즉시 전송
|
||||
const [sendCompany, setSendCompany] = useState("");
|
||||
const [sendUsers, setSendUsers] = useState<CompanyUser[]>([]);
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(new Set());
|
||||
const [sendTimeStart, setSendTimeStart] = useState("08:30");
|
||||
const [sendTimeEnd, setSendTimeEnd] = useState("17:30");
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const [newHoliday, setNewHoliday] = useState({ date: "", name: "" });
|
||||
const [holidayYear, setHolidayYear] = useState(String(new Date().getFullYear()));
|
||||
const [holidayPage, setHolidayPage] = useState(1);
|
||||
@@ -163,6 +176,36 @@ export default function SmartFactoryLogPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSendCompanyChange = async (companyCode: string) => {
|
||||
setSendCompany(companyCode);
|
||||
setSelectedUserIds(new Set());
|
||||
if (!companyCode) { setSendUsers([]); return; }
|
||||
try {
|
||||
const res = await getCompanyUsers(companyCode);
|
||||
if (res.success) setSendUsers(res.data);
|
||||
} catch { setSendUsers([]); }
|
||||
};
|
||||
|
||||
const handleSendNow = async () => {
|
||||
if (!sendCompany || selectedUserIds.size === 0) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const res = await sendNow({
|
||||
companyCode: sendCompany,
|
||||
userIds: Array.from(selectedUserIds),
|
||||
});
|
||||
if (res.success) {
|
||||
alert(res.message);
|
||||
fetchLogs();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("즉시 전송 실패:", e);
|
||||
alert("전송에 실패했습니다.");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [scheduleError, setScheduleError] = useState("");
|
||||
const handleSaveSchedule = async () => {
|
||||
if (!editingSchedule.companyCode) return;
|
||||
@@ -190,7 +233,7 @@ export default function SmartFactoryLogPage() {
|
||||
await upsertSchedule({
|
||||
companyCode: s.company_code, isActive: !s.is_active,
|
||||
timeStart: s.time_start, timeEnd: s.time_end,
|
||||
excludeWeekend: s.exclude_weekend, excludeHolidays: s.exclude_holidays,
|
||||
excludeWeekend: s.exclude_weekend, excludeHolidays: s.exclude_holidays, dailyCount: s.daily_count || 1,
|
||||
});
|
||||
fetchSchedules();
|
||||
} catch (e) { console.error("스케줄 토글 실패:", e); }
|
||||
@@ -234,6 +277,10 @@ export default function SmartFactoryLogPage() {
|
||||
<Settings className="h-4 w-4" />
|
||||
스케줄 설정
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sendnow" className="gap-1.5">
|
||||
<Send className="h-4 w-4" />
|
||||
즉시 전송
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="apikeys" className="gap-1.5">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
API 키 관리
|
||||
@@ -503,7 +550,7 @@ export default function SmartFactoryLogPage() {
|
||||
회사별 자동 전송 스케줄
|
||||
</CardTitle>
|
||||
<Button size="sm" onClick={() => {
|
||||
setEditingSchedule({ companyCode: "", isActive: true, timeStart: "08:30", timeEnd: "17:30", excludeWeekend: true, excludeHolidays: true });
|
||||
setEditingSchedule({ companyCode: "", isActive: true, timeStart: "08:30", timeEnd: "17:30", excludeWeekend: true, excludeHolidays: true, dailyCount: 1 });
|
||||
setScheduleError(""); setScheduleDialogOpen(true);
|
||||
}}>
|
||||
<Plus className="h-4 w-4 mr-1" />스케줄 추가
|
||||
@@ -522,6 +569,7 @@ export default function SmartFactoryLogPage() {
|
||||
<TableHead className="w-[140px]">로그인 시간대</TableHead>
|
||||
<TableHead className="w-[90px]">주말 제외</TableHead>
|
||||
<TableHead className="w-[90px]">공휴일 제외</TableHead>
|
||||
<TableHead className="w-[70px]">횟수</TableHead>
|
||||
<TableHead className="w-[80px]">활성</TableHead>
|
||||
<TableHead className="w-[160px]">동작</TableHead>
|
||||
</TableRow>
|
||||
@@ -535,6 +583,7 @@ export default function SmartFactoryLogPage() {
|
||||
</TableCell>
|
||||
<TableCell>{s.exclude_weekend ? <Badge variant="secondary">Y</Badge> : <span className="text-muted-foreground text-xs">N</span>}</TableCell>
|
||||
<TableCell>{s.exclude_holidays ? <Badge variant="secondary">Y</Badge> : <span className="text-muted-foreground text-xs">N</span>}</TableCell>
|
||||
<TableCell className="text-sm text-center">{s.daily_count || 1}회</TableCell>
|
||||
<TableCell><Switch checked={s.is_active} onCheckedChange={() => handleToggleSchedule(s)} /></TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -543,7 +592,7 @@ export default function SmartFactoryLogPage() {
|
||||
setEditingSchedule({
|
||||
companyCode: s.company_code, isActive: s.is_active,
|
||||
timeStart: s.time_start, timeEnd: s.time_end,
|
||||
excludeWeekend: s.exclude_weekend, excludeHolidays: s.exclude_holidays,
|
||||
excludeWeekend: s.exclude_weekend, excludeHolidays: s.exclude_holidays, dailyCount: s.daily_count || 1,
|
||||
});
|
||||
setScheduleError(""); setScheduleDialogOpen(true);
|
||||
}}>
|
||||
@@ -566,7 +615,109 @@ export default function SmartFactoryLogPage() {
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ─── 탭 3: API 키 관리 ─── */}
|
||||
{/* ─── 탭 3: 즉시 전송 ─── */}
|
||||
<TabsContent value="sendnow" className="overflow-y-auto flex-1 pb-6">
|
||||
<div className="space-y-6 pt-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Send className="h-4 w-4" />
|
||||
사용자 지정 즉시 전송
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 회사 선택 + 시간대 */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="w-[200px]">
|
||||
<label className="text-xs text-muted-foreground mb-1 block">회사</label>
|
||||
<Select value={sendCompany || "none"} onValueChange={(v) => handleSendCompanyChange(v === "none" ? "" : v)}>
|
||||
<SelectTrigger><SelectValue placeholder="회사 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none" disabled>회사 선택</SelectItem>
|
||||
{companies.filter((c) => c.company_code !== "*").map((c) => (
|
||||
<SelectItem key={c.company_code} value={c.company_code}>{c.company_name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSendNow}
|
||||
disabled={sending || selectedUserIds.size === 0}
|
||||
>
|
||||
{sending ? <RefreshCw className="h-4 w-4 mr-1 animate-spin" /> : <Send className="h-4 w-4 mr-1" />}
|
||||
{selectedUserIds.size > 0 ? `${selectedUserIds.size}명 전송` : "전송"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
선택한 사용자의 로그를 현재 시각으로 즉시 전송합니다.
|
||||
</p>
|
||||
|
||||
{/* 사용자 목록 */}
|
||||
{sendCompany && (
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={sendUsers.length > 0 && selectedUserIds.size === sendUsers.length}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedUserIds(new Set(sendUsers.map((u) => u.user_id)));
|
||||
} else {
|
||||
setSelectedUserIds(new Set());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>사용자 ID</TableHead>
|
||||
<TableHead>이름</TableHead>
|
||||
<TableHead>부서</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sendUsers.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
|
||||
사용자가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
sendUsers.map((u) => (
|
||||
<TableRow key={u.user_id} className="cursor-pointer" onClick={() => {
|
||||
setSelectedUserIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(u.user_id)) next.delete(u.user_id);
|
||||
else next.add(u.user_id);
|
||||
return next;
|
||||
});
|
||||
}}>
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={selectedUserIds.has(u.user_id)}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-mono">{u.user_id}</TableCell>
|
||||
<TableCell className="text-sm">{u.user_name}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{u.dept_name || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ─── 탭 4: API 키 관리 ─── */}
|
||||
<TabsContent value="apikeys" className="overflow-y-auto flex-1 pb-6">
|
||||
<div className="space-y-6 pt-4">
|
||||
<Card>
|
||||
@@ -811,6 +962,20 @@ export default function SmartFactoryLogPage() {
|
||||
<label className="text-sm">공휴일 제외</label>
|
||||
<Switch checked={editingSchedule.excludeHolidays} onCheckedChange={(v) => setEditingSchedule((p) => ({ ...p, excludeHolidays: v }))} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm">일일 접속 횟수</label>
|
||||
<p className="text-xs text-muted-foreground">사용자별 하루 최대 접속/종료 쌍 수</p>
|
||||
</div>
|
||||
<Select value={String(editingSchedule.dailyCount)} onValueChange={(v) => setEditingSchedule((p) => ({ ...p, dailyCount: parseInt(v, 10) }))}>
|
||||
<SelectTrigger className="w-[80px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1회</SelectItem>
|
||||
<SelectItem value="2">2회</SelectItem>
|
||||
<SelectItem value="3">3회</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{scheduleError && (
|
||||
<p className="text-sm text-destructive px-6">{scheduleError}</p>
|
||||
|
||||
@@ -95,6 +95,8 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
// === COMPANY_7 (탑씰) ===
|
||||
"/COMPANY_7/master-data/item-info": dynamic(() => import("@/app/(main)/COMPANY_7/master-data/item-info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/master-data/department": dynamic(() => import("@/app/(main)/COMPANY_7/master-data/department/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/master-data/company": dynamic(() => import("@/app/(main)/COMPANY_7/master-data/company/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/master-data/options": dynamic(() => import("@/app/(main)/COMPANY_7/master-data/options/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/sales/order": dynamic(() => import("@/app/(main)/COMPANY_7/sales/order/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/sales/customer": dynamic(() => import("@/app/(main)/COMPANY_7/sales/customer/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/sales/sales-item": dynamic(() => import("@/app/(main)/COMPANY_7/sales/sales-item/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -105,11 +107,16 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_7/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_7/production/process-info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_7/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_7/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/production/bom": dynamic(() => import("@/app/(main)/COMPANY_7/production/bom/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/equipment/info": dynamic(() => import("@/app/(main)/COMPANY_7/equipment/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/equipment/plc-settings": dynamic(() => import("@/app/(main)/COMPANY_7/equipment/plc-settings/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/logistics/receiving": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/receiving/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/logistics/packaging": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/packaging/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/logistics/info": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/logistics/inventory": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/inventory/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/logistics/warehouse": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/warehouse/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/outsourcing/subcontractor": dynamic(() => import("@/app/(main)/COMPANY_7/outsourcing/subcontractor/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/outsourcing/subcontractor-item": dynamic(() => import("@/app/(main)/COMPANY_7/outsourcing/subcontractor-item/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/design/project": dynamic(() => import("@/app/(main)/COMPANY_7/design/project/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -159,6 +166,7 @@ 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 }),
|
||||
@@ -248,6 +256,47 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_10/design/my-work": dynamic(() => import("@/app/(main)/COMPANY_10/design/my-work/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/design/design-request": dynamic(() => import("@/app/(main)/COMPANY_10/design/design-request/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/design/task-management": dynamic(() => import("@/app/(main)/COMPANY_10/design/task-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
// === COMPANY_29 (시연용 회사) ===
|
||||
"/COMPANY_29/master-data/options": dynamic(() => import("@/app/(main)/COMPANY_29/master-data/options/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/master-data/item-info": dynamic(() => import("@/app/(main)/COMPANY_29/master-data/item-info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/master-data/department": dynamic(() => import("@/app/(main)/COMPANY_29/master-data/department/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/master-data/company": dynamic(() => import("@/app/(main)/COMPANY_29/master-data/company/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/sales/order": dynamic(() => import("@/app/(main)/COMPANY_29/sales/order/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/sales/customer": dynamic(() => import("@/app/(main)/COMPANY_29/sales/customer/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/sales/sales-item": dynamic(() => import("@/app/(main)/COMPANY_29/sales/sales-item/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/sales/shipping-order": dynamic(() => import("@/app/(main)/COMPANY_29/sales/shipping-order/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/sales/shipping-plan": dynamic(() => import("@/app/(main)/COMPANY_29/sales/shipping-plan/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/sales/claim": dynamic(() => import("@/app/(main)/COMPANY_29/sales/claim/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/sales/quote": dynamic(() => import("@/app/(main)/COMPANY_29/sales/quote/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_29/production/process-info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_29/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_29/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/production/bom": dynamic(() => import("@/app/(main)/COMPANY_29/production/bom/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/equipment/info": dynamic(() => import("@/app/(main)/COMPANY_29/equipment/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/equipment/plc-settings": dynamic(() => import("@/app/(main)/COMPANY_29/equipment/plc-settings/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/monitoring/production": dynamic(() => import("@/app/(main)/COMPANY_29/monitoring/production/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/monitoring/equipment": dynamic(() => import("@/app/(main)/COMPANY_29/monitoring/equipment/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_29/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/logistics/receiving": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/receiving/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/logistics/packaging": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/packaging/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/logistics/inventory": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/inventory/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/logistics/warehouse": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/warehouse/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/logistics/info": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/outsourcing/subcontractor": dynamic(() => import("@/app/(main)/COMPANY_29/outsourcing/subcontractor/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/outsourcing/subcontractor-item": dynamic(() => import("@/app/(main)/COMPANY_29/outsourcing/subcontractor-item/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/purchase/order": dynamic(() => import("@/app/(main)/COMPANY_29/purchase/order/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/purchase/purchase-item": dynamic(() => import("@/app/(main)/COMPANY_29/purchase/purchase-item/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/purchase/supplier": dynamic(() => import("@/app/(main)/COMPANY_29/purchase/supplier/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/quality/inspection": dynamic(() => import("@/app/(main)/COMPANY_29/quality/inspection/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/quality/item-inspection": dynamic(() => import("@/app/(main)/COMPANY_29/quality/item-inspection/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/mold/info": dynamic(() => import("@/app/(main)/COMPANY_29/mold/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/design/project": dynamic(() => import("@/app/(main)/COMPANY_29/design/project/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/design/change-management": dynamic(() => import("@/app/(main)/COMPANY_29/design/change-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/design/my-work": dynamic(() => import("@/app/(main)/COMPANY_29/design/my-work/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/design/design-request": dynamic(() => import("@/app/(main)/COMPANY_29/design/design-request/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_29/design/task-management": dynamic(() => import("@/app/(main)/COMPANY_29/design/task-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
// === COMPANY_9 (제일그라스) ===
|
||||
"/COMPANY_9/sales/order": dynamic(() => import("@/app/(main)/COMPANY_9/sales/order/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
@@ -332,6 +381,8 @@ const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
|
||||
// COMPANY_7 (탑씰)
|
||||
"/COMPANY_7/master-data/item-info": () => import("@/app/(main)/COMPANY_7/master-data/item-info/page"),
|
||||
"/COMPANY_7/master-data/department": () => import("@/app/(main)/COMPANY_7/master-data/department/page"),
|
||||
"/COMPANY_7/master-data/company": () => import("@/app/(main)/COMPANY_7/master-data/company/page"),
|
||||
"/COMPANY_7/master-data/options": () => import("@/app/(main)/COMPANY_7/master-data/options/page"),
|
||||
"/COMPANY_7/sales/order": () => import("@/app/(main)/COMPANY_7/sales/order/page"),
|
||||
"/COMPANY_7/sales/customer": () => import("@/app/(main)/COMPANY_7/sales/customer/page"),
|
||||
"/COMPANY_7/sales/sales-item": () => import("@/app/(main)/COMPANY_7/sales/sales-item/page"),
|
||||
@@ -341,11 +392,16 @@ const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
|
||||
"/COMPANY_7/production/process-info": () => import("@/app/(main)/COMPANY_7/production/process-info/page"),
|
||||
"/COMPANY_7/production/work-instruction": () => import("@/app/(main)/COMPANY_7/production/work-instruction/page"),
|
||||
"/COMPANY_7/production/plan-management": () => import("@/app/(main)/COMPANY_7/production/plan-management/page"),
|
||||
"/COMPANY_7/production/bom": () => import("@/app/(main)/COMPANY_7/production/bom/page"),
|
||||
"/COMPANY_7/equipment/info": () => import("@/app/(main)/COMPANY_7/equipment/info/page"),
|
||||
"/COMPANY_7/equipment/plc-settings": () => import("@/app/(main)/COMPANY_7/equipment/plc-settings/page"),
|
||||
"/COMPANY_7/logistics/material-status": () => import("@/app/(main)/COMPANY_7/logistics/material-status/page"),
|
||||
"/COMPANY_7/logistics/outbound": () => import("@/app/(main)/COMPANY_7/logistics/outbound/page"),
|
||||
"/COMPANY_7/logistics/receiving": () => import("@/app/(main)/COMPANY_7/logistics/receiving/page"),
|
||||
"/COMPANY_7/logistics/packaging": () => import("@/app/(main)/COMPANY_7/logistics/packaging/page"),
|
||||
"/COMPANY_7/logistics/info": () => import("@/app/(main)/COMPANY_7/logistics/info/page"),
|
||||
"/COMPANY_7/logistics/inventory": () => import("@/app/(main)/COMPANY_7/logistics/inventory/page"),
|
||||
"/COMPANY_7/logistics/warehouse": () => import("@/app/(main)/COMPANY_7/logistics/warehouse/page"),
|
||||
"/COMPANY_7/outsourcing/subcontractor": () => import("@/app/(main)/COMPANY_7/outsourcing/subcontractor/page"),
|
||||
"/COMPANY_7/outsourcing/subcontractor-item": () => import("@/app/(main)/COMPANY_7/outsourcing/subcontractor-item/page"),
|
||||
"/COMPANY_7/design/project": () => import("@/app/(main)/COMPANY_7/design/project/page"),
|
||||
|
||||
@@ -139,16 +139,7 @@ export const useLogin = () => {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const firstMenuPath = result.data?.firstMenuPath;
|
||||
const firstMenuName = result.data?.firstMenuName;
|
||||
if (firstMenuName) {
|
||||
localStorage.setItem("currentMenuName", firstMenuName);
|
||||
}
|
||||
if (firstMenuPath) {
|
||||
router.push(firstMenuPath);
|
||||
} else {
|
||||
router.push(AUTH_CONFIG.ROUTES.MAIN);
|
||||
}
|
||||
router.push(AUTH_CONFIG.ROUTES.MAIN);
|
||||
}
|
||||
} else {
|
||||
// 로그인 실패
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface SmartFactorySchedule {
|
||||
time_end: string;
|
||||
exclude_weekend: boolean;
|
||||
exclude_holidays: boolean;
|
||||
daily_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -109,6 +110,7 @@ export async function upsertSchedule(params: {
|
||||
timeEnd: string;
|
||||
excludeWeekend: boolean;
|
||||
excludeHolidays: boolean;
|
||||
dailyCount: number;
|
||||
}): Promise<{ success: boolean; message: string }> {
|
||||
const response = await apiClient.post("/admin/smart-factory-log/schedules", params);
|
||||
return response.data;
|
||||
@@ -171,3 +173,26 @@ export async function deleteApiKey(companyCode: string): Promise<{ success: bool
|
||||
const response = await apiClient.delete(`/admin/smart-factory-log/api-keys/${companyCode}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ─── 즉시 전송 ───
|
||||
|
||||
export interface CompanyUser {
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
dept_name: string | null;
|
||||
}
|
||||
|
||||
export async function getCompanyUsers(companyCode: string): Promise<{ success: boolean; data: CompanyUser[] }> {
|
||||
const response = await apiClient.get(`/admin/smart-factory-log/users/${companyCode}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function sendNow(params: {
|
||||
companyCode: string;
|
||||
userIds: string[];
|
||||
timeStart?: string;
|
||||
timeEnd?: string;
|
||||
}): Promise<{ success: boolean; data: { total: number }; message: string }> {
|
||||
const response = await apiClient.post("/admin/smart-factory-log/send-now", params);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -31,9 +31,17 @@ const nextConfig = {
|
||||
];
|
||||
},
|
||||
|
||||
// 개발 환경에서 CORS 처리
|
||||
// 캐시 및 CORS 헤더
|
||||
async headers() {
|
||||
return [
|
||||
// HTML 페이지: 배포 후 즉시 반영되도록 캐시 금지
|
||||
{
|
||||
source: "/((?!_next/static|_next/image|favicon.ico).*)",
|
||||
headers: [
|
||||
{ key: "Cache-Control", value: "no-cache, no-store, must-revalidate" },
|
||||
],
|
||||
},
|
||||
// API CORS
|
||||
{
|
||||
source: "/api/:path*",
|
||||
headers: [
|
||||
|
||||
Generated
+5
-83
@@ -270,7 +270,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -312,7 +311,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -346,7 +344,6 @@
|
||||
"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",
|
||||
@@ -3062,7 +3059,6 @@
|
||||
"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",
|
||||
@@ -3722,7 +3718,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -3817,7 +3812,6 @@
|
||||
"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"
|
||||
@@ -4161,7 +4155,6 @@
|
||||
"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",
|
||||
@@ -6662,7 +6655,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -6673,7 +6665,6 @@
|
||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -6716,7 +6707,6 @@
|
||||
"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",
|
||||
@@ -6799,7 +6789,6 @@
|
||||
"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",
|
||||
@@ -7432,7 +7421,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -8583,8 +8571,7 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3": {
|
||||
"version": "7.9.0",
|
||||
@@ -8906,7 +8893,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -9688,7 +9674,6 @@
|
||||
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -9777,7 +9762,6 @@
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@@ -9879,7 +9863,6 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -10497,20 +10480,6 @@
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"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/fstream": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz",
|
||||
@@ -11050,7 +11019,6 @@
|
||||
"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"
|
||||
@@ -11831,8 +11799,7 @@
|
||||
"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",
|
||||
"peer": true
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
@@ -13031,38 +12998,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==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"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",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
@@ -13183,7 +13118,6 @@
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -13477,7 +13411,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -13507,7 +13440,6 @@
|
||||
"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",
|
||||
@@ -13556,7 +13488,6 @@
|
||||
"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",
|
||||
@@ -13760,7 +13691,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -13830,7 +13760,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -13881,7 +13810,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -13923,8 +13851,7 @@
|
||||
"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",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0",
|
||||
@@ -14232,7 +14159,6 @@
|
||||
"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"
|
||||
@@ -14255,8 +14181,7 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/recharts/node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
@@ -15314,8 +15239,7 @@
|
||||
"version": "0.180.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/three-mesh-bvh": {
|
||||
"version": "0.8.3",
|
||||
@@ -15403,7 +15327,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -15752,7 +15675,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
||||
Reference in New Issue
Block a user