Compare commits
2 Commits
2690a3fe66
...
32f358c720
| Author | SHA1 | Date |
|---|---|---|
|
|
32f358c720 | |
|
|
160b78e70f |
|
|
@ -144,6 +144,8 @@ import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트
|
|||
import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력
|
||||
import moldRoutes from "./routes/moldRoutes"; // 금형 관리
|
||||
import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 관리
|
||||
import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시 관리
|
||||
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -337,6 +339,8 @@ app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작
|
|||
app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력
|
||||
app.use("/api/mold", moldRoutes); // 금형 관리
|
||||
app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리
|
||||
app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리
|
||||
app.use("/api/design", designRoutes); // 설계 모듈
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
|
|
|
|||
|
|
@ -0,0 +1,946 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,482 @@
|
|||
/**
|
||||
* 출하지시 컨트롤러 (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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -144,6 +144,218 @@ 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) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
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;
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* 출하지시 라우트
|
||||
*/
|
||||
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;
|
||||
|
|
@ -10,10 +10,16 @@ const router = Router();
|
|||
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 출하계획 목록 조회 (관리 화면용)
|
||||
router.get("/list", shippingPlanController.getList);
|
||||
|
||||
// 품목별 집계 + 기존 출하계획 조회
|
||||
router.get("/aggregate", shippingPlanController.getAggregate);
|
||||
|
||||
// 출하계획 일괄 저장
|
||||
router.post("/batch", shippingPlanController.batchSave);
|
||||
|
||||
// 출하계획 단건 수정
|
||||
router.put("/:id", shippingPlanController.updatePlan);
|
||||
|
||||
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
|
|
@ -0,0 +1,609 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,893 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,826 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,530 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -89,6 +89,20 @@ 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/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/exCallConfList": dynamic(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
|
|
|
|||
|
|
@ -428,6 +428,14 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
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 });
|
||||
toast.warning("이 메뉴에 할당된 화면이 없습니다. 메뉴 설정을 확인해주세요.");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,330 @@
|
|||
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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -58,3 +58,102 @@ export async function batchSaveShippingPlans(
|
|||
const res = await apiClient.post("/shipping-plan/batch", { plans, source });
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -266,7 +266,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
|
|
@ -308,7 +307,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -342,7 +340,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
|
|
@ -3058,7 +3055,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
|
||||
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.17.8",
|
||||
"@types/react-reconciler": "^0.32.0",
|
||||
|
|
@ -3712,7 +3708,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
|
||||
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.6"
|
||||
},
|
||||
|
|
@ -3807,7 +3802,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
|
||||
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
|
|
@ -4121,7 +4115,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
|
||||
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.3.0",
|
||||
"prosemirror-collab": "^1.3.1",
|
||||
|
|
@ -6622,7 +6615,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
|
|
@ -6633,7 +6625,6 @@
|
|||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
|
|
@ -6676,7 +6667,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
|
|
@ -6759,7 +6749,6 @@
|
|||
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.2",
|
||||
"@typescript-eslint/types": "8.46.2",
|
||||
|
|
@ -7392,7 +7381,6 @@
|
|||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -8543,8 +8531,7 @@
|
|||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3": {
|
||||
"version": "7.9.0",
|
||||
|
|
@ -8866,7 +8853,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -9626,7 +9612,6 @@
|
|||
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
|
|
@ -9715,7 +9700,6 @@
|
|||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
|
|
@ -9817,7 +9801,6 @@
|
|||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
|
|
@ -10989,7 +10972,6 @@
|
|||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
|
|
@ -11770,8 +11752,7 @@
|
|||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
|
|
@ -13110,7 +13091,6 @@
|
|||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
|
|
@ -13404,7 +13384,6 @@
|
|||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
|
|
@ -13434,7 +13413,6 @@
|
|||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
|
|
@ -13483,7 +13461,6 @@
|
|||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
|
||||
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.20.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
|
|
@ -13687,7 +13664,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -13757,7 +13733,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
|
|
@ -13808,7 +13783,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
||||
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
|
|
@ -13841,8 +13815,7 @@
|
|||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0",
|
||||
|
|
@ -14150,7 +14123,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
|
|
@ -14173,8 +14145,7 @@
|
|||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/recharts/node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
|
|
@ -15204,8 +15175,7 @@
|
|||
"version": "0.180.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/three-mesh-bvh": {
|
||||
"version": "0.8.3",
|
||||
|
|
@ -15293,7 +15263,6 @@
|
|||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -15642,7 +15611,6 @@
|
|||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
|
|||
Loading…
Reference in New Issue