Compare commits

..

No commits in common. "32f358c7207be482f99cc860bfbb02e4bbae871b" and "2690a3fe66a1cb82a68600205c00acbdfd87de75" have entirely different histories.

20 changed files with 37 additions and 11212 deletions

View File

@ -144,8 +144,6 @@ import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트
import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력 import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력
import moldRoutes from "./routes/moldRoutes"; // 금형 관리 import moldRoutes from "./routes/moldRoutes"; // 금형 관리
import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 관리 import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 관리
import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시 관리
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
import { BatchSchedulerService } from "./services/batchSchedulerService"; import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -339,8 +337,6 @@ app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작
app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력 app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력
app.use("/api/mold", moldRoutes); // 금형 관리 app.use("/api/mold", moldRoutes); // 금형 관리
app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리 app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리
app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리
app.use("/api/design", designRoutes); // 설계 모듈
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리

View File

@ -1,946 +0,0 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query, getPool } from "../database/db";
import { logger } from "../utils/logger";
// 회사코드 필터 조건 생성 헬퍼
function companyFilter(companyCode: string, paramIndex: number, alias?: string): { condition: string; param: string; nextIndex: number } {
const col = alias ? `${alias}.company_code` : "company_code";
if (companyCode === "*") {
return { condition: "", param: "", nextIndex: paramIndex };
}
return { condition: `${col} = $${paramIndex}`, param: companyCode, nextIndex: paramIndex + 1 };
}
// ============================================
// 설계의뢰/설변요청 (DR/ECR) CRUD
// ============================================
export async function getDesignRequestList(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { source_type, status, priority, search } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let pi = 1;
if (companyCode !== "*") {
conditions.push(`r.company_code = $${pi}`);
params.push(companyCode);
pi++;
}
if (source_type) { conditions.push(`r.source_type = $${pi}`); params.push(source_type); pi++; }
if (status) { conditions.push(`r.status = $${pi}`); params.push(status); pi++; }
if (priority) { conditions.push(`r.priority = $${pi}`); params.push(priority); pi++; }
if (search) {
conditions.push(`(r.target_name ILIKE $${pi} OR r.request_no ILIKE $${pi} OR r.requester ILIKE $${pi})`);
params.push(`%${search}%`);
pi++;
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const sql = `
SELECT r.*,
COALESCE(json_agg(json_build_object('id', h.id, 'step', h.step, 'history_date', h.history_date, 'user_name', h.user_name, 'description', h.description)) FILTER (WHERE h.id IS NOT NULL), '[]') AS history,
COALESCE((SELECT json_agg(i.impact_type) FROM dsn_request_impact i WHERE i.request_id = r.id), '[]') AS impact
FROM dsn_design_request r
LEFT JOIN dsn_request_history h ON h.request_id = r.id
${where}
GROUP BY r.id
ORDER BY r.created_date DESC
`;
const result = await query(sql, params);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("설계의뢰 목록 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function getDesignRequestDetail(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { id } = req.params;
const conditions = [`r.id = $1`];
const params: any[] = [id];
if (companyCode !== "*") { conditions.push(`r.company_code = $2`); params.push(companyCode); }
const sql = `
SELECT r.*,
COALESCE((SELECT json_agg(json_build_object('id', h.id, 'step', h.step, 'history_date', h.history_date, 'user_name', h.user_name, 'description', h.description) ORDER BY h.created_date) FROM dsn_request_history h WHERE h.request_id = r.id), '[]') AS history,
COALESCE((SELECT json_agg(i.impact_type) FROM dsn_request_impact i WHERE i.request_id = r.id), '[]') AS impact
FROM dsn_design_request r
WHERE ${conditions.join(" AND ")}
`;
const result = await query(sql, params);
if (!result.length) { res.status(404).json({ success: false, message: "의뢰를 찾을 수 없습니다." }); return; }
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("설계의뢰 상세 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createDesignRequest(req: AuthenticatedRequest, res: Response): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const {
request_no, source_type, request_date, due_date, priority, status,
target_name, customer, req_dept, requester, designer, order_no,
design_type, spec, change_type, drawing_no, urgency, reason,
content, apply_timing, review_memo, project_id, ecn_no,
impact, history,
} = req.body;
const sql = `
INSERT INTO dsn_design_request (
request_no, source_type, request_date, due_date, priority, status,
target_name, customer, req_dept, requester, designer, order_no,
design_type, spec, change_type, drawing_no, urgency, reason,
content, apply_timing, review_memo, project_id, ecn_no,
writer, company_code
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25)
RETURNING *
`;
const result = await client.query(sql, [
request_no, source_type || "dr", request_date, due_date, priority || "보통", status || "신규접수",
target_name, customer, req_dept, requester, designer, order_no,
design_type, spec, change_type, drawing_no, urgency || "보통", reason,
content, apply_timing, review_memo, project_id, ecn_no,
userId, companyCode,
]);
const requestId = result.rows[0].id;
if (impact?.length) {
for (const imp of impact) {
await client.query(
`INSERT INTO dsn_request_impact (request_id, impact_type, writer, company_code) VALUES ($1,$2,$3,$4)`,
[requestId, imp, userId, companyCode]
);
}
}
if (history?.length) {
for (const h of history) {
await client.query(
`INSERT INTO dsn_request_history (request_id, step, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[requestId, h.step, h.history_date, h.user_name, h.description, userId, companyCode]
);
}
}
await client.query("COMMIT");
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("설계의뢰 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
export async function updateDesignRequest(req: AuthenticatedRequest, res: Response): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { id } = req.params;
const {
request_no, source_type, request_date, due_date, priority, status, approval_step,
target_name, customer, req_dept, requester, designer, order_no,
design_type, spec, change_type, drawing_no, urgency, reason,
content, apply_timing, review_memo, project_id, ecn_no,
impact, history,
} = req.body;
const conditions = [`id = $1`];
const params: any[] = [id];
let pi = 2;
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
const setClauses = [];
const setParams: any[] = [];
const fields: Record<string, any> = {
request_no, source_type, request_date, due_date, priority, status, approval_step,
target_name, customer, req_dept, requester, designer, order_no,
design_type, spec, change_type, drawing_no, urgency, reason,
content, apply_timing, review_memo, project_id, ecn_no,
};
for (const [key, val] of Object.entries(fields)) {
if (val !== undefined) {
setClauses.push(`${key} = $${pi}`);
setParams.push(val);
pi++;
}
}
setClauses.push(`updated_date = now()`);
const sql = `UPDATE dsn_design_request SET ${setClauses.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`;
const result = await client.query(sql, [...params, ...setParams]);
if (!result.rowCount) { await client.query("ROLLBACK"); res.status(404).json({ success: false, message: "의뢰를 찾을 수 없습니다." }); return; }
if (impact !== undefined) {
await client.query(`DELETE FROM dsn_request_impact WHERE request_id = $1`, [id]);
for (const imp of impact) {
await client.query(
`INSERT INTO dsn_request_impact (request_id, impact_type, writer, company_code) VALUES ($1,$2,$3,$4)`,
[id, imp, userId, companyCode]
);
}
}
if (history !== undefined) {
await client.query(`DELETE FROM dsn_request_history WHERE request_id = $1`, [id]);
for (const h of history) {
await client.query(
`INSERT INTO dsn_request_history (request_id, step, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[id, h.step, h.history_date, h.user_name, h.description, userId, companyCode]
);
}
}
await client.query("COMMIT");
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("설계의뢰 수정 오류", error);
res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
export async function deleteDesignRequest(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { id } = req.params;
const conditions = [`id = $1`];
const params: any[] = [id];
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
const sql = `DELETE FROM dsn_design_request WHERE ${conditions.join(" AND ")} RETURNING id`;
const result = await query(sql, params);
if (!result.length) { res.status(404).json({ success: false, message: "의뢰를 찾을 수 없습니다." }); return; }
res.json({ success: true });
} catch (error: any) {
logger.error("설계의뢰 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// 이력 추가 (단건)
export async function addRequestHistory(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { id } = req.params;
const { step, history_date, user_name, description } = req.body;
const sql = `INSERT INTO dsn_request_history (request_id, step, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`;
const result = await query(sql, [id, step, history_date, user_name, description, userId, companyCode]);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("의뢰 이력 추가 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 설계 프로젝트 CRUD
// ============================================
export async function getProjectList(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { status, search } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let pi = 1;
if (companyCode !== "*") { conditions.push(`p.company_code = $${pi}`); params.push(companyCode); pi++; }
if (status) { conditions.push(`p.status = $${pi}`); params.push(status); pi++; }
if (search) {
conditions.push(`(p.name ILIKE $${pi} OR p.project_no ILIKE $${pi} OR p.customer ILIKE $${pi})`);
params.push(`%${search}%`);
pi++;
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const sql = `
SELECT p.*,
COALESCE(
(SELECT json_agg(json_build_object(
'id', t.id, 'name', t.name, 'category', t.category, 'assignee', t.assignee,
'start_date', t.start_date, 'end_date', t.end_date, 'status', t.status,
'progress', t.progress, 'priority', t.priority, 'remark', t.remark, 'sort_order', t.sort_order
) ORDER BY t.sort_order, t.start_date)
FROM dsn_project_task t WHERE t.project_id = p.id), '[]'
) AS tasks
FROM dsn_project p
${where}
ORDER BY p.created_date DESC
`;
const result = await query(sql, params);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("프로젝트 목록 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function getProjectDetail(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { id } = req.params;
const conditions = [`p.id = $1`];
const params: any[] = [id];
if (companyCode !== "*") { conditions.push(`p.company_code = $2`); params.push(companyCode); }
const sql = `
SELECT p.*,
COALESCE(
(SELECT json_agg(json_build_object(
'id', t.id, 'name', t.name, 'category', t.category, 'assignee', t.assignee,
'start_date', t.start_date, 'end_date', t.end_date, 'status', t.status,
'progress', t.progress, 'priority', t.priority, 'remark', t.remark, 'sort_order', t.sort_order
) ORDER BY t.sort_order, t.start_date)
FROM dsn_project_task t WHERE t.project_id = p.id), '[]'
) AS tasks
FROM dsn_project p
WHERE ${conditions.join(" AND ")}
`;
const result = await query(sql, params);
if (!result.length) { res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." }); return; }
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("프로젝트 상세 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createProject(req: AuthenticatedRequest, res: Response): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { project_no, name, status: pStatus, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type, tasks } = req.body;
const result = await client.query(
`INSERT INTO dsn_project (project_no, name, status, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type, writer, company_code)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`,
[project_no, name, pStatus || "계획", pm, customer, start_date, end_date, source_no, description, progress || "0", parent_id, relation_type, userId, companyCode]
);
const projectId = result.rows[0].id;
if (tasks?.length) {
for (let i = 0; i < tasks.length; i++) {
const t = tasks[i];
await client.query(
`INSERT INTO dsn_project_task (project_id, name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order, writer, company_code)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
[projectId, t.name, t.category, t.assignee, t.start_date, t.end_date, t.status || "대기", t.progress || "0", t.priority || "보통", t.remark, String(i), userId, companyCode]
);
}
}
await client.query("COMMIT");
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("프로젝트 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
export async function updateProject(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { id } = req.params;
const { project_no, name, status: pStatus, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type } = req.body;
const conditions = [`id = $1`];
const params: any[] = [id];
let pi = 2;
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
const sets: string[] = [];
const fields: Record<string, any> = { project_no, name, status: pStatus, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type };
for (const [key, val] of Object.entries(fields)) {
if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; }
}
sets.push(`updated_date = now()`);
const result = await query(`UPDATE dsn_project SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params);
if (!result.length) { res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." }); return; }
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("프로젝트 수정 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteProject(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { id } = req.params;
const conditions = [`id = $1`];
const params: any[] = [id];
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
const result = await query(`DELETE FROM dsn_project WHERE ${conditions.join(" AND ")} RETURNING id`, params);
if (!result.length) { res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." }); return; }
res.json({ success: true });
} catch (error: any) {
logger.error("프로젝트 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 프로젝트 태스크 CRUD
// ============================================
export async function getTasksByProject(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { projectId } = req.params;
const conditions = [`t.project_id = $1`];
const params: any[] = [projectId];
if (companyCode !== "*") { conditions.push(`t.company_code = $2`); params.push(companyCode); }
const sql = `
SELECT t.*,
COALESCE((SELECT json_agg(json_build_object('id', w.id, 'start_dt', w.start_dt, 'end_dt', w.end_dt, 'hours', w.hours, 'description', w.description, 'progress_before', w.progress_before, 'progress_after', w.progress_after, 'author', w.author, 'sub_item_id', w.sub_item_id) ORDER BY w.start_dt) FROM dsn_work_log w WHERE w.task_id = t.id), '[]') AS work_logs,
COALESCE((SELECT json_agg(json_build_object('id', i.id, 'title', i.title, 'status', i.status, 'priority', i.priority, 'description', i.description, 'registered_by', i.registered_by, 'registered_date', i.registered_date, 'resolved_date', i.resolved_date)) FROM dsn_task_issue i WHERE i.task_id = t.id), '[]') AS issues,
COALESCE((SELECT json_agg(json_build_object('id', s.id, 'name', s.name, 'weight', s.weight, 'progress', s.progress, 'status', s.status) ORDER BY s.created_date) FROM dsn_task_sub_item s WHERE s.task_id = t.id), '[]') AS sub_items
FROM dsn_project_task t
WHERE ${conditions.join(" AND ")}
ORDER BY t.sort_order, t.start_date
`;
const result = await query(sql, params);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("태스크 목록 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createTask(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { projectId } = req.params;
const { name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order } = req.body;
const result = await query(
`INSERT INTO dsn_project_task (project_id, name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order, writer, company_code)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING *`,
[projectId, name, category, assignee, start_date, end_date, status || "대기", progress || "0", priority || "보통", remark, sort_order || "0", userId, companyCode]
);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("태스크 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function updateTask(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { taskId } = req.params;
const { name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order } = req.body;
const conditions = [`id = $1`];
const params: any[] = [taskId];
let pi = 2;
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
const sets: string[] = [];
const fields: Record<string, any> = { name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order };
for (const [key, val] of Object.entries(fields)) {
if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; }
}
sets.push(`updated_date = now()`);
const result = await query(`UPDATE dsn_project_task SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params);
if (!result.length) { res.status(404).json({ success: false, message: "태스크를 찾을 수 없습니다." }); return; }
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("태스크 수정 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteTask(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { taskId } = req.params;
const conditions = [`id = $1`];
const params: any[] = [taskId];
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
const result = await query(`DELETE FROM dsn_project_task WHERE ${conditions.join(" AND ")} RETURNING id`, params);
if (!result.length) { res.status(404).json({ success: false, message: "태스크를 찾을 수 없습니다." }); return; }
res.json({ success: true });
} catch (error: any) {
logger.error("태스크 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 작업일지 CRUD
// ============================================
export async function getWorkLogsByTask(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { taskId } = req.params;
const conditions = [`w.task_id = $1`];
const params: any[] = [taskId];
if (companyCode !== "*") { conditions.push(`w.company_code = $2`); params.push(companyCode); }
const sql = `
SELECT w.*,
COALESCE((SELECT json_agg(json_build_object('id', a.id, 'file_name', a.file_name, 'file_type', a.file_type, 'file_size', a.file_size)) FROM dsn_work_attachment a WHERE a.work_log_id = w.id), '[]') AS attachments,
COALESCE((SELECT json_agg(json_build_object('id', p.id, 'item', p.item, 'qty', p.qty, 'unit', p.unit, 'reason', p.reason, 'status', p.status)) FROM dsn_purchase_req p WHERE p.work_log_id = w.id), '[]') AS purchase_reqs,
COALESCE((SELECT json_agg(json_build_object(
'id', c.id, 'to_user', c.to_user, 'to_dept', c.to_dept, 'title', c.title, 'description', c.description, 'status', c.status, 'due_date', c.due_date,
'responses', COALESCE((SELECT json_agg(json_build_object('id', cr.id, 'response_date', cr.response_date, 'user_name', cr.user_name, 'content', cr.content)) FROM dsn_coop_response cr WHERE cr.coop_req_id = c.id), '[]')
)) FROM dsn_coop_req c WHERE c.work_log_id = w.id), '[]') AS coop_reqs
FROM dsn_work_log w
WHERE ${conditions.join(" AND ")}
ORDER BY w.start_dt DESC
`;
const result = await query(sql, params);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("작업일지 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createWorkLog(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { taskId } = req.params;
const { start_dt, end_dt, hours, description, progress_before, progress_after, author, sub_item_id } = req.body;
const result = await query(
`INSERT INTO dsn_work_log (task_id, start_dt, end_dt, hours, description, progress_before, progress_after, author, sub_item_id, writer, company_code)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`,
[taskId, start_dt, end_dt, hours || "0", description, progress_before || "0", progress_after || "0", author, sub_item_id, userId, companyCode]
);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("작업일지 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteWorkLog(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { workLogId } = req.params;
const conditions = [`id = $1`];
const params: any[] = [workLogId];
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
const result = await query(`DELETE FROM dsn_work_log WHERE ${conditions.join(" AND ")} RETURNING id`, params);
if (!result.length) { res.status(404).json({ success: false, message: "작업일지를 찾을 수 없습니다." }); return; }
res.json({ success: true });
} catch (error: any) {
logger.error("작업일지 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 태스크 하위항목 CRUD
// ============================================
export async function createSubItem(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { taskId } = req.params;
const { name, weight, progress, status } = req.body;
const result = await query(
`INSERT INTO dsn_task_sub_item (task_id, name, weight, progress, status, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
[taskId, name, weight || "0", progress || "0", status || "대기", userId, companyCode]
);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("하위항목 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function updateSubItem(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { subItemId } = req.params;
const { name, weight, progress, status } = req.body;
const conditions = [`id = $1`];
const params: any[] = [subItemId];
let pi = 2;
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
const sets: string[] = [];
const fields: Record<string, any> = { name, weight, progress, status };
for (const [key, val] of Object.entries(fields)) {
if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; }
}
sets.push(`updated_date = now()`);
const result = await query(`UPDATE dsn_task_sub_item SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params);
if (!result.length) { res.status(404).json({ success: false, message: "하위항목을 찾을 수 없습니다." }); return; }
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("하위항목 수정 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteSubItem(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { subItemId } = req.params;
const conditions = [`id = $1`];
const params: any[] = [subItemId];
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
const result = await query(`DELETE FROM dsn_task_sub_item WHERE ${conditions.join(" AND ")} RETURNING id`, params);
if (!result.length) { res.status(404).json({ success: false, message: "하위항목을 찾을 수 없습니다." }); return; }
res.json({ success: true });
} catch (error: any) {
logger.error("하위항목 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 태스크 이슈 CRUD
// ============================================
export async function createIssue(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { taskId } = req.params;
const { title, status, priority, description, registered_by, registered_date } = req.body;
const result = await query(
`INSERT INTO dsn_task_issue (task_id, title, status, priority, description, registered_by, registered_date, writer, company_code)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`,
[taskId, title, status || "등록", priority || "보통", description, registered_by, registered_date, userId, companyCode]
);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("이슈 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function updateIssue(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { issueId } = req.params;
const { title, status, priority, description, resolved_date } = req.body;
const conditions = [`id = $1`];
const params: any[] = [issueId];
let pi = 2;
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
const sets: string[] = [];
const fields: Record<string, any> = { title, status, priority, description, resolved_date };
for (const [key, val] of Object.entries(fields)) {
if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; }
}
sets.push(`updated_date = now()`);
const result = await query(`UPDATE dsn_task_issue SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params);
if (!result.length) { res.status(404).json({ success: false, message: "이슈를 찾을 수 없습니다." }); return; }
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("이슈 수정 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// ECN (설변통보) CRUD
// ============================================
export async function getEcnList(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { status, search } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let pi = 1;
if (companyCode !== "*") { conditions.push(`e.company_code = $${pi}`); params.push(companyCode); pi++; }
if (status) { conditions.push(`e.status = $${pi}`); params.push(status); pi++; }
if (search) {
conditions.push(`(e.ecn_no ILIKE $${pi} OR e.target ILIKE $${pi})`);
params.push(`%${search}%`);
pi++;
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const sql = `
SELECT e.*,
COALESCE((SELECT json_agg(json_build_object('id', h.id, 'status', h.status, 'history_date', h.history_date, 'user_name', h.user_name, 'description', h.description) ORDER BY h.created_date) FROM dsn_ecn_history h WHERE h.ecn_id = e.id), '[]') AS history,
COALESCE((SELECT json_agg(nd.dept_name) FROM dsn_ecn_notify_dept nd WHERE nd.ecn_id = e.id), '[]') AS notify_depts
FROM dsn_ecn e
${where}
ORDER BY e.created_date DESC
`;
const result = await query(sql, params);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("ECN 목록 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createEcn(req: AuthenticatedRequest, res: Response): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { ecn_no, ecr_id, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, notify_depts, history } = req.body;
const result = await client.query(
`INSERT INTO dsn_ecn (ecn_no, ecr_id, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, writer, company_code)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`,
[ecn_no, ecr_id, ecn_date, apply_date, status || "ECN발행", target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, userId, companyCode]
);
const ecnId = result.rows[0].id;
if (notify_depts?.length) {
for (const dept of notify_depts) {
await client.query(`INSERT INTO dsn_ecn_notify_dept (ecn_id, dept_name, writer, company_code) VALUES ($1,$2,$3,$4)`, [ecnId, dept, userId, companyCode]);
}
}
if (history?.length) {
for (const h of history) {
await client.query(
`INSERT INTO dsn_ecn_history (ecn_id, status, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[ecnId, h.status, h.history_date, h.user_name, h.description, userId, companyCode]
);
}
}
await client.query("COMMIT");
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("ECN 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
export async function updateEcn(req: AuthenticatedRequest, res: Response): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { id } = req.params;
const { ecn_no, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, notify_depts, history } = req.body;
const conditions = [`id = $1`];
const params: any[] = [id];
let pi = 2;
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
const sets: string[] = [];
const fields: Record<string, any> = { ecn_no, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark };
for (const [key, val] of Object.entries(fields)) {
if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; }
}
sets.push(`updated_date = now()`);
const result = await client.query(`UPDATE dsn_ecn SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params);
if (!result.rowCount) { await client.query("ROLLBACK"); res.status(404).json({ success: false, message: "ECN을 찾을 수 없습니다." }); return; }
if (notify_depts !== undefined) {
await client.query(`DELETE FROM dsn_ecn_notify_dept WHERE ecn_id = $1`, [id]);
for (const dept of notify_depts) {
await client.query(`INSERT INTO dsn_ecn_notify_dept (ecn_id, dept_name, writer, company_code) VALUES ($1,$2,$3,$4)`, [id, dept, userId, companyCode]);
}
}
if (history !== undefined) {
await client.query(`DELETE FROM dsn_ecn_history WHERE ecn_id = $1`, [id]);
for (const h of history) {
await client.query(
`INSERT INTO dsn_ecn_history (ecn_id, status, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[id, h.status, h.history_date, h.user_name, h.description, userId, companyCode]
);
}
}
await client.query("COMMIT");
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("ECN 수정 오류", error);
res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
export async function deleteEcn(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { id } = req.params;
const conditions = [`id = $1`];
const params: any[] = [id];
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
const result = await query(`DELETE FROM dsn_ecn WHERE ${conditions.join(" AND ")} RETURNING id`, params);
if (!result.length) { res.status(404).json({ success: false, message: "ECN을 찾을 수 없습니다." }); return; }
res.json({ success: true });
} catch (error: any) {
logger.error("ECN 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 나의 업무 (My Work) - 로그인 사용자 기준
// ============================================
export async function getMyWork(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userName = req.user!.userName;
const { status, project_id } = req.query;
const conditions = [`t.assignee = $1`];
const params: any[] = [userName];
let pi = 2;
if (companyCode !== "*") { conditions.push(`t.company_code = $${pi}`); params.push(companyCode); pi++; }
if (status) { conditions.push(`t.status = $${pi}`); params.push(status); pi++; }
if (project_id) { conditions.push(`t.project_id = $${pi}`); params.push(project_id); pi++; }
const sql = `
SELECT t.*,
p.project_no, p.name AS project_name, p.customer AS project_customer, p.status AS project_status,
COALESCE((SELECT json_agg(json_build_object('id', s.id, 'name', s.name, 'weight', s.weight, 'progress', s.progress, 'status', s.status) ORDER BY s.created_date) FROM dsn_task_sub_item s WHERE s.task_id = t.id), '[]') AS sub_items,
COALESCE((SELECT json_agg(json_build_object(
'id', w.id, 'start_dt', w.start_dt, 'end_dt', w.end_dt, 'hours', w.hours, 'description', w.description, 'sub_item_id', w.sub_item_id,
'attachments', COALESCE((SELECT json_agg(json_build_object('id', a.id, 'file_name', a.file_name, 'file_type', a.file_type, 'file_size', a.file_size)) FROM dsn_work_attachment a WHERE a.work_log_id = w.id), '[]'),
'purchase_reqs', COALESCE((SELECT json_agg(json_build_object('id', pr.id, 'item', pr.item, 'qty', pr.qty, 'unit', pr.unit, 'reason', pr.reason, 'status', pr.status)) FROM dsn_purchase_req pr WHERE pr.work_log_id = w.id), '[]'),
'coop_reqs', COALESCE((SELECT json_agg(json_build_object(
'id', c.id, 'to_user', c.to_user, 'to_dept', c.to_dept, 'title', c.title, 'description', c.description, 'status', c.status, 'due_date', c.due_date,
'responses', COALESCE((SELECT json_agg(json_build_object('id', cr.id, 'response_date', cr.response_date, 'user_name', cr.user_name, 'content', cr.content)) FROM dsn_coop_response cr WHERE cr.coop_req_id = c.id), '[]')
)) FROM dsn_coop_req c WHERE c.work_log_id = w.id), '[]')
) ORDER BY w.start_dt DESC) FROM dsn_work_log w WHERE w.task_id = t.id), '[]') AS work_logs
FROM dsn_project_task t
JOIN dsn_project p ON p.id = t.project_id
WHERE ${conditions.join(" AND ")}
ORDER BY
CASE t.status WHEN '진행중' THEN 1 WHEN '대기' THEN 2 WHEN '검토중' THEN 3 ELSE 4 END,
t.end_date ASC
`;
const result = await query(sql, params);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("나의 업무 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 구매요청 / 협업요청 CRUD (my-work에서 사용)
// ============================================
export async function createPurchaseReq(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { workLogId } = req.params;
const { item, qty, unit, reason, status } = req.body;
const result = await query(
`INSERT INTO dsn_purchase_req (work_log_id, item, qty, unit, reason, status, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
[workLogId, item, qty, unit, reason, status || "요청", userId, companyCode]
);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("구매요청 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createCoopReq(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { workLogId } = req.params;
const { to_user, to_dept, title, description, due_date } = req.body;
const result = await query(
`INSERT INTO dsn_coop_req (work_log_id, to_user, to_dept, title, description, status, due_date, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`,
[workLogId, to_user, to_dept, title, description, "요청", due_date, userId, companyCode]
);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("협업요청 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function addCoopResponse(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { coopReqId } = req.params;
const { response_date, user_name, content } = req.body;
const result = await query(
`INSERT INTO dsn_coop_response (coop_req_id, response_date, user_name, content, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
[coopReqId, response_date, user_name, content, userId, companyCode]
);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("협업응답 추가 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}

View File

@ -1,482 +0,0 @@
/**
* (shipment_instruction + shipment_instruction_detail)
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { numberingRuleService } from "../services/numberingRuleService";
// ─── 출하지시 목록 조회 ───
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { dateFrom, dateTo, status, customer, keyword } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
if (companyCode !== "*") {
conditions.push(`si.company_code = $${idx}`);
params.push(companyCode);
idx++;
}
if (dateFrom) {
conditions.push(`si.instruction_date >= $${idx}::date`);
params.push(dateFrom);
idx++;
}
if (dateTo) {
conditions.push(`si.instruction_date <= $${idx}::date`);
params.push(dateTo);
idx++;
}
if (status) {
conditions.push(`si.status = $${idx}`);
params.push(status);
idx++;
}
if (customer) {
conditions.push(`(c.customer_name ILIKE $${idx} OR si.partner_id ILIKE $${idx})`);
params.push(`%${customer}%`);
idx++;
}
if (keyword) {
conditions.push(`(si.instruction_no ILIKE $${idx} OR si.memo ILIKE $${idx})`);
params.push(`%${keyword}%`);
idx++;
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT
si.*,
COALESCE(c.customer_name, si.partner_id, '') AS customer_name,
COALESCE(
json_agg(
json_build_object(
'id', sid.id,
'item_code', sid.item_code,
'item_name', COALESCE(i.item_name, sid.item_name, sid.item_code),
'spec', sid.spec,
'material', sid.material,
'order_qty', sid.order_qty,
'plan_qty', sid.plan_qty,
'ship_qty', sid.ship_qty,
'source_type', sid.source_type,
'shipment_plan_id', sid.shipment_plan_id,
'sales_order_id', sid.sales_order_id,
'detail_id', sid.detail_id
)
) FILTER (WHERE sid.id IS NOT NULL),
'[]'
) AS items
FROM shipment_instruction si
LEFT JOIN customer_mng c
ON si.partner_id = c.customer_code AND si.company_code = c.company_code
LEFT JOIN shipment_instruction_detail sid
ON si.id = sid.instruction_id AND si.company_code = sid.company_code
LEFT JOIN LATERAL (
SELECT item_name FROM item_info
WHERE item_number = sid.item_code AND company_code = si.company_code
LIMIT 1
) i ON true
${where}
GROUP BY si.id, c.customer_name
ORDER BY si.created_date DESC
`;
const pool = getPool();
const result = await pool.query(query, params);
logger.info("출하지시 목록 조회", { companyCode, count: result.rowCount });
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("출하지시 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 다음 출하지시번호 미리보기 ───
export async function previewNextNo(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
let instructionNo: string;
try {
const rule = await numberingRuleService.getNumberingRuleByColumn(
companyCode, "shipment_instruction", "instruction_no"
);
if (rule) {
instructionNo = await numberingRuleService.previewCode(
rule.ruleId, companyCode, {}
);
} else {
throw new Error("채번 규칙 없음");
}
} catch {
const pool = getPool();
const today = new Date().toISOString().split("T")[0].replace(/-/g, "");
const seqRes = await pool.query(
`SELECT COUNT(*) + 1 AS seq FROM shipment_instruction WHERE company_code = $1 AND instruction_no LIKE $2`,
[companyCode, `SI-${today}-%`]
);
const seq = String(seqRes.rows[0].seq).padStart(3, "0");
instructionNo = `SI-${today}-${seq}`;
}
return res.json({ success: true, instructionNo });
} catch (error: any) {
logger.error("출하지시번호 미리보기 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 출하지시 저장 (신규/수정) ───
export async function save(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const {
id: editId,
instructionDate,
partnerId,
status: orderStatus,
memo,
carrierName,
vehicleNo,
driverName,
driverContact,
arrivalTime,
deliveryAddress,
items,
} = req.body;
if (!instructionDate) {
return res.status(400).json({ success: false, message: "출하지시일은 필수입니다" });
}
if (!items || items.length === 0) {
return res.status(400).json({ success: false, message: "품목을 선택해주세요" });
}
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
let instructionId: number;
let instructionNo: string;
if (editId) {
// 수정
const check = await client.query(
`SELECT id, instruction_no FROM shipment_instruction WHERE id = $1 AND company_code = $2`,
[editId, companyCode]
);
if (check.rowCount === 0) {
throw new Error("출하지시를 찾을 수 없습니다");
}
instructionId = editId;
instructionNo = check.rows[0].instruction_no;
await client.query(
`UPDATE shipment_instruction SET
instruction_date = $1::date, partner_id = $2, status = $3, memo = $4,
carrier_name = $5, vehicle_no = $6, driver_name = $7, driver_contact = $8,
arrival_time = $9, delivery_address = $10,
updated_date = NOW(), updated_by = $11
WHERE id = $12 AND company_code = $13`,
[
instructionDate, partnerId, orderStatus || "READY", memo,
carrierName, vehicleNo, driverName, driverContact,
arrivalTime || null, deliveryAddress,
userId, editId, companyCode,
]
);
// 기존 디테일 삭제 후 재삽입
await client.query(
`DELETE FROM shipment_instruction_detail WHERE instruction_id = $1 AND company_code = $2`,
[editId, companyCode]
);
} else {
// 신규 - 채번 규칙이 있으면 사용, 없으면 자체 생성
try {
const rule = await numberingRuleService.getNumberingRuleByColumn(
companyCode, "shipment_instruction", "instruction_no"
);
if (rule) {
instructionNo = await numberingRuleService.allocateCode(
rule.ruleId, companyCode, { instruction_date: instructionDate }
);
logger.info("채번 규칙으로 출하지시번호 생성", { ruleId: rule.ruleId, instructionNo });
} else {
throw new Error("채번 규칙 없음 - 폴백");
}
} catch {
const today = new Date().toISOString().split("T")[0].replace(/-/g, "");
const seqRes = await client.query(
`SELECT COUNT(*) + 1 AS seq FROM shipment_instruction WHERE company_code = $1 AND instruction_no LIKE $2`,
[companyCode, `SI-${today}-%`]
);
const seq = String(seqRes.rows[0].seq).padStart(3, "0");
instructionNo = `SI-${today}-${seq}`;
logger.info("폴백으로 출하지시번호 생성", { instructionNo });
}
const insertRes = await client.query(
`INSERT INTO shipment_instruction
(company_code, instruction_no, instruction_date, partner_id, status, memo,
carrier_name, vehicle_no, driver_name, driver_contact, arrival_time, delivery_address,
created_date, created_by)
VALUES ($1, $2, $3::date, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), $13)
RETURNING id`,
[
companyCode, instructionNo, instructionDate, partnerId,
orderStatus || "READY", memo,
carrierName, vehicleNo, driverName, driverContact,
arrivalTime || null, deliveryAddress, userId,
]
);
instructionId = insertRes.rows[0].id;
}
// 디테일 삽입
for (const item of items) {
await client.query(
`INSERT INTO shipment_instruction_detail
(company_code, instruction_id, shipment_plan_id, sales_order_id, detail_id,
item_code, item_name, spec, material, order_qty, plan_qty, ship_qty,
source_type, created_date, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), $14)`,
[
companyCode, instructionId,
item.shipmentPlanId || null, item.salesOrderId || null, item.detailId || null,
item.itemCode, item.itemName, item.spec, item.material,
item.orderQty || 0, item.planQty || 0, item.shipQty || 0,
item.sourceType || "shipmentPlan", userId,
]
);
}
await client.query("COMMIT");
logger.info("출하지시 저장 완료", { companyCode, instructionId, instructionNo, itemCount: items.length });
return res.json({ success: true, data: { id: instructionId, instructionNo } });
} catch (txErr) {
await client.query("ROLLBACK");
throw txErr;
} finally {
client.release();
}
} catch (error: any) {
logger.error("출하지시 저장 실패", { error: error.message, stack: error.stack });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 출하지시 삭제 ───
export async function remove(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { ids } = req.body;
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({ success: false, message: "삭제할 ID가 필요합니다" });
}
const pool = getPool();
// CASCADE로 디테일도 자동 삭제
const result = await pool.query(
`DELETE FROM shipment_instruction WHERE id = ANY($1::int[]) AND company_code = $2 RETURNING id`,
[ids, companyCode]
);
logger.info("출하지시 삭제", { companyCode, deletedCount: result.rowCount });
return res.json({ success: true, deletedCount: result.rowCount });
} catch (error: any) {
logger.error("출하지시 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 출하계획 목록 (모달 왼쪽 패널용) ───
export async function getShipmentPlanSource(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword, customer, page: pageStr, pageSize: pageSizeStr } = req.query;
const page = Math.max(1, parseInt(pageStr as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20));
const offset = (page - 1) * pageSize;
const conditions = ["sp.company_code = $1", "sp.status = 'READY'"];
const params: any[] = [companyCode];
let idx = 2;
if (keyword) {
conditions.push(`(COALESCE(d.part_code, m.part_code, '') ILIKE $${idx} OR COALESCE(i.item_name, d.part_name, m.part_name, '') ILIKE $${idx})`);
params.push(`%${keyword}%`);
idx++;
}
if (customer) {
conditions.push(`(c.customer_name ILIKE $${idx} OR COALESCE(m.partner_id, d.delivery_partner_code, '') ILIKE $${idx})`);
params.push(`%${customer}%`);
idx++;
}
const whereClause = conditions.join(" AND ");
const fromClause = `
FROM shipment_plan sp
LEFT JOIN sales_order_detail d ON sp.detail_id = d.id AND sp.company_code = d.company_code
LEFT JOIN sales_order_mng m ON sp.sales_order_id = m.id AND sp.company_code = m.company_code
LEFT JOIN LATERAL (
SELECT item_name FROM item_info
WHERE item_number = COALESCE(d.part_code, m.part_code) AND company_code = sp.company_code
LIMIT 1
) i ON true
LEFT JOIN customer_mng c
ON COALESCE(m.partner_id, d.delivery_partner_code) = c.customer_code AND sp.company_code = c.company_code
WHERE ${whereClause}
`;
const pool = getPool();
const countResult = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params);
const totalCount = parseInt(countResult.rows[0].total);
const query = `
SELECT
sp.id, sp.plan_qty, sp.plan_date, sp.status, sp.shipment_plan_no,
COALESCE(m.order_no, d.order_no, '') AS order_no,
COALESCE(d.part_code, m.part_code, '') AS item_code,
COALESCE(i.item_name, d.part_name, m.part_name, COALESCE(d.part_code, m.part_code, '')) AS item_name,
COALESCE(d.spec, m.spec, '') AS spec,
COALESCE(m.material, '') AS material,
COALESCE(c.customer_name, m.partner_id, d.delivery_partner_code, '') AS customer_name,
COALESCE(m.partner_id, d.delivery_partner_code, '') AS partner_code,
sp.detail_id, sp.sales_order_id
${fromClause}
ORDER BY sp.created_date DESC
LIMIT $${idx} OFFSET $${idx + 1}
`;
params.push(pageSize, offset);
const result = await pool.query(query, params);
return res.json({ success: true, data: result.rows, totalCount, page, pageSize });
} catch (error: any) {
logger.error("출하계획 소스 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 수주 목록 (모달 왼쪽 패널용) ───
export async function getSalesOrderSource(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword, customer, page: pageStr, pageSize: pageSizeStr } = req.query;
const page = Math.max(1, parseInt(pageStr as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20));
const offset = (page - 1) * pageSize;
const conditions = ["d.company_code = $1"];
const params: any[] = [companyCode];
let idx = 2;
if (keyword) {
conditions.push(`(d.part_code ILIKE $${idx} OR COALESCE(i.item_name, d.part_name, d.part_code) ILIKE $${idx} OR d.order_no ILIKE $${idx})`);
params.push(`%${keyword}%`);
idx++;
}
if (customer) {
conditions.push(`(c.customer_name ILIKE $${idx} OR COALESCE(d.delivery_partner_code, m.partner_id, '') ILIKE $${idx})`);
params.push(`%${customer}%`);
idx++;
}
const whereClause = conditions.join(" AND ");
const fromClause = `
FROM sales_order_detail d
LEFT JOIN sales_order_mng m ON d.order_no = m.order_no AND d.company_code = m.company_code
LEFT JOIN LATERAL (
SELECT item_name FROM item_info
WHERE item_number = d.part_code AND company_code = d.company_code
LIMIT 1
) i ON true
LEFT JOIN customer_mng c
ON COALESCE(d.delivery_partner_code, m.partner_id) = c.customer_code AND d.company_code = c.company_code
WHERE ${whereClause}
`;
const pool = getPool();
const countResult = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params);
const totalCount = parseInt(countResult.rows[0].total);
const query = `
SELECT
d.id, d.order_no, d.part_code AS item_code,
COALESCE(i.item_name, d.part_name, d.part_code) AS item_name,
COALESCE(d.spec, '') AS spec, COALESCE(m.material, '') AS material,
COALESCE(NULLIF(d.qty,'')::numeric, 0) AS qty,
COALESCE(NULLIF(d.balance_qty,'')::numeric, 0) AS balance_qty,
COALESCE(c.customer_name, COALESCE(d.delivery_partner_code, m.partner_id, '')) AS customer_name,
COALESCE(d.delivery_partner_code, m.partner_id, '') AS partner_code,
m.id AS master_id
${fromClause}
ORDER BY d.created_date DESC
LIMIT $${idx} OFFSET $${idx + 1}
`;
params.push(pageSize, offset);
const result = await pool.query(query, params);
return res.json({ success: true, data: result.rows, totalCount, page, pageSize });
} catch (error: any) {
logger.error("수주 소스 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 품목 목록 (모달 왼쪽 패널용) ───
export async function getItemSource(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword, page: pageStr, pageSize: pageSizeStr } = req.query;
const page = Math.max(1, parseInt(pageStr as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20));
const offset = (page - 1) * pageSize;
const conditions = ["company_code = $1"];
const params: any[] = [companyCode];
let idx = 2;
if (keyword) {
conditions.push(`(item_number ILIKE $${idx} OR item_name ILIKE $${idx})`);
params.push(`%${keyword}%`);
idx++;
}
const whereClause = conditions.join(" AND ");
const pool = getPool();
const countResult = await pool.query(`SELECT COUNT(*) AS total FROM item_info WHERE ${whereClause}`, params);
const totalCount = parseInt(countResult.rows[0].total);
const query = `
SELECT
item_number AS item_code, item_name,
COALESCE(size, '') AS spec, COALESCE(material, '') AS material
FROM item_info
WHERE ${whereClause}
ORDER BY item_name
LIMIT $${idx} OFFSET $${idx + 1}
`;
params.push(pageSize, offset);
const result = await pool.query(query, params);
return res.json({ success: true, data: result.rows, totalCount, page, pageSize });
} catch (error: any) {
logger.error("품목 소스 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}

View File

@ -144,218 +144,6 @@ async function getNormalizedOrders(
} }
} }
// ─── 출하계획 목록 조회 (관리 화면용) ───
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { dateFrom, dateTo, status, customer, keyword } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
// 멀티테넌시
if (companyCode === "*") {
// 최고 관리자: 전체 조회
} else {
conditions.push(`sp.company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
if (dateFrom) {
conditions.push(`sp.plan_date >= $${paramIndex}::date`);
params.push(dateFrom);
paramIndex++;
}
if (dateTo) {
conditions.push(`sp.plan_date <= $${paramIndex}::date`);
params.push(dateTo);
paramIndex++;
}
if (status) {
conditions.push(`sp.status = $${paramIndex}`);
params.push(status);
paramIndex++;
}
if (customer) {
conditions.push(`(c.customer_name ILIKE $${paramIndex} OR COALESCE(m.partner_id, d.delivery_partner_code, '') ILIKE $${paramIndex})`);
params.push(`%${customer}%`);
paramIndex++;
}
if (keyword) {
conditions.push(`(
COALESCE(m.order_no, d.order_no, '') ILIKE $${paramIndex}
OR COALESCE(d.part_code, m.part_code, '') ILIKE $${paramIndex}
OR COALESCE(i.item_name, d.part_name, m.part_name, '') ILIKE $${paramIndex}
OR sp.shipment_plan_no ILIKE $${paramIndex}
)`);
params.push(`%${keyword}%`);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT
sp.id,
sp.plan_date,
sp.plan_qty,
sp.status,
sp.memo,
sp.shipment_plan_no,
sp.created_date,
sp.created_by,
sp.detail_id,
sp.sales_order_id,
sp.remain_qty,
COALESCE(m.order_no, d.order_no, '') AS order_no,
COALESCE(d.part_code, m.part_code, '') AS part_code,
COALESCE(i.item_name, d.part_name, m.part_name, COALESCE(d.part_code, m.part_code, '')) AS part_name,
COALESCE(d.spec, m.spec, '') AS spec,
COALESCE(m.material, '') AS material,
COALESCE(c.customer_name, m.partner_id, d.delivery_partner_code, '') AS customer_name,
COALESCE(m.partner_id, d.delivery_partner_code, '') AS partner_code,
COALESCE(d.due_date, m.due_date::text, '') AS due_date,
COALESCE(NULLIF(d.qty,'')::numeric, m.order_qty, 0) AS order_qty,
COALESCE(NULLIF(d.ship_qty,'')::numeric, m.ship_qty, 0) AS shipped_qty
FROM shipment_plan sp
LEFT JOIN sales_order_detail d
ON sp.detail_id = d.id AND sp.company_code = d.company_code
LEFT JOIN sales_order_mng m
ON sp.sales_order_id = m.id AND sp.company_code = m.company_code
LEFT JOIN LATERAL (
SELECT item_name FROM item_info
WHERE item_number = COALESCE(d.part_code, m.part_code)
AND company_code = sp.company_code
LIMIT 1
) i ON true
LEFT JOIN customer_mng c
ON COALESCE(m.partner_id, d.delivery_partner_code) = c.customer_code
AND sp.company_code = c.company_code
${whereClause}
ORDER BY sp.created_date DESC
`;
const pool = getPool();
const result = await pool.query(query, params);
logger.info("출하계획 목록 조회", {
companyCode,
rowCount: result.rowCount,
});
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("출하계획 목록 조회 실패", {
error: error.message,
stack: error.stack,
});
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 출하계획 단건 수정 ───
export async function updatePlan(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { id } = req.params;
const { planQty, planDate, memo } = req.body;
const pool = getPool();
const check = await pool.query(
`SELECT id, status FROM shipment_plan WHERE id = $1 AND company_code = $2`,
[id, companyCode]
);
if (check.rowCount === 0) {
return res.status(404).json({ success: false, message: "출하계획을 찾을 수 없습니다" });
}
const setClauses: string[] = [];
const updateParams: any[] = [];
let idx = 1;
if (planQty !== undefined) {
setClauses.push(`plan_qty = $${idx}`);
updateParams.push(planQty);
idx++;
}
if (planDate !== undefined) {
setClauses.push(`plan_date = $${idx}::date`);
updateParams.push(planDate);
idx++;
}
if (memo !== undefined) {
setClauses.push(`memo = $${idx}`);
updateParams.push(memo);
idx++;
}
setClauses.push(`updated_date = NOW()`);
setClauses.push(`updated_by = $${idx}`);
updateParams.push(userId);
idx++;
updateParams.push(id);
updateParams.push(companyCode);
const updateQuery = `
UPDATE shipment_plan
SET ${setClauses.join(", ")}
WHERE id = $${idx - 1} AND company_code = $${idx}
RETURNING *
`;
// 파라미터 인덱스 수정
const finalParams: any[] = [];
let pIdx = 1;
const setClausesFinal: string[] = [];
if (planQty !== undefined) {
setClausesFinal.push(`plan_qty = $${pIdx}`);
finalParams.push(planQty);
pIdx++;
}
if (planDate !== undefined) {
setClausesFinal.push(`plan_date = $${pIdx}::date`);
finalParams.push(planDate);
pIdx++;
}
if (memo !== undefined) {
setClausesFinal.push(`memo = $${pIdx}`);
finalParams.push(memo);
pIdx++;
}
setClausesFinal.push(`updated_date = NOW()`);
setClausesFinal.push(`updated_by = $${pIdx}`);
finalParams.push(userId);
pIdx++;
finalParams.push(id);
finalParams.push(companyCode);
const result = await pool.query(
`UPDATE shipment_plan
SET ${setClausesFinal.join(", ")}
WHERE id = $${pIdx} AND company_code = $${pIdx + 1}
RETURNING *`,
finalParams
);
logger.info("출하계획 수정", { companyCode, planId: id, userId });
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("출하계획 수정 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 품목별 집계 + 기존 출하계획 조회 ─── // ─── 품목별 집계 + 기존 출하계획 조회 ───
export async function getAggregate(req: AuthenticatedRequest, res: Response) { export async function getAggregate(req: AuthenticatedRequest, res: Response) {

View File

@ -1,67 +0,0 @@
import express from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
getDesignRequestList, getDesignRequestDetail, createDesignRequest, updateDesignRequest, deleteDesignRequest, addRequestHistory,
getProjectList, getProjectDetail, createProject, updateProject, deleteProject,
getTasksByProject, createTask, updateTask, deleteTask,
getWorkLogsByTask, createWorkLog, deleteWorkLog,
createSubItem, updateSubItem, deleteSubItem,
createIssue, updateIssue,
getEcnList, createEcn, updateEcn, deleteEcn,
getMyWork,
createPurchaseReq, createCoopReq, addCoopResponse,
} from "../controllers/designController";
const router = express.Router();
router.use(authenticateToken);
// 설계의뢰/설변요청 (DR/ECR)
router.get("/requests", getDesignRequestList);
router.get("/requests/:id", getDesignRequestDetail);
router.post("/requests", createDesignRequest);
router.put("/requests/:id", updateDesignRequest);
router.delete("/requests/:id", deleteDesignRequest);
router.post("/requests/:id/history", addRequestHistory);
// 설계 프로젝트
router.get("/projects", getProjectList);
router.get("/projects/:id", getProjectDetail);
router.post("/projects", createProject);
router.put("/projects/:id", updateProject);
router.delete("/projects/:id", deleteProject);
// 프로젝트 태스크
router.get("/projects/:projectId/tasks", getTasksByProject);
router.post("/projects/:projectId/tasks", createTask);
router.put("/tasks/:taskId", updateTask);
router.delete("/tasks/:taskId", deleteTask);
// 작업일지
router.get("/tasks/:taskId/work-logs", getWorkLogsByTask);
router.post("/tasks/:taskId/work-logs", createWorkLog);
router.delete("/work-logs/:workLogId", deleteWorkLog);
// 태스크 하위항목
router.post("/tasks/:taskId/sub-items", createSubItem);
router.put("/sub-items/:subItemId", updateSubItem);
router.delete("/sub-items/:subItemId", deleteSubItem);
// 태스크 이슈
router.post("/tasks/:taskId/issues", createIssue);
router.put("/issues/:issueId", updateIssue);
// ECN (설변통보)
router.get("/ecn", getEcnList);
router.post("/ecn", createEcn);
router.put("/ecn/:id", updateEcn);
router.delete("/ecn/:id", deleteEcn);
// 나의 업무
router.get("/my-work", getMyWork);
// 구매요청 / 협업요청
router.post("/work-logs/:workLogId/purchase-reqs", createPurchaseReq);
router.post("/work-logs/:workLogId/coop-reqs", createCoopReq);
router.post("/coop-reqs/:coopReqId/responses", addCoopResponse);
export default router;

View File

@ -1,21 +0,0 @@
/**
*
*/
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as shippingOrderController from "../controllers/shippingOrderController";
const router = Router();
router.use(authenticateToken);
router.get("/list", shippingOrderController.getList);
router.get("/preview-no", shippingOrderController.previewNextNo);
router.post("/save", shippingOrderController.save);
router.post("/delete", shippingOrderController.remove);
// 모달 왼쪽 패널 데이터 소스
router.get("/source/shipment-plan", shippingOrderController.getShipmentPlanSource);
router.get("/source/sales-order", shippingOrderController.getSalesOrderSource);
router.get("/source/item", shippingOrderController.getItemSource);
export default router;

View File

@ -10,16 +10,10 @@ const router = Router();
router.use(authenticateToken); router.use(authenticateToken);
// 출하계획 목록 조회 (관리 화면용)
router.get("/list", shippingPlanController.getList);
// 품목별 집계 + 기존 출하계획 조회 // 품목별 집계 + 기존 출하계획 조회
router.get("/aggregate", shippingPlanController.getAggregate); router.get("/aggregate", shippingPlanController.getAggregate);
// 출하계획 일괄 저장 // 출하계획 일괄 저장
router.post("/batch", shippingPlanController.batchSave); router.post("/batch", shippingPlanController.batchSave);
// 출하계획 단건 수정
router.put("/:id", shippingPlanController.updatePlan);
export default router; export default router;

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

View File

@ -1,609 +0,0 @@
"use client";
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 { 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 {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import {
Search,
RotateCcw,
Package,
ClipboardList,
Factory,
MapPin,
AlertTriangle,
CheckCircle2,
} from "lucide-react";
import { cn } from "@/lib/utils";
// --- Types ---
type WorkOrderStatus = "pending" | "in_progress";
interface WorkOrder {
id: string;
itemCode: string;
itemName: string;
quantity: number;
date: string;
status: WorkOrderStatus;
}
interface MaterialLocation {
location: string;
qty: number;
}
interface Material {
code: string;
name: string;
required: number;
current: number;
unit: string;
locations: MaterialLocation[];
}
interface Warehouse {
code: string;
name: string;
}
// --- Sample Data ---
const sampleWarehouses: Warehouse[] = [
{ code: "WH001", name: "제1창고 (위치관리)" },
{ code: "WH002", name: "제2창고 (위치관리)" },
{ code: "WH003", name: "제3창고 (위치관리)" },
];
const sampleWorkOrders: WorkOrder[] = [
{
id: "WO2024001",
itemCode: "PROD-A001",
itemName: "상품 A",
quantity: 1000,
date: "2024-11-06",
status: "pending",
},
{
id: "WO2024002",
itemCode: "PROD-A002",
itemName: "상품 B",
quantity: 500,
date: "2024-11-07",
status: "pending",
},
{
id: "WO2024003",
itemCode: "PROD-A003",
itemName: "상품 C",
quantity: 800,
date: "2024-11-08",
status: "pending",
},
{
id: "WO2024004",
itemCode: "PROD-A004",
itemName: "상품 D",
quantity: 1200,
date: "2024-11-09",
status: "in_progress",
},
];
const sampleMaterials: Material[] = [
{
code: "MAT-R001",
name: "원자재 A",
required: 5000,
current: 4200,
unit: "kg",
locations: [
{ location: "A-01-01", qty: 2000 },
{ location: "A-01-02", qty: 1500 },
{ location: "A-01-03", qty: 700 },
],
},
{
code: "MAT-R002",
name: "원자재 B",
required: 3000,
current: 3500,
unit: "kg",
locations: [
{ location: "A-02-01", qty: 2000 },
{ location: "A-02-02", qty: 1500 },
],
},
{
code: "MAT-R003",
name: "원자재 C",
required: 2000,
current: 800,
unit: "EA",
locations: [
{ location: "B-01-01", qty: 500 },
{ location: "B-01-02", qty: 300 },
],
},
{
code: "MAT-R004",
name: "원자재 D",
required: 1500,
current: 1500,
unit: "L",
locations: [{ location: "C-01-01", qty: 1500 }],
},
{
code: "MAT-R005",
name: "원자재 E",
required: 4000,
current: 2500,
unit: "kg",
locations: [
{ location: "A-03-01", qty: 1000 },
{ location: "A-03-02", qty: 1000 },
{ location: "A-03-03", qty: 500 },
],
},
];
const formatDate = (date: Date) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
};
const getStatusLabel = (status: WorkOrderStatus) =>
status === "pending" ? "대기" : "진행중";
const getStatusStyle = (status: WorkOrderStatus) =>
status === "pending"
? "bg-amber-100 text-amber-700 border-amber-200"
: "bg-blue-100 text-blue-700 border-blue-200";
export default function MaterialStatusPage() {
const today = new Date();
const weekAgo = new Date(today);
weekAgo.setDate(today.getDate() - 7);
const [searchDateFrom, setSearchDateFrom] = useState(formatDate(weekAgo));
const [searchDateTo, setSearchDateTo] = useState(formatDate(today));
const [searchItemCode, setSearchItemCode] = useState("");
const [searchItemName, setSearchItemName] = useState("");
const [workOrders] = useState<WorkOrder[]>(sampleWorkOrders);
const [checkedWoIds, setCheckedWoIds] = useState<string[]>([]);
const [selectedWoId, setSelectedWoId] = useState<string | null>(null);
const [warehouse, setWarehouse] = useState(sampleWarehouses[0]?.code || "");
const [materialSearch, setMaterialSearch] = useState("");
const [showShortageOnly, setShowShortageOnly] = useState(false);
const [materials] = useState<Material[]>(sampleMaterials);
const isAllChecked =
workOrders.length > 0 && checkedWoIds.length === workOrders.length;
const handleCheckAll = useCallback(
(checked: boolean) => {
setCheckedWoIds(checked ? workOrders.map((wo) => wo.id) : []);
},
[workOrders]
);
const handleCheckWo = useCallback((id: string, checked: boolean) => {
setCheckedWoIds((prev) =>
checked ? [...prev, id] : prev.filter((i) => i !== id)
);
}, []);
const handleSelectWo = useCallback((id: string) => {
setSelectedWoId((prev) => (prev === id ? null : id));
}, []);
const handleLoadSelectedMaterials = useCallback(() => {
if (checkedWoIds.length === 0) {
alert("자재를 조회할 작업지시를 선택해주세요.");
return;
}
console.log("선택된 작업지시:", checkedWoIds);
}, [checkedWoIds]);
const handleResetSearch = useCallback(() => {
const t = new Date();
const w = new Date(t);
w.setDate(t.getDate() - 7);
setSearchDateFrom(formatDate(w));
setSearchDateTo(formatDate(t));
setSearchItemCode("");
setSearchItemName("");
setMaterialSearch("");
setShowShortageOnly(false);
}, []);
const filteredMaterials = useMemo(() => {
if (!warehouse) return [];
return materials.filter((m) => {
const searchLower = materialSearch.toLowerCase();
const matchesSearch =
!materialSearch ||
m.code.toLowerCase().includes(searchLower) ||
m.name.toLowerCase().includes(searchLower);
const matchesShortage = !showShortageOnly || m.current < m.required;
return matchesSearch && matchesShortage;
});
}, [materials, warehouse, 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>
{/* 검색 영역 */}
<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">
<Input
type="date"
className="h-9 w-[140px]"
value={searchDateFrom}
onChange={(e) => setSearchDateFrom(e.target.value)}
/>
<span className="text-muted-foreground">~</span>
<Input
type="date"
className="h-9 w-[140px]"
value={searchDateTo}
onChange={(e) => setSearchDateTo(e.target.value)}
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input
placeholder="품목코드"
className="h-9 w-[140px]"
value={searchItemCode}
onChange={(e) => setSearchItemCode(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input
placeholder="품목명"
className="h-9 w-[140px]"
value={searchItemName}
onChange={(e) => setSearchItemName(e.target.value)}
/>
</div>
<div className="flex-1" />
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-9"
onClick={handleResetSearch}
>
<RotateCcw className="mr-2 h-4 w-4" />
</Button>
<Button size="sm" className="h-9">
<Search className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 메인 콘텐츠 (좌우 분할) */}
<div className="flex-1 overflow-hidden rounded-lg border bg-background shadow-sm">
<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 gap-2">
<Checkbox
checked={isAllChecked}
onCheckedChange={handleCheckAll}
/>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="font-normal">
{workOrders.length}
</Badge>
<Button
size="sm"
className="h-8"
onClick={handleLoadSelectedMaterials}
>
<Search className="mr-1.5 h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 작업지시 목록 */}
<div className="flex-1 space-y-2 overflow-auto p-3">
{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" />
<p className="text-sm text-muted-foreground">
</p>
</div>
) : (
workOrders.map((wo) => (
<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",
selectedWoId === wo.id
? "border-primary bg-primary/5 shadow-md"
: "border-border"
)}
onClick={() => handleSelectWo(wo.id)}
>
<div
className="flex items-start pt-0.5"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={checkedWoIds.includes(wo.id)}
onCheckedChange={(c) =>
handleCheckWo(wo.id, c as boolean)
}
/>
</div>
<div className="flex flex-1 flex-col gap-1.5">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-primary">
{wo.id}
</span>
<span
className={cn(
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
getStatusStyle(wo.status)
)}
>
{getStatusLabel(wo.status)}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold">
{wo.itemName}
</span>
<span className="text-xs text-muted-foreground">
({wo.itemCode})
</span>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>:</span>
<span className="font-semibold text-foreground">
{wo.quantity.toLocaleString()}
</span>
<span className="mx-1">|</span>
<span>:</span>
<span className="font-semibold text-foreground">
{wo.date}
</span>
</div>
</div>
</div>
))
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 원자재 현황 */}
<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">
<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">
<Input
placeholder="원자재 검색"
className="h-9 min-w-[150px] flex-1"
value={materialSearch}
onChange={(e) => setMaterialSearch(e.target.value)}
/>
<Select value={warehouse} onValueChange={setWarehouse}>
<SelectTrigger className="h-9 w-[200px]">
<SelectValue placeholder="창고 선택" />
</SelectTrigger>
<SelectContent>
{sampleWarehouses.map((wh) => (
<SelectItem key={wh.code} value={wh.code}>
{wh.name}
</SelectItem>
))}
</SelectContent>
</Select>
<label className="flex cursor-pointer items-center gap-2 text-sm 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">
{filteredMaterials.length}
</span>
</div>
{/* 원자재 목록 */}
<div className="flex-1 space-y-2 overflow-auto p-3">
{!warehouse ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Factory className="mb-3 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" />
<p className="text-sm text-muted-foreground">
</p>
</div>
) : (
filteredMaterials.map((material) => {
const shortage = material.required - material.current;
const isShortage = shortage > 0;
const percentage = Math.min(
(material.current / material.required) * 100,
100
);
return (
<div
key={material.code}
className={cn(
"rounded-lg border-2 p-3 transition-all hover:shadow-md hover:-translate-y-0.5",
isShortage
? "border-destructive/40 bg-destructive/2"
: "border-emerald-300/50 bg-emerald-50/20"
)}
>
{/* 메인 정보 라인 */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-bold">
{material.name}
</span>
<span className="text-xs text-muted-foreground">
({material.code})
</span>
<span className="text-xs text-muted-foreground">
|
</span>
<span className="text-xs text-muted-foreground">
:
</span>
<span className="text-xs font-semibold text-blue-600">
{material.required.toLocaleString()}
{material.unit}
</span>
<span className="text-xs text-muted-foreground">
|
</span>
<span className="text-xs text-muted-foreground">
:
</span>
<span
className={cn(
"text-xs font-semibold",
isShortage
? "text-destructive"
: "text-foreground"
)}
>
{material.current.toLocaleString()}
{material.unit}
</span>
<span className="text-xs text-muted-foreground">
|
</span>
<span className="text-xs text-muted-foreground">
{isShortage ? "부족:" : "여유:"}
</span>
<span
className={cn(
"text-xs font-semibold",
isShortage
? "text-destructive"
: "text-emerald-600"
)}
>
{Math.abs(shortage).toLocaleString()}
{material.unit}
</span>
<span className="text-xs font-semibold text-muted-foreground">
({percentage.toFixed(0)}%)
</span>
{isShortage ? (
<span className="ml-auto inline-flex items-center gap-1 rounded-md border border-destructive bg-destructive/10 px-2 py-0.5 text-[11px] font-semibold text-destructive">
<AlertTriangle className="h-3 w-3" />
</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">
<CheckCircle2 className="h-3 w-3" />
</span>
)}
</div>
{/* 위치별 재고 */}
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<MapPin className="h-3.5 w-3.5 text-muted-foreground" />
{material.locations.map((loc, idx) => (
<span
key={idx}
className="inline-flex items-center gap-1 rounded bg-muted/40 px-2 py-0.5 text-xs transition-colors hover:bg-muted/60"
>
<span className="font-semibold font-mono text-primary">
{loc.location}
</span>
<span className="font-semibold">
{loc.qty.toLocaleString()}
{material.unit}
</span>
</span>
))}
</div>
</div>
);
})
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
);
}

View File

@ -1,893 +0,0 @@
"use client";
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,
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 {
Search,
Download,
Upload,
Settings,
RotateCcw,
Plus,
Save,
BarChart3,
ClipboardList,
Inbox,
} from "lucide-react";
import { cn } from "@/lib/utils";
// --- Types ---
type ClaimType = "불량" | "교환" | "반품" | "배송지연" | "기타";
type ClaimStatus = "접수" | "처리중" | "완료" | "취소";
interface Claim {
claimNo: string;
claimDate: string;
claimType: ClaimType;
claimStatus: ClaimStatus;
customerName: string;
managerName: string;
orderNo: string;
claimContent: string;
processContent: string;
}
// --- Sample Data ---
const initialData: Claim[] = [
{
claimNo: "CLM-2025-004",
claimDate: "2025-11-09",
claimType: "불량",
claimStatus: "접수",
customerName: "주식회사 코아스포트",
managerName: "김철수",
orderNo: "SO-2025-0102",
claimContent: "제품 표면에 스크래치가 발견되었습니다.",
processContent: "",
},
{
claimNo: "CLM-2025-001",
claimDate: "2025-01-05",
claimType: "불량",
claimStatus: "접수",
customerName: "(주)현상산업",
managerName: "김철수",
orderNo: "SO-2025-0102",
claimContent: "제품 불량",
processContent: "",
},
{
claimNo: "CLM-2025-002",
claimDate: "2025-01-04",
claimType: "교환",
claimStatus: "처리중",
customerName: "대한전섬",
managerName: "이영희",
orderNo: "SO-2025-0095",
claimContent: "규격 불일치",
processContent: "교환 진행 중",
},
{
claimNo: "CLM-2025-003",
claimDate: "2025-01-03",
claimType: "반품",
claimStatus: "완료",
customerName: "삼성전자",
managerName: "박민수",
orderNo: "SO-2024-1285",
claimContent: "수량 초과 납품",
processContent: "반품 완료",
},
];
const getClaimTypeStyle = (type: ClaimType) => {
switch (type) {
case "불량":
return "bg-rose-100 text-rose-800 border-rose-200";
case "교환":
return "bg-amber-100 text-amber-800 border-amber-200";
case "반품":
return "bg-blue-100 text-blue-800 border-blue-200";
case "배송지연":
return "bg-indigo-100 text-indigo-800 border-indigo-200";
case "기타":
return "bg-gray-100 text-gray-800 border-gray-200";
default:
return "bg-gray-100 text-gray-800 border-gray-200";
}
};
const getClaimStatusStyle = (status: ClaimStatus) => {
switch (status) {
case "접수":
return "bg-blue-100 text-blue-800 border-blue-200";
case "처리중":
return "bg-amber-100 text-amber-800 border-amber-200";
case "완료":
return "bg-emerald-100 text-emerald-800 border-emerald-200";
case "취소":
return "bg-rose-100 text-rose-800 border-rose-200";
default:
return "bg-gray-100 text-gray-800 border-gray-200";
}
};
const CLAIM_TYPES: ClaimType[] = ["불량", "교환", "반품", "배송지연", "기타"];
const CLAIM_STATUSES: ClaimStatus[] = ["접수", "처리중", "완료", "취소"];
export default function ClaimManagementPage() {
const [data, setData] = useState<Claim[]>(initialData);
const [selectedClaimNo, setSelectedClaimNo] = useState<string | null>(null);
// 검색 상태
const [searchDateFrom, setSearchDateFrom] = useState("");
const [searchDateTo, setSearchDateTo] = useState("");
const [searchClaimType, setSearchClaimType] = useState<string>("all");
const [searchStatus, setSearchStatus] = useState<string>("all");
const [searchCustomer, setSearchCustomer] = useState("");
const [searchClaimNo, setSearchClaimNo] = useState("");
// 모달 상태
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [formData, setFormData] = useState<Partial<Claim>>({});
useEffect(() => {
const today = new Date();
const thirtyDaysAgo = new Date(today);
thirtyDaysAgo.setDate(today.getDate() - 30);
setSearchDateFrom(thirtyDaysAgo.toISOString().split("T")[0]);
setSearchDateTo(today.toISOString().split("T")[0]);
}, []);
const filteredData = useMemo(() => {
return data
.filter((claim) => {
if (searchDateFrom && claim.claimDate < searchDateFrom) return false;
if (searchDateTo && claim.claimDate > searchDateTo) return false;
if (searchClaimType !== "all" && claim.claimType !== searchClaimType)
return false;
if (searchStatus !== "all" && claim.claimStatus !== searchStatus)
return false;
if (
searchCustomer &&
!claim.customerName
.toLowerCase()
.includes(searchCustomer.toLowerCase())
)
return false;
if (
searchClaimNo &&
!claim.claimNo.toLowerCase().includes(searchClaimNo.toLowerCase())
)
return false;
return true;
})
.sort((a, b) => b.claimDate.localeCompare(a.claimDate));
}, [
data,
searchDateFrom,
searchDateTo,
searchClaimType,
searchStatus,
searchCustomer,
searchClaimNo,
]);
// 상태별 카운트
const statusCounts = useMemo(() => {
const counts = { 접수: 0, 처리중: 0, 완료: 0, 취소: 0 };
data.forEach((claim) => {
if (counts[claim.claimStatus] !== undefined) {
counts[claim.claimStatus]++;
}
});
return counts;
}, [data]);
const generateClaimNo = useCallback(() => {
const year = new Date().getFullYear();
const prefix = `CLM-${year}-`;
const existingNumbers = data
.filter((c) => c.claimNo.startsWith(prefix))
.map((c) => parseInt(c.claimNo.replace(prefix, ""), 10))
.filter((n) => !isNaN(n));
const maxNumber =
existingNumbers.length > 0 ? Math.max(...existingNumbers) : 0;
return `${prefix}${String(maxNumber + 1).padStart(3, "0")}`;
}, [data]);
const handleRowClick = (claimNo: string) => {
setSelectedClaimNo(claimNo);
};
const openRegisterModal = () => {
setIsEditMode(false);
setFormData({
claimNo: generateClaimNo(),
claimDate: new Date().toISOString().split("T")[0],
claimType: undefined,
claimStatus: "접수",
customerName: "",
managerName: "",
orderNo: "",
claimContent: "",
processContent: "",
});
setIsModalOpen(true);
};
const openEditModal = (claimNo: string) => {
const claim = data.find((c) => c.claimNo === claimNo);
if (!claim) return;
setIsEditMode(true);
setFormData({ ...claim });
setIsModalOpen(true);
};
const handleFormChange = (field: keyof Claim, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSave = () => {
if (!formData.claimType || !formData.customerName || !formData.claimContent) {
alert("필수 항목을 모두 입력해주세요.");
return;
}
const claimData: Claim = {
claimNo: formData.claimNo || "",
claimDate: formData.claimDate || new Date().toISOString().split("T")[0],
claimType: formData.claimType as ClaimType,
claimStatus: (formData.claimStatus as ClaimStatus) || "접수",
customerName: formData.customerName || "",
managerName: formData.managerName || "",
orderNo: formData.orderNo || "",
claimContent: formData.claimContent || "",
processContent: formData.processContent || "",
};
if (isEditMode) {
setData((prev) =>
prev.map((c) => (c.claimNo === claimData.claimNo ? claimData : c))
);
} else {
setData((prev) => [claimData, ...prev]);
}
setIsModalOpen(false);
alert("클레임이 저장되었습니다.");
};
const handleResetSearch = () => {
const today = new Date();
const thirtyDaysAgo = new Date(today);
thirtyDaysAgo.setDate(today.getDate() - 30);
setSearchDateFrom(thirtyDaysAgo.toISOString().split("T")[0]);
setSearchDateTo(today.toISOString().split("T")[0]);
setSearchClaimType("all");
setSearchStatus("all");
setSearchCustomer("");
setSearchClaimNo("");
};
const selectedClaim = useMemo(
() => data.find((c) => c.claimNo === selectedClaimNo),
[data, selectedClaimNo]
);
const statCards: {
label: string;
value: number;
gradient: string;
textColor: string;
}[] = [
{
label: "접수",
value: statusCounts["접수"],
gradient: "from-indigo-500 to-purple-600",
textColor: "text-white",
},
{
label: "처리중",
value: statusCounts["처리중"],
gradient: "from-amber-300 to-amber-500",
textColor: "text-amber-900",
},
{
label: "완료",
value: statusCounts["완료"],
gradient: "from-cyan-400 to-blue-500",
textColor: "text-white",
},
{
label: "취소",
value: statusCounts["취소"],
gradient: "from-slate-300 to-slate-400",
textColor: "text-slate-800",
},
];
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="space-y-1.5">
<Label className="text-xs text-muted-foreground"> </Label>
<Select value={searchClaimType} onValueChange={setSearchClaimType}>
<SelectTrigger className="w-[120px] h-9">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{CLAIM_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t}
</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-[120px] h-9">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{CLAIM_STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<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)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input
placeholder="클레임번호 검색"
className="w-[150px] h-9"
value={searchClaimNo}
onChange={(e) => setSearchClaimNo(e.target.value)}
/>
</div>
<div className="flex-1" />
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-9"
onClick={handleResetSearch}
>
<RotateCcw className="w-4 h-4 mr-2" />
</Button>
<Button variant="outline" size="sm" className="h-9">
<Settings className="w-4 h-4 mr-2" />
</Button>
<Button variant="outline" size="sm" className="h-9">
<Upload className="w-4 h-4 mr-2" />
</Button>
<Button variant="outline" size="sm" className="h-9">
<Download className="w-4 h-4 mr-2" />
</Button>
</div>
</CardContent>
</Card>
{/* 메인 분할 레이아웃 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 왼쪽: 클레임 목록 */}
<ResizablePanel defaultSize={65} minSize={35}>
<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">
<ClipboardList className="w-4 h-4" />
<Badge variant="secondary" className="font-normal">
{filteredData.length}
</Badge>
</div>
<Button size="sm" onClick={openRegisterModal}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
</div>
<div className="flex-1 overflow-auto">
<Table>
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
<TableRow>
<TableHead className="w-[50px] text-center">No</TableHead>
<TableHead className="w-[130px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[90px] text-center">
</TableHead>
<TableHead className="w-[90px] text-center">
</TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead> </TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredData.length === 0 ? (
<TableRow>
<TableCell
colSpan={9}
className="h-32 text-center text-muted-foreground"
>
<div className="flex flex-col items-center gap-2">
<Inbox className="w-8 h-8 text-muted-foreground/50" />
<span> </span>
</div>
</TableCell>
</TableRow>
) : (
filteredData.map((claim, idx) => (
<TableRow
key={claim.claimNo}
className={cn(
"cursor-pointer hover:bg-muted/50 transition-colors",
selectedClaimNo === claim.claimNo && "bg-primary/5"
)}
onClick={() => handleRowClick(claim.claimNo)}
onDoubleClick={() => openEditModal(claim.claimNo)}
>
<TableCell className="text-center text-muted-foreground">
{idx + 1}
</TableCell>
<TableCell className="font-medium">
{claim.claimNo}
</TableCell>
<TableCell>{claim.claimDate}</TableCell>
<TableCell className="text-center">
<span
className={cn(
"px-2 py-1 rounded-full text-[11px] font-medium border",
getClaimTypeStyle(claim.claimType)
)}
>
{claim.claimType}
</span>
</TableCell>
<TableCell className="text-center">
<span
className={cn(
"px-2 py-1 rounded-full text-[11px] font-medium border",
getClaimStatusStyle(claim.claimStatus)
)}
>
{claim.claimStatus}
</span>
</TableCell>
<TableCell>{claim.customerName}</TableCell>
<TableCell>{claim.managerName || "-"}</TableCell>
<TableCell className="text-muted-foreground text-xs">
{claim.orderNo || "-"}
</TableCell>
<TableCell className="text-xs text-muted-foreground truncate max-w-[300px]">
{claim.claimContent || "-"}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 처리 현황 */}
<ResizablePanel defaultSize={35} minSize={20}>
<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 flex items-center gap-2">
<BarChart3 className="w-4 h-4" />
</span>
</div>
<div className="flex-1 overflow-auto p-4 space-y-5">
{/* 상태별 카드 */}
<div className="grid grid-cols-2 gap-3">
{statCards.map((card) => (
<div
key={card.label}
className={cn(
"rounded-xl p-5 text-center bg-linear-to-br transition-all hover:-translate-y-0.5 hover:shadow-md",
card.gradient,
card.textColor
)}
>
<div className="text-sm font-medium opacity-90 mb-2">
{card.label}
</div>
<div className="text-4xl font-bold">{card.value}</div>
</div>
))}
</div>
{/* 선택된 클레임 상세 */}
{selectedClaim ? (
<div className="space-y-4">
<h3 className="text-sm font-semibold flex items-center gap-2 pt-2 border-t">
<ClipboardList className="w-4 h-4" />
- {selectedClaim.claimNo}
</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="font-medium">
{selectedClaim.claimNo}
</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1">
</span>
<span>{selectedClaim.claimDate}</span>
</div>
<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",
getClaimTypeStyle(selectedClaim.claimType)
)}
>
{selectedClaim.claimType}
</span>
</div>
<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",
getClaimStatusStyle(selectedClaim.claimStatus)
)}
>
{selectedClaim.claimStatus}
</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1">
</span>
<span>{selectedClaim.customerName}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1">
</span>
<span>{selectedClaim.managerName || "-"}</span>
</div>
<div className="col-span-2">
<span className="text-muted-foreground text-xs block mb-1">
</span>
<span className="text-muted-foreground">
{selectedClaim.orderNo || "-"}
</span>
</div>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1">
</span>
<div className="bg-muted/30 p-3 rounded-md border border-border/50 text-sm whitespace-pre-wrap min-h-[60px]">
{selectedClaim.claimContent || "-"}
</div>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1">
</span>
<div className="bg-muted/30 p-3 rounded-md border border-border/50 text-sm whitespace-pre-wrap min-h-[60px]">
{selectedClaim.processContent || "-"}
</div>
</div>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => openEditModal(selectedClaim.claimNo)}
>
<Save className="w-4 h-4 mr-2" />
</Button>
</div>
) : (
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="w-14 h-14 rounded-full bg-muted flex items-center justify-center mb-3">
<BarChart3 className="w-7 h-7 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">
</p>
</div>
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 클레임 등록/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[900px] max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{isEditMode ? "클레임 수정" : "클레임 등록"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{isEditMode
? "클레임 정보를 수정합니다."
: "새로운 클레임을 등록합니다."}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-auto">
<div className="flex flex-col md:flex-row gap-5">
{/* 왼쪽: 기본 정보 */}
<div className="md:w-[340px] shrink-0 space-y-4 bg-muted/30 p-4 rounded-lg border border-border/50">
<h3 className="text-sm font-semibold pb-2 border-b">
</h3>
<div>
<Label htmlFor="claimNo" className="text-xs sm:text-sm">
</Label>
<Input
id="claimNo"
value={formData.claimNo || ""}
readOnly
className="h-8 text-xs sm:h-10 sm:text-sm bg-muted cursor-not-allowed"
/>
</div>
<div>
<Label htmlFor="claimDate" className="text-xs sm:text-sm">
</Label>
<Input
id="claimDate"
type="date"
value={formData.claimDate || ""}
onChange={(e) =>
handleFormChange("claimDate", e.target.value)
}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label htmlFor="claimType" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Select
value={formData.claimType || ""}
onValueChange={(v) => handleFormChange("claimType", v)}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{CLAIM_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="claimStatus" className="text-xs sm:text-sm">
</Label>
<Select
value={formData.claimStatus || "접수"}
onValueChange={(v) => handleFormChange("claimStatus", v)}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CLAIM_STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="customerName" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="customerName"
value={formData.customerName || ""}
onChange={(e) =>
handleFormChange("customerName", e.target.value)
}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label htmlFor="managerName" className="text-xs sm:text-sm">
</Label>
<Input
id="managerName"
value={formData.managerName || ""}
onChange={(e) =>
handleFormChange("managerName", e.target.value)
}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label htmlFor="orderNo" className="text-xs sm:text-sm">
</Label>
<Input
id="orderNo"
value={formData.orderNo || ""}
onChange={(e) =>
handleFormChange("orderNo", e.target.value)
}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
{/* 오른쪽: 상세 내용 */}
<div className="flex-1 space-y-4 bg-muted/30 p-4 rounded-lg border border-border/50 min-w-0">
<h3 className="text-sm font-semibold pb-2 border-b">
</h3>
<div className="flex flex-col flex-1">
<Label htmlFor="claimContent" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Textarea
id="claimContent"
value={formData.claimContent || ""}
onChange={(e) =>
handleFormChange("claimContent", e.target.value)
}
placeholder="클레임 내용을 상세히 입력해주세요"
className="min-h-[200px] resize-y text-xs sm:text-sm"
/>
</div>
<div>
<Label
htmlFor="processContent"
className="text-xs sm:text-sm"
>
</Label>
<Textarea
id="processContent"
value={formData.processContent || ""}
onChange={(e) =>
handleFormChange("processContent", e.target.value)
}
placeholder="처리 내용을 입력해주세요"
className="min-h-[150px] resize-y text-xs sm:text-sm"
/>
</div>
</div>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsModalOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSave}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
<Save className="w-4 h-4 mr-2" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -1,826 +0,0 @@
"use client";
import React, { useState, useMemo, useEffect, useCallback, useRef } 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 { Textarea } from "@/components/ui/textarea";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, Truck, Search, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import {
getShippingOrderList,
saveShippingOrder,
deleteShippingOrders,
previewShippingOrderNo,
getShipmentPlanSource,
getSalesOrderSource,
getItemSource,
} from "@/lib/api/shipping";
type DataSourceType = "shipmentPlan" | "salesOrder" | "itemInfo";
const STATUS_OPTIONS = [
{ value: "all", label: "전체" },
{ value: "READY", label: "준비중" },
{ value: "IN_PROGRESS", label: "진행중" },
{ value: "COMPLETED", label: "완료" },
];
const getStatusLabel = (s: string) => STATUS_OPTIONS.find(o => o.value === s)?.label || s;
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";
}
};
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" };
}
};
interface SelectedItem {
id: string | number;
itemCode: string;
itemName: string;
spec: string;
material: string;
customer: string;
planQty: number;
orderQty: number;
sourceType: DataSourceType;
shipmentPlanId?: number;
salesOrderId?: number;
detailId?: string;
partnerCode?: string;
}
export default function ShippingOrderPage() {
const [orders, setOrders] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [checkedIds, setCheckedIds] = useState<number[]>([]);
// 검색
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("");
// 모달
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
// 모달 폼
const [formOrderNumber, setFormOrderNumber] = useState("");
const [formOrderDate, setFormOrderDate] = useState("");
const [formCustomer, setFormCustomer] = useState("");
const [formPartnerId, setFormPartnerId] = useState("");
const [formStatus, setFormStatus] = useState("READY");
const [formCarrier, setFormCarrier] = useState("");
const [formVehicle, setFormVehicle] = useState("");
const [formDriver, setFormDriver] = useState("");
const [formDriverPhone, setFormDriverPhone] = useState("");
const [formArrival, setFormArrival] = useState("");
const [formAddress, setFormAddress] = useState("");
const [formMemo, setFormMemo] = useState("");
const [isTransportCollapsed, setIsTransportCollapsed] = useState(false);
// 모달 왼쪽 패널
const [dataSource, setDataSource] = useState<DataSourceType>("shipmentPlan");
const [sourceKeyword, setSourceKeyword] = useState("");
const [sourceData, setSourceData] = useState<any[]>([]);
const [sourceLoading, setSourceLoading] = useState(false);
const [selectedItems, setSelectedItems] = useState<SelectedItem[]>([]);
const [sourcePage, setSourcePage] = useState(1);
const [sourcePageSize] = 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();
const result = await getShippingOrderList(params);
if (result.success) setOrders(result.data || []);
} catch (err) {
console.error("출하지시 조회 실패:", err);
} finally {
setLoading(false);
}
}, [searchDateFrom, searchDateTo, searchStatus, debouncedCustomer, debouncedKeyword]);
useEffect(() => {
if (searchDateFrom && searchDateTo) fetchOrders();
}, [fetchOrders]);
// 소스 데이터 조회
const fetchSourceData = useCallback(async (pageOverride?: number) => {
setSourceLoading(true);
try {
const currentPage = pageOverride ?? sourcePage;
const params: any = { page: currentPage, pageSize: sourcePageSize };
if (sourceKeyword.trim()) params.keyword = sourceKeyword.trim();
let result;
switch (dataSource) {
case "shipmentPlan":
result = await getShipmentPlanSource(params);
break;
case "salesOrder":
result = await getSalesOrderSource(params);
break;
case "itemInfo":
result = await getItemSource(params);
break;
}
if (result?.success) {
setSourceData(result.data || []);
setSourceTotalCount(result.totalCount || 0);
}
} catch (err) {
console.error("소스 데이터 조회 실패:", err);
} finally {
setSourceLoading(false);
}
}, [dataSource, sourceKeyword, sourcePage, sourcePageSize]);
useEffect(() => {
if (isModalOpen) {
setSourcePage(1);
fetchSourceData(1);
}
}, [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;
try {
const result = await deleteShippingOrders(checkedIds);
if (result.success) {
setCheckedIds([]);
fetchOrders();
alert("삭제되었습니다.");
}
} catch (err: any) {
alert(err.message || "삭제 실패");
}
};
// 모달 열기
const openModal = (order?: any) => {
if (order) {
setIsEditMode(true);
setEditId(order.id);
setFormOrderNumber(order.instruction_no || "");
setFormOrderDate(order.instruction_date ? order.instruction_date.split("T")[0] : "");
setFormCustomer(order.customer_name || "");
setFormPartnerId(order.partner_id || "");
setFormStatus(order.status || "READY");
setFormCarrier(order.carrier_name || "");
setFormVehicle(order.vehicle_no || "");
setFormDriver(order.driver_name || "");
setFormDriverPhone(order.driver_contact || "");
setFormArrival(order.arrival_time ? order.arrival_time.slice(0, 16) : "");
setFormAddress(order.delivery_address || "");
setFormMemo(order.memo || "");
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;
else if (srcType === "itemInfo") sourceId = it.item_code || "";
return {
id: sourceId,
itemCode: it.item_code || "",
itemName: it.item_name || "",
spec: it.spec || "",
material: it.material || "",
customer: order.customer_name || "",
planQty: Number(it.plan_qty || 0),
orderQty: Number(it.order_qty || 0),
sourceType: srcType,
shipmentPlanId: it.shipment_plan_id,
salesOrderId: it.sales_order_id,
detailId: it.detail_id,
partnerCode: order.partner_id,
};
}));
} else {
setIsEditMode(false);
setEditId(null);
setFormOrderNumber("불러오는 중...");
setFormOrderDate(new Date().toISOString().split("T")[0]);
previewShippingOrderNo().then(r => {
if (r.success) setFormOrderNumber(r.instructionNo);
else setFormOrderNumber("(자동생성)");
}).catch(() => setFormOrderNumber("(자동생성)"));
setFormCustomer("");
setFormPartnerId("");
setFormStatus("READY");
setFormCarrier("");
setFormVehicle("");
setFormDriver("");
setFormDriverPhone("");
setFormArrival("");
setFormAddress("");
setFormMemo("");
setSelectedItems([]);
}
setDataSource("shipmentPlan");
setSourceKeyword("");
setSourceData([]);
setIsTransportCollapsed(false);
setIsModalOpen(true);
};
// 소스 아이템 선택 토글
const toggleSourceItem = (item: any) => {
const key = dataSource === "shipmentPlan" ? item.id
: dataSource === "salesOrder" ? item.id
: 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;
});
if (exists > -1) {
setSelectedItems(prev => prev.filter((_, i) => i !== exists));
} else {
const newItem: SelectedItem = {
id: key,
itemCode: item.item_code || "",
itemName: item.item_name || "",
spec: item.spec || "",
material: item.material || "",
customer: item.customer_name || "",
planQty: Number(item.plan_qty || item.qty || item.balance_qty || 0),
orderQty: Number(item.plan_qty || item.balance_qty || item.qty || 1),
sourceType: dataSource,
shipmentPlanId: dataSource === "shipmentPlan" ? item.id : undefined,
salesOrderId: dataSource === "salesOrder" ? (item.master_id || undefined) : undefined,
detailId: dataSource === "salesOrder" ? item.id : (dataSource === "shipmentPlan" ? item.detail_id : undefined),
partnerCode: item.partner_code || "",
};
setSelectedItems(prev => [...prev, newItem]);
if (!formCustomer && item.customer_name) {
setFormCustomer(item.customer_name);
setFormPartnerId(item.partner_code || "");
}
}
};
const removeSelectedItem = (idx: number) => {
setSelectedItems(prev => prev.filter((_, i) => i !== idx));
};
const updateOrderQty = (idx: number, val: number) => {
setSelectedItems(prev => prev.map((item, i) => i === idx ? { ...item, orderQty: val } : item));
};
// 저장
const handleSave = async () => {
if (!formOrderDate) { alert("출하지시일을 입력해주세요."); return; }
if (selectedItems.length === 0) { alert("품목을 선택해주세요."); return; }
setSaving(true);
try {
const payload = {
id: isEditMode ? editId : undefined,
instructionDate: formOrderDate,
partnerId: formPartnerId || formCustomer,
status: formStatus,
memo: formMemo,
carrierName: formCarrier,
vehicleNo: formVehicle,
driverName: formDriver,
driverContact: formDriverPhone,
arrivalTime: formArrival || null,
deliveryAddress: formAddress,
items: selectedItems.map(item => ({
itemCode: item.itemCode,
itemName: item.itemName,
spec: item.spec,
material: item.material,
orderQty: item.orderQty,
planQty: item.planQty,
shipQty: 0,
sourceType: item.sourceType,
shipmentPlanId: item.shipmentPlanId,
salesOrderId: item.salesOrderId,
detailId: item.detailId,
})),
};
const result = await saveShippingOrder(payload);
if (result.success) {
setIsModalOpen(false);
fetchOrders();
alert(isEditMode ? "출하지시가 수정되었습니다." : "출하지시가 등록되었습니다.");
} else {
alert(result.message || "저장 실패");
}
} catch (err: any) {
alert(err.message || "저장 중 오류 발생");
} finally {
setSaving(false);
}
};
const formatDate = (d: string) => d ? d.split("T")[0] : "-";
const dataSourceTitle: Record<DataSourceType, string> = {
shipmentPlan: "출하계획 목록",
salesOrder: "수주정보 목록",
itemInfo: "품목정보 목록",
};
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-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 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>
</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>
</div>
{/* 등록/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-[90vw] max-h-[90vh] w-[1400px] p-0 flex flex-col overflow-hidden">
<DialogHeader className="p-5 pb-4 border-b bg-primary text-primary-foreground shrink-0">
<DialogTitle className="text-lg">{isEditMode ? "출하지시 수정" : "출하지시 등록"}</DialogTitle>
<DialogDescription className="text-primary-foreground/70">
{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">
<Select value={dataSource} onValueChange={(v) => setDataSource(v as DataSourceType)}>
<SelectTrigger className="w-[130px] h-8 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}>
{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>
<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>
) : sourceData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<div className="text-sm"> </div>
</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>}
</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)}>
<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-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>
{dataSource === "shipmentPlan" && (
<TableCell className="text-center">
<Badge variant="secondary" className="text-[10px]">{getStatusLabel(item.status)}</Badge>
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
{/* 페이징 */}
{sourceTotalCount > 0 && (
<div className="px-4 py-2 border-t bg-muted/10 flex items-center justify-between shrink-0">
<span className="text-xs text-muted-foreground">
{sourceTotalCount} {(sourcePage - 1) * sourcePageSize + 1}-{Math.min(sourcePage * sourcePageSize, sourceTotalCount)}
</span>
<div className="flex items-center gap-1">
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage <= 1}
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)}
onClick={() => { const p = sourcePage + 1; setSourcePage(p); fetchSourceData(p); }}>
<ChevronRight className="w-3.5 h-3.5" />
</Button>
</div>
</div>
)}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 폼 */}
<ResizablePanel defaultSize={45} minSize={30}>
<div className="flex flex-col h-full overflow-auto p-5 bg-muted/20 gap-5">
{/* 기본 정보 */}
<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>
<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>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input value={formCustomer} readOnly placeholder="품목 선택 시 자동" className="h-9 bg-muted/50" />
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Select value={formStatus} onValueChange={setFormStatus}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="READY"></SelectItem>
<SelectItem value="IN_PROGRESS"></SelectItem>
<SelectItem value="COMPLETED"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</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" />}
</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>
)}
</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="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>
) : (
<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>
</TableRow>
</TableHeader>
<TableBody>
{selectedItems.map((item, idx) => {
const b = getSourceBadge(item.sourceType);
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>
</TableCell>
<TableCell className="text-xs">{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" />
</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" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</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>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
<DialogFooter className="p-4 border-t bg-muted/30 shrink-0">
<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>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -1,530 +0,0 @@
"use client";
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 { cn } from "@/lib/utils";
import {
getShipmentPlanList,
updateShipmentPlan,
type ShipmentPlanListItem,
} from "@/lib/api/shipping";
const STATUS_OPTIONS = [
{ value: "all", label: "전체" },
{ value: "READY", label: "준비" },
{ value: "CONFIRMED", label: "확정" },
{ value: "SHIPPING", label: "출하중" },
{ value: "COMPLETED", label: "완료" },
{ value: "CANCEL_REQUEST", label: "취소요청" },
{ value: "CANCELLED", label: "취소완료" },
];
const getStatusLabel = (status: string) => {
const found = STATUS_OPTIONS.find(o => o.value === status);
return found?.label || status;
};
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";
}
};
export default function ShippingPlanPage() {
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("");
// 상세 패널 편집
const [editPlanQty, setEditPlanQty] = useState("");
const [editPlanDate, setEditPlanDate] = useState("");
const [editMemo, setEditMemo] = useState("");
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();
const result = await getShipmentPlanList(params);
if (result.success) {
setData(result.data || []);
}
} catch (err) {
console.error("출하계획 조회 실패:", err);
} finally {
setLoading(false);
}
}, [searchDateFrom, searchDateTo, searchStatus, searchCustomer, searchKeyword]);
// 초기 로드 + 검색 시 자동 조회
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]);
};
const selectedPlan = useMemo(() => data.find(p => p.id === selectedId), [data, selectedId]);
const handleRowClick = (plan: ShipmentPlanListItem) => {
if (isDetailChanged && selectedId !== plan.id) {
if (!confirm("변경사항이 있습니다. 저장하지 않고 이동하시겠습니까?")) return;
}
setSelectedId(plan.id);
setEditPlanQty(String(Number(plan.plan_qty)));
setEditPlanDate(plan.plan_date ? plan.plan_date.split("T")[0] : "");
setEditMemo(plan.memo || "");
setIsDetailChanged(false);
};
const handleCheckAll = (checked: boolean) => {
if (checked) {
setCheckedIds(data.filter(p => p.status !== "CANCELLED").map(p => p.id));
} else {
setCheckedIds([]);
}
};
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보다 커야 합니다.");
return;
}
if (!editPlanDate) {
alert("출하계획일을 입력해주세요.");
return;
}
setSaving(true);
try {
const result = await updateShipmentPlan(selectedId, {
planQty: qty,
planDate: editPlanDate,
memo: editMemo,
});
if (result.success) {
setIsDetailChanged(false);
alert("저장되었습니다.");
fetchData();
} else {
alert(result.message || "저장 실패");
}
} catch (err: any) {
alert(err.message || "저장 중 오류 발생");
} finally {
setSaving(false);
}
};
const formatDate = (dateStr: string) => {
if (!dateStr) return "-";
return dateStr.split("T")[0];
};
const formatNumber = (val: string | number) => {
const num = Number(val);
return isNaN(num) ? "0" : num.toLocaleString();
};
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="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>
<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">
<ResizablePanelGroup direction="horizontal">
<ResizablePanel defaultSize={selectedId ? 65 : 100} 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">
{data.length}
</Badge>
</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>
) : (
<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-[50px] text-center">No</TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[140px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.length === 0 ? (
<TableRow>
<TableCell colSpan={11} className="h-32 text-center text-muted-foreground">
.
</TableCell>
</TableRow>
) : (
data.map((plan, idx) => (
<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"
)}
onClick={() => handleRowClick(plan)}
>
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
<Checkbox
checked={checkedIds.includes(plan.id)}
onCheckedChange={(c) => handleCheck(plan.id, c as boolean)}
disabled={plan.status === "CANCELLED"}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{idx + 1}</TableCell>
<TableCell>
<span className={cn("px-2 py-1 rounded-full text-[11px] font-medium border", getStatusColor(plan.status))}>
{getStatusLabel(plan.status)}
</span>
</TableCell>
<TableCell className="font-medium">{plan.order_no || "-"}</TableCell>
<TableCell>{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">{formatDate(plan.due_date)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
)}
</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>
</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="font-medium">{selectedPlan.shipment_plan_no || "-"}</span>
</div>
<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>
{/* 품목 정보 */}
<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>
</section>
{/* 수량 정보 */}
<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>
{/* 출하 정보 */}
<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>
{/* 등록자 정보 */}
<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>
</div>
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</div>
</div>
);
}

View File

@ -89,20 +89,6 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
// 자동화 관리 // 자동화 관리
"/admin/automaticMng/flowMgmtList": dynamic(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page"), { ssr: false, loading: LoadingFallback }), "/admin/automaticMng/flowMgmtList": dynamic(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/batchmngList": dynamic(() => import("@/app/(main)/admin/automaticMng/batchmngList/page"), { ssr: false, loading: LoadingFallback }), "/admin/automaticMng/batchmngList": dynamic(() => import("@/app/(main)/admin/automaticMng/batchmngList/page"), { ssr: false, loading: LoadingFallback }),
// 설계 관리 (커스텀 페이지)
"/design/task-management": dynamic(() => import("@/app/(main)/design/task-management/page"), { ssr: false, loading: LoadingFallback }),
"/design/my-work": dynamic(() => import("@/app/(main)/design/my-work/page"), { ssr: false, loading: LoadingFallback }),
// 영업 관리 (커스텀 페이지)
"/sales/shipping-plan": dynamic(() => import("@/app/(main)/sales/shipping-plan/page"), { ssr: false, loading: LoadingFallback }),
"/sales/shipping-order": dynamic(() => import("@/app/(main)/sales/shipping-order/page"), { ssr: false, loading: LoadingFallback }),
// 물류 관리 (커스텀 페이지)
"/logistics/material-status": dynamic(() => import("@/app/(main)/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
// 설계 관리 (커스텀 페이지)
"/design/change-management": dynamic(() => import("@/app/(main)/design/change-management/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/exconList": dynamic(() => import("@/app/(main)/admin/automaticMng/exconList/page"), { ssr: false, loading: LoadingFallback }), "/admin/automaticMng/exconList": dynamic(() => import("@/app/(main)/admin/automaticMng/exconList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/exCallConfList": dynamic(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page"), { ssr: false, loading: LoadingFallback }), "/admin/automaticMng/exCallConfList": dynamic(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page"), { ssr: false, loading: LoadingFallback }),

View File

@ -428,14 +428,6 @@ function AppLayoutInner({ children }: AppLayoutProps) {
return; return;
} }
// 4) 커스텀 페이지 URL (React 직접 구현 페이지) → admin 탭으로 렌더링
if (menu.url && menu.url !== "#" && !menu.url.startsWith("/screen/") && !menu.url.startsWith("/screens/")) {
console.log("[handleMenuClick] → 커스텀 페이지 탭:", menu.url);
openTab({ type: "admin", title: menuName, adminUrl: menu.url });
if (isMobile) setSidebarOpen(false);
return;
}
console.warn("[handleMenuClick] 어떤 조건에도 매칭 안 됨:", { menuName, menuType: menu.menuType, url: menu.url, screenId: menu.screenId }); console.warn("[handleMenuClick] 어떤 조건에도 매칭 안 됨:", { menuName, menuType: menu.menuType, url: menu.url, screenId: menu.screenId });
toast.warning("이 메뉴에 할당된 화면이 없습니다. 메뉴 설정을 확인해주세요."); toast.warning("이 메뉴에 할당된 화면이 없습니다. 메뉴 설정을 확인해주세요.");
}; };

View File

@ -1,330 +0,0 @@
import { apiClient, ApiResponse } from "./client";
// ============================================
// 설계의뢰/설변요청 (DR/ECR)
// ============================================
export async function getDesignRequestList(params?: {
source_type?: string;
status?: string;
priority?: string;
search?: string;
}): Promise<ApiResponse<any[]>> {
try {
const res = await apiClient.get("/design/requests", { params });
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function getDesignRequestDetail(id: string): Promise<ApiResponse<any>> {
try {
const res = await apiClient.get(`/design/requests/${id}`);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function createDesignRequest(data: any): Promise<ApiResponse<any>> {
try {
const res = await apiClient.post("/design/requests", data);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function updateDesignRequest(id: string, data: any): Promise<ApiResponse<any>> {
try {
const res = await apiClient.put(`/design/requests/${id}`, data);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function deleteDesignRequest(id: string): Promise<ApiResponse<any>> {
try {
const res = await apiClient.delete(`/design/requests/${id}`);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function addRequestHistory(id: string, data: { step: string; history_date: string; user_name: string; description: string }): Promise<ApiResponse<any>> {
try {
const res = await apiClient.post(`/design/requests/${id}/history`, data);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
// ============================================
// 설계 프로젝트
// ============================================
export async function getProjectList(params?: {
status?: string;
search?: string;
}): Promise<ApiResponse<any[]>> {
try {
const res = await apiClient.get("/design/projects", { params });
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function getProjectDetail(id: string): Promise<ApiResponse<any>> {
try {
const res = await apiClient.get(`/design/projects/${id}`);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function createProject(data: any): Promise<ApiResponse<any>> {
try {
const res = await apiClient.post("/design/projects", data);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function updateProject(id: string, data: any): Promise<ApiResponse<any>> {
try {
const res = await apiClient.put(`/design/projects/${id}`, data);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function deleteProject(id: string): Promise<ApiResponse<any>> {
try {
const res = await apiClient.delete(`/design/projects/${id}`);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
// ============================================
// 프로젝트 태스크
// ============================================
export async function getTasksByProject(projectId: string): Promise<ApiResponse<any[]>> {
try {
const res = await apiClient.get(`/design/projects/${projectId}/tasks`);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function createTask(projectId: string, data: any): Promise<ApiResponse<any>> {
try {
const res = await apiClient.post(`/design/projects/${projectId}/tasks`, data);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function updateTask(taskId: string, data: any): Promise<ApiResponse<any>> {
try {
const res = await apiClient.put(`/design/tasks/${taskId}`, data);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function deleteTask(taskId: string): Promise<ApiResponse<any>> {
try {
const res = await apiClient.delete(`/design/tasks/${taskId}`);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
// ============================================
// 작업일지
// ============================================
export async function getWorkLogsByTask(taskId: string): Promise<ApiResponse<any[]>> {
try {
const res = await apiClient.get(`/design/tasks/${taskId}/work-logs`);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function createWorkLog(taskId: string, data: any): Promise<ApiResponse<any>> {
try {
const res = await apiClient.post(`/design/tasks/${taskId}/work-logs`, data);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function deleteWorkLog(workLogId: string): Promise<ApiResponse<any>> {
try {
const res = await apiClient.delete(`/design/work-logs/${workLogId}`);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
// ============================================
// 태스크 하위항목
// ============================================
export async function createSubItem(taskId: string, data: any): Promise<ApiResponse<any>> {
try {
const res = await apiClient.post(`/design/tasks/${taskId}/sub-items`, data);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function updateSubItem(subItemId: string, data: any): Promise<ApiResponse<any>> {
try {
const res = await apiClient.put(`/design/sub-items/${subItemId}`, data);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function deleteSubItem(subItemId: string): Promise<ApiResponse<any>> {
try {
const res = await apiClient.delete(`/design/sub-items/${subItemId}`);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
// ============================================
// 태스크 이슈
// ============================================
export async function createIssue(taskId: string, data: any): Promise<ApiResponse<any>> {
try {
const res = await apiClient.post(`/design/tasks/${taskId}/issues`, data);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function updateIssue(issueId: string, data: any): Promise<ApiResponse<any>> {
try {
const res = await apiClient.put(`/design/issues/${issueId}`, data);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
// ============================================
// ECN (설변통보)
// ============================================
export async function getEcnList(params?: {
status?: string;
search?: string;
}): Promise<ApiResponse<any[]>> {
try {
const res = await apiClient.get("/design/ecn", { params });
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function createEcn(data: any): Promise<ApiResponse<any>> {
try {
const res = await apiClient.post("/design/ecn", data);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function updateEcn(id: string, data: any): Promise<ApiResponse<any>> {
try {
const res = await apiClient.put(`/design/ecn/${id}`, data);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function deleteEcn(id: string): Promise<ApiResponse<any>> {
try {
const res = await apiClient.delete(`/design/ecn/${id}`);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
// ============================================
// 나의 업무 (My Work)
// ============================================
export async function getMyWork(params?: {
status?: string;
project_id?: string;
}): Promise<ApiResponse<any[]>> {
try {
const res = await apiClient.get("/design/my-work", { params });
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
// ============================================
// 구매요청 / 협업요청
// ============================================
export async function createPurchaseReq(workLogId: string, data: any): Promise<ApiResponse<any>> {
try {
const res = await apiClient.post(`/design/work-logs/${workLogId}/purchase-reqs`, data);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function createCoopReq(workLogId: string, data: any): Promise<ApiResponse<any>> {
try {
const res = await apiClient.post(`/design/work-logs/${workLogId}/coop-reqs`, data);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function addCoopResponse(coopReqId: string, data: any): Promise<ApiResponse<any>> {
try {
const res = await apiClient.post(`/design/coop-reqs/${coopReqId}/responses`, data);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}

View File

@ -58,102 +58,3 @@ export async function batchSaveShippingPlans(
const res = await apiClient.post("/shipping-plan/batch", { plans, source }); const res = await apiClient.post("/shipping-plan/batch", { plans, source });
return res.data as { success: boolean; message?: string; data?: any }; return res.data as { success: boolean; message?: string; data?: any };
} }
// 출하계획 목록 조회 (관리 화면용)
export interface ShipmentPlanListItem {
id: number;
plan_date: string;
plan_qty: string;
status: string;
memo: string | null;
shipment_plan_no: string | null;
created_date: string;
created_by: string;
detail_id: string | null;
sales_order_id: number | null;
remain_qty: string | null;
order_no: string;
part_code: string;
part_name: string;
spec: string;
material: string;
customer_name: string;
partner_code: string;
due_date: string;
order_qty: string;
shipped_qty: string;
}
export interface ShipmentPlanListParams {
dateFrom?: string;
dateTo?: string;
status?: string;
customer?: string;
keyword?: string;
}
export async function getShipmentPlanList(params: ShipmentPlanListParams) {
const res = await apiClient.get("/shipping-plan/list", { params });
return res.data as { success: boolean; data: ShipmentPlanListItem[] };
}
// 출하계획 단건 수정
export async function updateShipmentPlan(
id: number,
data: { planQty?: number; planDate?: string; memo?: string }
) {
const res = await apiClient.put(`/shipping-plan/${id}`, data);
return res.data as { success: boolean; data?: any; message?: string };
}
// ─── 출하지시 API ───
export async function getShippingOrderList(params?: {
dateFrom?: string;
dateTo?: string;
status?: string;
customer?: string;
keyword?: string;
}) {
const res = await apiClient.get("/shipping-order/list", { params });
return res.data as { success: boolean; data: any[] };
}
export async function saveShippingOrder(data: any) {
const res = await apiClient.post("/shipping-order/save", data);
return res.data as { success: boolean; data?: any; message?: string };
}
export async function previewShippingOrderNo() {
const res = await apiClient.get("/shipping-order/preview-no");
return res.data as { success: boolean; instructionNo: string };
}
export async function deleteShippingOrders(ids: number[]) {
const res = await apiClient.post("/shipping-order/delete", { ids });
return res.data as { success: boolean; deletedCount?: number; message?: string };
}
// 모달 데이터 소스 (페이징 지원)
export interface PaginatedSourceResponse {
success: boolean;
data: any[];
totalCount: number;
page: number;
pageSize: number;
}
export async function getShipmentPlanSource(params?: { keyword?: string; customer?: string; page?: number; pageSize?: number }) {
const res = await apiClient.get("/shipping-order/source/shipment-plan", { params });
return res.data as PaginatedSourceResponse;
}
export async function getSalesOrderSource(params?: { keyword?: string; customer?: string; page?: number; pageSize?: number }) {
const res = await apiClient.get("/shipping-order/source/sales-order", { params });
return res.data as PaginatedSourceResponse;
}
export async function getItemSource(params?: { keyword?: string; page?: number; pageSize?: number }) {
const res = await apiClient.get("/shipping-order/source/item", { params });
return res.data as PaginatedSourceResponse;
}

View File

@ -266,6 +266,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@ -307,6 +308,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -340,6 +342,7 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@ -3055,6 +3058,7 @@
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz", "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==", "integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.17.8", "@babel/runtime": "^7.17.8",
"@types/react-reconciler": "^0.32.0", "@types/react-reconciler": "^0.32.0",
@ -3708,6 +3712,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==", "integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@tanstack/query-core": "5.90.6" "@tanstack/query-core": "5.90.6"
}, },
@ -3802,6 +3807,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==", "integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
@ -4115,6 +4121,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz", "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==", "integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"prosemirror-changeset": "^2.3.0", "prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1", "prosemirror-collab": "^1.3.1",
@ -6615,6 +6622,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@ -6625,6 +6633,7 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@ -6667,6 +6676,7 @@
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz", "resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==", "integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0", "@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3", "@tweenjs/tween.js": "~23.1.3",
@ -6749,6 +6759,7 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2", "@typescript-eslint/types": "8.46.2",
@ -7381,6 +7392,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -8531,7 +8543,8 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/d3": { "node_modules/d3": {
"version": "7.9.0", "version": "7.9.0",
@ -8853,6 +8866,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -9612,6 +9626,7 @@
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==", "integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@ -9700,6 +9715,7 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"eslint-config-prettier": "bin/cli.js" "eslint-config-prettier": "bin/cli.js"
}, },
@ -9801,6 +9817,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@ -10972,6 +10989,7 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/immer" "url": "https://opencollective.com/immer"
@ -11752,7 +11770,8 @@
"version": "1.9.4", "version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause",
"peer": true
}, },
"node_modules/levn": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
@ -13091,6 +13110,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@ -13384,6 +13404,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"orderedmap": "^2.0.0" "orderedmap": "^2.0.0"
} }
@ -13413,6 +13434,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"prosemirror-model": "^1.0.0", "prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0", "prosemirror-transform": "^1.0.0",
@ -13461,6 +13483,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz", "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==", "integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"prosemirror-model": "^1.20.0", "prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0", "prosemirror-state": "^1.0.0",
@ -13664,6 +13687,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -13733,6 +13757,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.26.0" "scheduler": "^0.26.0"
}, },
@ -13783,6 +13808,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
@ -13815,7 +13841,8 @@
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/react-leaflet": { "node_modules/react-leaflet": {
"version": "5.0.0", "version": "5.0.0",
@ -14123,6 +14150,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/use-sync-external-store": "^0.0.6", "@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0" "use-sync-external-store": "^1.4.0"
@ -14145,7 +14173,8 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/recharts/node_modules/redux-thunk": { "node_modules/recharts/node_modules/redux-thunk": {
"version": "3.1.0", "version": "3.1.0",
@ -15175,7 +15204,8 @@
"version": "0.180.0", "version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/three-mesh-bvh": { "node_modules/three-mesh-bvh": {
"version": "0.8.3", "version": "0.8.3",
@ -15263,6 +15293,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -15611,6 +15642,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"