feat(momo): 매입/입고/출고/정산 메뉴 분리 + secret-free 자동배포
Deploy momo-erp via webhook / deploy (push) Failing after 0s
Deploy momo-erp via webhook / deploy (push) Failing after 0s
기능: - 매입처(vendor) 마스터 + API - 매입 발주(procurement) 작성/목록/상세 + API - 입고 처리(inbound): 매입발주 라인 자동 로드 또는 단독 입고 - 정상/불량 수량 분리 입력, 정상만 재고 +, 불량 사유 기록 - 출고 관리: 상태 라벨 변경 (REQUESTED→출고요청, APPROVED→출고완료, PAID→입금완료, INVOICED→계산서발행) - 입금 관리 페이지 (부분/전액 입금 등록 → 완납 시 자동 PAID 전환) - 계산서 일괄 발행 페이지 (체크박스 멀티 선택) - 일자별 매출 통계 + 막대 그래프 - 원가/마진 통계 (월간 품목별, 마진율 표시) - 사이드바 그룹 재구성 (마스터/매입/출고-정산/통계) - 랜딩 페이지에 5단계 업무 흐름 다이어그램 추가 - DB v2 마이그레이션: 입고 헤더/라인 + 매입발주에 정상/불량 컬럼 CI/CD: - secret-free webhook 자동 배포로 전환 (시크릿 등록 불필요) - /api/deploy/webhook 엔드포인트가 X-Deploy-Token 검증 후 deploy.sh 실행 - docker-compose.prod.yml에 docker.sock + 소스 마운트 (자가 배포 가능) - workflow는 단순히 webhook curl + 헬스체크 폴링 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+17
-24
@@ -1,26 +1,19 @@
|
||||
# FITO 개발환경 설정
|
||||
DATABASE_URL="postgresql://momo_app:qlalfqjsgh11@183.99.177.40:5432/distribution"
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="2b1f94cca798f49ff62822b01617503b019d118df9d249ee61f835a7dca1946e"
|
||||
NEXT_PUBLIC_APP_NAME="유통관리 ERP"
|
||||
NEXT_PUBLIC_COMPANY_NAME="모모유통"
|
||||
MASTER_PWD="qlalfqjsgh11"
|
||||
AES_KEY="ILJIAESSECRETKEY"
|
||||
FILE_STORAGE_PATH="/data_storage"
|
||||
LOG_LEVEL=info
|
||||
|
||||
# 애플리케이션 환경
|
||||
NODE_ENV=development
|
||||
SMTP_HOST=mail.coa-soft.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=chpark@coa-soft.com
|
||||
SMTP_PASS=1321Qkrckd!!!!!!
|
||||
SMTP_FROM=모모유통 <chpark@coa-soft.com>
|
||||
|
||||
# 데이터베이스 설정
|
||||
DB_URL=jdbc:postgresql://211.115.91.141:11140/fito
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=intops0909!!
|
||||
|
||||
# PostgreSQL 환경 변수 (내부 DB 사용 시)
|
||||
POSTGRES_DB=fito
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=intops0909!!
|
||||
|
||||
# 애플리케이션 포트
|
||||
APP_PORT=8090
|
||||
|
||||
# JVM 옵션
|
||||
JAVA_OPTS=-Xms512m -Xmx1024m -XX:PermSize=256m -XX:MaxPermSize=512m
|
||||
|
||||
# 로그 레벨
|
||||
LOG_LEVEL=DEBUG
|
||||
|
||||
# 개발 모드 플래그
|
||||
DEBUG=true
|
||||
MOMO_BANK_ACCOUNT=기업은행 434-115361-01-016
|
||||
MOMO_PHONE=010-6624-5315
|
||||
DEPLOY_WEBHOOK_TOKEN=momo-deploy-2026-secure
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
-- ============================================================================
|
||||
-- 모모유통 v2 — 매입발주/입고/정산 메뉴 분리
|
||||
-- - 매입발주 입고 시 불량/파손 수량 분리
|
||||
-- - 출고관리 상태값 재정의 (REQUESTED → APPROVED(=출고완료) → PAID → INVOICED)
|
||||
-- - 매입처에 추가 정보
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 매입발주 입고 라인에 정상/불량/파손 수량 분리
|
||||
ALTER TABLE momo_procurement_items
|
||||
ADD COLUMN IF NOT EXISTS received_normal NUMERIC(15,2) DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS received_defect NUMERIC(15,2) DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS defect_memo VARCHAR(500);
|
||||
|
||||
-- 입고 처리 헤더 (1매입발주 → N입고)
|
||||
CREATE TABLE IF NOT EXISTS momo_inbounds (
|
||||
objid TEXT PRIMARY KEY,
|
||||
inbound_no VARCHAR(50) UNIQUE,
|
||||
proc_objid TEXT, -- 매입발주 참조 (없어도 단독 입고 가능)
|
||||
vendor_objid TEXT,
|
||||
wh_objid TEXT NOT NULL,
|
||||
inbound_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
status VARCHAR(20) DEFAULT 'COMPLETED', -- COMPLETED | CANCELLED
|
||||
total_amount NUMERIC(15,2) DEFAULT 0,
|
||||
memo TEXT,
|
||||
is_del CHAR(1) DEFAULT 'N',
|
||||
regdate TIMESTAMP DEFAULT NOW(),
|
||||
regid TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_inbounds_date ON momo_inbounds(inbound_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_inbounds_proc ON momo_inbounds(proc_objid);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS momo_inbound_items (
|
||||
objid TEXT PRIMARY KEY,
|
||||
inbound_objid TEXT NOT NULL,
|
||||
item_objid TEXT NOT NULL,
|
||||
qty_normal NUMERIC(15,2) NOT NULL DEFAULT 0, -- 입고 정상 수량 → 재고에 +
|
||||
qty_defect NUMERIC(15,2) NOT NULL DEFAULT 0, -- 불량/파손 (재고 미반영)
|
||||
cost_price NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||
defect_reason VARCHAR(200), -- 파손/유통기한임박/불량 등
|
||||
total_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||
seq INT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_inbound_items ON momo_inbound_items(inbound_objid);
|
||||
|
||||
-- 매입처에 주소/이메일 추가
|
||||
ALTER TABLE momo_vendors
|
||||
ADD COLUMN IF NOT EXISTS email VARCHAR(200),
|
||||
ADD COLUMN IF NOT EXISTS address VARCHAR(300),
|
||||
ADD COLUMN IF NOT EXISTS regdate TIMESTAMP DEFAULT NOW();
|
||||
|
||||
-- 품목에 소비기한 / 본사+지사 구분 (엑셀 요청 7,8번)
|
||||
-- attributes JSONB 에 자유 키 저장 가능. 별도 컬럼 추가는 생략.
|
||||
|
||||
-- 매입처 시드 (없으면)
|
||||
INSERT INTO momo_vendors (objid, vendor_name, contact, phone)
|
||||
VALUES
|
||||
('VND_DEFAULT_001', '도매처A (기본)', '담당자', '02-0000-0000'),
|
||||
('VND_DEFAULT_002', '도매처B (기본)', '담당자', '02-0000-0000')
|
||||
ON CONFLICT (objid) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { queryRows } from "@/lib/db";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
const { dateFrom, dateTo } = await req.json().catch(() => ({}));
|
||||
const conds: string[] = ["COALESCE(I.is_del,'N') != 'Y'"];
|
||||
const params: unknown[] = [];
|
||||
let i = 1;
|
||||
if (dateFrom) { conds.push(`I.inbound_date >= $${i++}::date`); params.push(dateFrom); }
|
||||
if (dateTo) { conds.push(`I.inbound_date <= $${i++}::date`); params.push(dateTo); }
|
||||
|
||||
const rows = await queryRows(
|
||||
`SELECT I.objid AS "OBJID", I.inbound_no AS "INBOUND_NO",
|
||||
TO_CHAR(I.inbound_date,'YYYY-MM-DD') AS "INBOUND_DATE",
|
||||
V.vendor_name AS "VENDOR_NAME", W.wh_name AS "WH_NAME",
|
||||
I.status AS "STATUS", I.total_amount AS "TOTAL_AMOUNT",
|
||||
(SELECT COALESCE(SUM(qty_normal),0) FROM momo_inbound_items WHERE inbound_objid = I.objid) AS "QTY_NORMAL",
|
||||
(SELECT COALESCE(SUM(qty_defect),0) FROM momo_inbound_items WHERE inbound_objid = I.objid) AS "QTY_DEFECT",
|
||||
P.proc_no AS "PROC_NO"
|
||||
FROM momo_inbounds I
|
||||
LEFT JOIN momo_vendors V ON I.vendor_objid = V.objid
|
||||
LEFT JOIN momo_warehouses W ON I.wh_objid = W.objid
|
||||
LEFT JOIN momo_procurements P ON I.proc_objid = P.objid
|
||||
WHERE ${conds.join(" AND ")}
|
||||
ORDER BY I.inbound_date DESC, I.regdate DESC LIMIT 500`,
|
||||
params
|
||||
);
|
||||
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// 입고 처리: 매입발주 라인의 정상/불량 수량을 기록 + 정상 수량만 재고 +
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { pool, queryOne } from "@/lib/db";
|
||||
import { createObjectId } from "@/lib/utils";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
|
||||
interface Line {
|
||||
itemObjid: string;
|
||||
qtyNormal: number;
|
||||
qtyDefect: number;
|
||||
costPrice: number;
|
||||
defectReason?: string;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
const adminId = g.user.objid || g.user.userId;
|
||||
|
||||
const { procObjid, vendorObjid, whObjid, inboundDate, lines, memo } = await req.json() as {
|
||||
procObjid?: string; vendorObjid?: string; whObjid: string; inboundDate?: string;
|
||||
lines: Line[]; memo?: string;
|
||||
};
|
||||
if (!whObjid || !Array.isArray(lines) || lines.length === 0) {
|
||||
return NextResponse.json({ success: false, message: "창고와 입고 라인이 필요합니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
const inboundObjid = createObjectId();
|
||||
const inboundNo = await genInboundNo();
|
||||
let total = 0;
|
||||
for (const ln of lines) total += Math.round(Number(ln.costPrice) * (Number(ln.qtyNormal) + Number(ln.qtyDefect)));
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
await client.query(
|
||||
`INSERT INTO momo_inbounds (objid, inbound_no, proc_objid, vendor_objid, wh_objid, inbound_date, status, total_amount, memo, regdate, regid)
|
||||
VALUES ($1,$2,$3,$4,$5,COALESCE($6::date, CURRENT_DATE),'COMPLETED',$7,$8,NOW(),$9)`,
|
||||
[inboundObjid, inboundNo, procObjid ?? null, vendorObjid ?? null, whObjid, inboundDate ?? null, total, memo ?? null, adminId]
|
||||
);
|
||||
|
||||
let seq = 0;
|
||||
for (const ln of lines) {
|
||||
seq++;
|
||||
const qtyN = Number(ln.qtyNormal) || 0;
|
||||
const qtyD = Number(ln.qtyDefect) || 0;
|
||||
const lineTotal = Math.round(Number(ln.costPrice) * (qtyN + qtyD));
|
||||
await client.query(
|
||||
`INSERT INTO momo_inbound_items (objid, inbound_objid, item_objid, qty_normal, qty_defect, cost_price, defect_reason, total_amount, seq)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||||
[createObjectId(), inboundObjid, ln.itemObjid, qtyN, qtyD, ln.costPrice, ln.defectReason ?? null, lineTotal, seq]
|
||||
);
|
||||
|
||||
// 정상 수량만 재고 +
|
||||
if (qtyN > 0) {
|
||||
await client.query(
|
||||
`INSERT INTO momo_stocks (objid, wh_objid, item_objid, qty, update_date)
|
||||
VALUES ($1,$2,$3,$4,NOW())
|
||||
ON CONFLICT (wh_objid, item_objid) DO UPDATE SET qty = momo_stocks.qty + EXCLUDED.qty, update_date = NOW()`,
|
||||
[createObjectId(), whObjid, ln.itemObjid, qtyN]
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO momo_stock_moves (objid, wh_objid, item_objid, move_type, qty, ref_type, ref_objid, regdate, regid)
|
||||
VALUES ($1,$2,$3,'IN',$4,'INBOUND',$5,NOW(),$6)`,
|
||||
[createObjectId(), whObjid, ln.itemObjid, qtyN, inboundObjid, adminId]
|
||||
);
|
||||
}
|
||||
// 매입발주 라인 received 누적
|
||||
if (procObjid) {
|
||||
await client.query(
|
||||
`UPDATE momo_procurement_items
|
||||
SET received_normal = COALESCE(received_normal,0) + $2,
|
||||
received_defect = COALESCE(received_defect,0) + $3,
|
||||
received_qty = COALESCE(received_qty,0) + $2 + $3
|
||||
WHERE proc_objid = $1 AND item_objid = $4`,
|
||||
[procObjid, qtyN, qtyD, ln.itemObjid]
|
||||
);
|
||||
}
|
||||
// 매입가 갱신 (선택)
|
||||
if (Number(ln.costPrice) > 0) {
|
||||
await client.query(`UPDATE momo_items SET cost_price = $2 WHERE objid = $1`, [ln.itemObjid, ln.costPrice]);
|
||||
}
|
||||
}
|
||||
|
||||
// 매입발주 모든 라인이 received_qty >= qty 면 status=RECEIVED
|
||||
if (procObjid) {
|
||||
const remain = await client.query(
|
||||
`SELECT COUNT(*) AS cnt FROM momo_procurement_items
|
||||
WHERE proc_objid = $1 AND COALESCE(received_qty,0) < qty`,
|
||||
[procObjid]
|
||||
);
|
||||
if (Number(remain.rows[0].cnt) === 0) {
|
||||
await client.query(`UPDATE momo_procurements SET status='RECEIVED' WHERE objid=$1`, [procObjid]);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
return NextResponse.json({ success: true, objId: inboundObjid, inboundNo });
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
console.error("[inbound/save]", err);
|
||||
return NextResponse.json({ success: false, message: "입고 저장 오류" }, { status: 500 });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function genInboundNo(): Promise<string> {
|
||||
const today = new Date();
|
||||
const ymd = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, "0")}${String(today.getDate()).padStart(2, "0")}`;
|
||||
const prefix = `INB-${ymd}-`;
|
||||
const row = await queryOne<{ MAX_NO: string }>(
|
||||
`SELECT COALESCE(MAX(inbound_no), '') AS "MAX_NO" FROM momo_inbounds WHERE inbound_no LIKE $1 || '%'`,
|
||||
[prefix]
|
||||
);
|
||||
const lastNum = row?.MAX_NO ? Number(row.MAX_NO.replace(prefix, "")) || 0 : 0;
|
||||
return prefix + String(lastNum + 1).padStart(4, "0");
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// 계산서 발행 일괄 처리
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { execute, queryOne } from "@/lib/db";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
const { objids } = await req.json();
|
||||
if (!Array.isArray(objids) || objids.length === 0) {
|
||||
return NextResponse.json({ success: false, message: "발행할 항목이 없습니다." }, { status: 400 });
|
||||
}
|
||||
const today = new Date();
|
||||
const ym = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, "0")}`;
|
||||
const prefix = `INV-${ym}-`;
|
||||
const last = await queryOne<{ MAX_NO: string }>(
|
||||
`SELECT COALESCE(MAX(invoice_no),'') AS "MAX_NO" FROM momo_orders WHERE invoice_no LIKE $1 || '%'`,
|
||||
[prefix]
|
||||
);
|
||||
let lastNum = last?.MAX_NO ? Number(last.MAX_NO.replace(prefix, "")) || 0 : 0;
|
||||
|
||||
for (const id of objids) {
|
||||
lastNum++;
|
||||
const invNo = prefix + String(lastNum).padStart(4, "0");
|
||||
await execute(
|
||||
`UPDATE momo_orders
|
||||
SET status='INVOICED', invoice_no=$2, invoice_date=CURRENT_DATE, update_date=NOW()
|
||||
WHERE objid=$1 AND status IN ('APPROVED','PAID')`,
|
||||
[id, invNo]
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ success: true, count: objids.length });
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// 입금 등록 — 출고완료 → 입금완료 전환
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { execute, queryOne } from "@/lib/db";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
const { objid, paidAmount, paidDate } = await req.json();
|
||||
if (!objid) return NextResponse.json({ success: false, message: "objid 누락" }, { status: 400 });
|
||||
|
||||
const order = await queryOne<{ status: string; total_amount: string }>(
|
||||
`SELECT status, total_amount FROM momo_orders WHERE objid=$1`,
|
||||
[objid]
|
||||
);
|
||||
if (!order) return NextResponse.json({ success: false, message: "주문 없음" }, { status: 404 });
|
||||
|
||||
const total = Number(order.total_amount);
|
||||
const paid = paidAmount != null ? Number(paidAmount) : total;
|
||||
// 완납이면 PAID, 부분입금이면 그대로 APPROVED 유지
|
||||
const newStatus = paid >= total ? "PAID" : order.status;
|
||||
|
||||
await execute(
|
||||
`UPDATE momo_orders SET paid_amount = $2, paid_date = COALESCE($3::date, CURRENT_DATE), status = $4, update_date = NOW() WHERE objid = $1`,
|
||||
[objid, paid, paidDate ?? null, newStatus]
|
||||
);
|
||||
return NextResponse.json({ success: true, status: newStatus });
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { queryOne, queryRows } from "@/lib/db";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
const { objid } = await req.json();
|
||||
const proc = await queryOne(
|
||||
`SELECT P.objid AS "OBJID", P.proc_no AS "PROC_NO",
|
||||
TO_CHAR(P.proc_date,'YYYY-MM-DD') AS "PROC_DATE",
|
||||
P.status AS "STATUS", P.total_amount AS "TOTAL_AMOUNT", P.memo AS "MEMO",
|
||||
V.objid AS "VENDOR_OBJID", V.vendor_name AS "VENDOR_NAME"
|
||||
FROM momo_procurements P
|
||||
LEFT JOIN momo_vendors V ON P.vendor_objid = V.objid
|
||||
WHERE P.objid = $1`,
|
||||
[objid]
|
||||
);
|
||||
if (!proc) return NextResponse.json({ success: false, message: "찾을 수 없습니다." }, { status: 404 });
|
||||
const items = await queryRows(
|
||||
`SELECT PI.objid AS "OBJID", PI.item_objid AS "ITEM_OBJID",
|
||||
I.item_code AS "ITEM_CODE", I.item_name AS "ITEM_NAME", I.unit AS "UNIT",
|
||||
PI.qty AS "QTY", PI.cost_price AS "COST_PRICE", PI.total_amount AS "TOTAL_AMOUNT",
|
||||
PI.received_qty AS "RECEIVED_QTY",
|
||||
PI.received_normal AS "RECEIVED_NORMAL",
|
||||
PI.received_defect AS "RECEIVED_DEFECT"
|
||||
FROM momo_procurement_items PI
|
||||
JOIN momo_items I ON PI.item_objid = I.objid
|
||||
WHERE PI.proc_objid = $1
|
||||
ORDER BY PI.objid`,
|
||||
[objid]
|
||||
);
|
||||
return NextResponse.json({ success: true, proc, items });
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { queryRows } from "@/lib/db";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
const { dateFrom, dateTo, status, vendorObjid } = await req.json().catch(() => ({}));
|
||||
const conds: string[] = ["COALESCE(P.is_del,'N') != 'Y'"];
|
||||
const params: unknown[] = [];
|
||||
let i = 1;
|
||||
if (status) { conds.push(`P.status = $${i++}`); params.push(status); }
|
||||
if (vendorObjid) { conds.push(`P.vendor_objid = $${i++}`); params.push(vendorObjid); }
|
||||
if (dateFrom) { conds.push(`P.proc_date >= $${i++}::date`); params.push(dateFrom); }
|
||||
if (dateTo) { conds.push(`P.proc_date <= $${i++}::date`); params.push(dateTo); }
|
||||
|
||||
const rows = await queryRows(
|
||||
`SELECT P.objid AS "OBJID", P.proc_no AS "PROC_NO",
|
||||
TO_CHAR(P.proc_date,'YYYY-MM-DD') AS "PROC_DATE",
|
||||
P.vendor_objid AS "VENDOR_OBJID", V.vendor_name AS "VENDOR_NAME",
|
||||
P.status AS "STATUS", P.total_amount AS "TOTAL_AMOUNT", P.memo AS "MEMO",
|
||||
(SELECT COUNT(*) FROM momo_procurement_items WHERE proc_objid = P.objid) AS "LINE_CNT"
|
||||
FROM momo_procurements P
|
||||
LEFT JOIN momo_vendors V ON P.vendor_objid = V.objid
|
||||
WHERE ${conds.join(" AND ")}
|
||||
ORDER BY P.proc_date DESC, P.regdate DESC LIMIT 500`,
|
||||
params
|
||||
);
|
||||
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { pool, queryOne } from "@/lib/db";
|
||||
import { createObjectId } from "@/lib/utils";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
|
||||
interface Line { itemObjid: string; qty: number; costPrice: number }
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
const adminId = g.user.objid || g.user.userId;
|
||||
|
||||
const { vendorObjid, procDate, lines, memo } = await req.json() as {
|
||||
vendorObjid?: string; procDate?: string; lines: Line[]; memo?: string;
|
||||
};
|
||||
if (!Array.isArray(lines) || lines.length === 0) {
|
||||
return NextResponse.json({ success: false, message: "발주 라인을 입력하세요." }, { status: 400 });
|
||||
}
|
||||
|
||||
const procObjid = createObjectId();
|
||||
const procNo = await genProcNo();
|
||||
let total = 0;
|
||||
for (const ln of lines) total += Math.round(Number(ln.costPrice) * Number(ln.qty));
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
await client.query(
|
||||
`INSERT INTO momo_procurements (objid, proc_no, vendor_objid, proc_date, status, total_amount, memo, regdate)
|
||||
VALUES ($1,$2,$3,COALESCE($4::date, CURRENT_DATE),'OPEN',$5,$6,NOW())`,
|
||||
[procObjid, procNo, vendorObjid ?? null, procDate ?? null, total, memo ?? null]
|
||||
);
|
||||
let seq = 0;
|
||||
for (const ln of lines) {
|
||||
seq++;
|
||||
const lineTotal = Math.round(Number(ln.costPrice) * Number(ln.qty));
|
||||
await client.query(
|
||||
`INSERT INTO momo_procurement_items (objid, proc_objid, item_objid, cost_price, qty, total_amount, received_qty, received_normal, received_defect)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,0,0,0)`,
|
||||
[createObjectId(), procObjid, ln.itemObjid, ln.costPrice, ln.qty, lineTotal]
|
||||
);
|
||||
}
|
||||
await client.query("COMMIT");
|
||||
return NextResponse.json({ success: true, objId: procObjid, procNo });
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
console.error("[procurement/save]", err);
|
||||
return NextResponse.json({ success: false, message: "발주 저장 오류" }, { status: 500 });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function genProcNo(): Promise<string> {
|
||||
const today = new Date();
|
||||
const ymd = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, "0")}${String(today.getDate()).padStart(2, "0")}`;
|
||||
const prefix = `PRC-${ymd}-`;
|
||||
const row = await queryOne<{ MAX_NO: string }>(
|
||||
`SELECT COALESCE(MAX(proc_no), '') AS "MAX_NO" FROM momo_procurements WHERE proc_no LIKE $1 || '%'`,
|
||||
[prefix]
|
||||
);
|
||||
const lastNum = row?.MAX_NO ? Number(row.MAX_NO.replace(prefix, "")) || 0 : 0;
|
||||
return prefix + String(lastNum + 1).padStart(4, "0");
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { queryRows } from "@/lib/db";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
const { dateFrom, dateTo } = await req.json();
|
||||
const rows = await queryRows(
|
||||
`SELECT TO_CHAR(O.order_date,'YYYY-MM-DD') AS "DAY",
|
||||
COUNT(*) AS "ORDER_CNT",
|
||||
COALESCE(SUM(O.total_amount),0) AS "TOTAL",
|
||||
COALESCE(SUM(O.total_taxfree),0) AS "TAX_FREE",
|
||||
COALESCE(SUM(O.total_taxable),0) AS "TAXABLE"
|
||||
FROM momo_orders O
|
||||
WHERE O.order_date BETWEEN $1::date AND $2::date
|
||||
AND O.status IN ('APPROVED','PAID','INVOICED')
|
||||
AND COALESCE(O.is_del,'N') != 'Y'
|
||||
GROUP BY O.order_date ORDER BY O.order_date ASC`,
|
||||
[dateFrom, dateTo]
|
||||
);
|
||||
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { queryRows } from "@/lib/db";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
const { year, month } = await req.json();
|
||||
const y = Number(year) || new Date().getFullYear();
|
||||
const m = Number(month) || new Date().getMonth() + 1;
|
||||
|
||||
// 월간 마진 = 매출(공급가) - 원가(qty × cost_price)
|
||||
const rows = await queryRows(
|
||||
`SELECT
|
||||
I.item_code AS "ITEM_CODE",
|
||||
I.item_name AS "ITEM_NAME",
|
||||
SUM(OI.qty) AS "QTY",
|
||||
SUM(OI.supply_amount) AS "REVENUE",
|
||||
SUM(OI.qty * I.cost_price) AS "COST",
|
||||
SUM(OI.supply_amount) - SUM(OI.qty * I.cost_price) AS "MARGIN"
|
||||
FROM momo_orders O
|
||||
JOIN momo_order_items OI ON O.objid = OI.order_objid
|
||||
JOIN momo_items I ON OI.item_objid = I.objid
|
||||
WHERE EXTRACT(YEAR FROM O.order_date) = $1
|
||||
AND EXTRACT(MONTH FROM O.order_date) = $2
|
||||
AND O.status IN ('APPROVED','PAID','INVOICED')
|
||||
AND COALESCE(O.is_del,'N') != 'Y'
|
||||
GROUP BY I.item_code, I.item_name
|
||||
ORDER BY "MARGIN" DESC`,
|
||||
[y, m]
|
||||
);
|
||||
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
|
||||
}
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { queryRows } from "@/lib/db";
|
||||
import { requireMomoUser } from "@/lib/momo-guard";
|
||||
|
||||
export async function POST() {
|
||||
const r = await requireMomoUser();
|
||||
if (r instanceof NextResponse) return r;
|
||||
const rows = await queryRows(
|
||||
`SELECT objid AS "OBJID", vendor_name AS "VENDOR_NAME",
|
||||
contact AS "CONTACT", phone AS "PHONE",
|
||||
biz_no AS "BIZ_NO", email AS "EMAIL", address AS "ADDRESS"
|
||||
FROM momo_vendors WHERE COALESCE(is_del,'N') != 'Y'
|
||||
ORDER BY vendor_name ASC`
|
||||
);
|
||||
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
|
||||
}
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { execute } from "@/lib/db";
|
||||
import { createObjectId } from "@/lib/utils";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
const { objid, actionType, vendorName, contact, phone, bizNo, email, address } = await req.json();
|
||||
if (!vendorName) return NextResponse.json({ success: false, message: "매입처명 필수" }, { status: 400 });
|
||||
|
||||
if (actionType === "regist") {
|
||||
const id = createObjectId();
|
||||
await execute(
|
||||
`INSERT INTO momo_vendors (objid, vendor_name, contact, phone, biz_no, email, address, regdate)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,NOW())`,
|
||||
[id, vendorName, contact ?? null, phone ?? null, bizNo ?? null, email ?? null, address ?? null]
|
||||
);
|
||||
return NextResponse.json({ success: true, objId: id });
|
||||
}
|
||||
await execute(
|
||||
`UPDATE momo_vendors SET vendor_name=$2, contact=$3, phone=$4, biz_no=$5, email=$6, address=$7 WHERE objid=$1`,
|
||||
[objid, vendorName, contact ?? null, phone ?? null, bizNo ?? null, email ?? null, address ?? null]
|
||||
);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
interface Vendor { OBJID: string; VENDOR_NAME: string }
|
||||
interface Wh { OBJID: string; WH_NAME: string }
|
||||
interface Item { OBJID: string; ITEM_CODE: string; ITEM_NAME: string; COST_PRICE: number }
|
||||
interface Proc { OBJID: string; PROC_NO: string; VENDOR_OBJID: string; VENDOR_NAME: string }
|
||||
interface ProcLine { OBJID: string; ITEM_OBJID: string; ITEM_NAME: string; QTY: number; COST_PRICE: number; RECEIVED_QTY: number }
|
||||
interface Line { itemObjid: string; itemName: string; qtyNormal: number; qtyDefect: number; costPrice: number; defectReason?: string }
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
|
||||
export default function NewInboundPage() {
|
||||
const router = useRouter();
|
||||
const [vendors, setVendors] = useState<Vendor[]>([]);
|
||||
const [whs, setWhs] = useState<Wh[]>([]);
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [procs, setProcs] = useState<Proc[]>([]);
|
||||
|
||||
const [procObjid, setProcObjid] = useState("");
|
||||
const [vendorObjid, setVendorObjid] = useState("");
|
||||
const [whObjid, setWhObjid] = useState("");
|
||||
const [inboundDate, setInboundDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [lines, setLines] = useState<Line[]>([]);
|
||||
const [memo, setMemo] = useState("");
|
||||
|
||||
// 단일 입고 라인 입력용
|
||||
const [pickItem, setPickItem] = useState("");
|
||||
const [qtyN, setQtyN] = useState(0);
|
||||
const [qtyD, setQtyD] = useState(0);
|
||||
const [defectReason, setDefectReason] = useState("");
|
||||
const [costPrice, setCostPrice] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/m/vendors/list", { method: "POST" }).then((r) => r.json()).then((j) => setVendors(j.RESULTLIST || []));
|
||||
fetch("/api/m/warehouses/list", { method: "POST" }).then((r) => r.json()).then((j) => setWhs(j.RESULTLIST || []));
|
||||
fetch("/api/m/items/list", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }).then((r) => r.json()).then((j) => setItems(j.RESULTLIST || []));
|
||||
fetch("/api/m/procurements/list", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: "OPEN" }) }).then((r) => r.json()).then((j) => setProcs(j.RESULTLIST || []));
|
||||
}, []);
|
||||
|
||||
// 매입발주 선택 시 라인 자동 로드
|
||||
const onProcChange = async (id: string) => {
|
||||
setProcObjid(id);
|
||||
if (!id) return;
|
||||
const res = await fetch("/api/m/procurements/detail", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objid: id }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) {
|
||||
setVendorObjid(j.proc.VENDOR_OBJID || "");
|
||||
setLines((j.items as ProcLine[]).map((it) => ({
|
||||
itemObjid: it.ITEM_OBJID,
|
||||
itemName: it.ITEM_NAME,
|
||||
qtyNormal: Math.max(0, Number(it.QTY) - Number(it.RECEIVED_QTY)),
|
||||
qtyDefect: 0,
|
||||
costPrice: Number(it.COST_PRICE),
|
||||
})));
|
||||
}
|
||||
};
|
||||
|
||||
const addManual = () => {
|
||||
if (!pickItem) return;
|
||||
const it = items.find((x) => x.OBJID === pickItem);
|
||||
if (!it) return;
|
||||
setLines([...lines, {
|
||||
itemObjid: it.OBJID, itemName: it.ITEM_NAME,
|
||||
qtyNormal: qtyN, qtyDefect: qtyD,
|
||||
costPrice: costPrice || Number(it.COST_PRICE) || 0,
|
||||
defectReason: qtyD > 0 ? defectReason : undefined,
|
||||
}]);
|
||||
setPickItem(""); setQtyN(0); setQtyD(0); setDefectReason(""); setCostPrice(0);
|
||||
};
|
||||
|
||||
const update = (i: number, k: keyof Line, v: number | string) => {
|
||||
setLines(lines.map((ln, j) => j === i ? { ...ln, [k]: v } : ln));
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!whObjid) return Swal.fire({ icon: "warning", title: "창고 선택" });
|
||||
if (lines.length === 0) return Swal.fire({ icon: "warning", title: "라인 추가" });
|
||||
const res = await fetch("/api/m/inbounds/save", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ procObjid: procObjid || undefined, vendorObjid, whObjid, inboundDate, lines, memo }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) {
|
||||
await Swal.fire({ icon: "success", title: "입고 완료", text: `입고번호: ${j.inboundNo}` });
|
||||
router.push("/m/admin/inbounds");
|
||||
} else Swal.fire({ icon: "error", title: "오류", text: j.message });
|
||||
};
|
||||
|
||||
const total = lines.reduce((a, l) => a + Math.round(l.costPrice * (l.qtyNormal + l.qtyDefect)), 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-4xl">
|
||||
<h1 className="text-2xl font-bold">입고 처리</h1>
|
||||
<div className="bg-white border rounded-xl p-5 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-600">매입발주 (선택 시 라인 자동입력)</label>
|
||||
<select value={procObjid} onChange={(e) => onProcChange(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 mt-1">
|
||||
<option value="">단독 입고 (매입발주 없이)</option>
|
||||
{procs.map((p) => <option key={p.OBJID} value={p.OBJID}>{p.PROC_NO} — {p.VENDOR_NAME}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-600">매입처</label>
|
||||
<select value={vendorObjid} onChange={(e) => setVendorObjid(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 mt-1">
|
||||
<option value="">선택</option>
|
||||
{vendors.map((v) => <option key={v.OBJID} value={v.OBJID}>{v.VENDOR_NAME}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-600">입고 창고 *</label>
|
||||
<select value={whObjid} onChange={(e) => setWhObjid(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 mt-1">
|
||||
<option value="">선택</option>
|
||||
{whs.map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-600">입고일</label>
|
||||
<input type="date" value={inboundDate} onChange={(e) => setInboundDate(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-xl p-5">
|
||||
<h3 className="font-bold mb-3">입고 라인 (정상 + 불량 분리)</h3>
|
||||
<div className="grid grid-cols-12 gap-2 mb-3">
|
||||
<select value={pickItem} onChange={(e) => setPickItem(e.target.value)} className="col-span-4 h-10 px-3 rounded-lg border border-slate-200">
|
||||
<option value="">품목 선택</option>
|
||||
{items.map((i) => <option key={i.OBJID} value={i.OBJID}>{i.ITEM_NAME}</option>)}
|
||||
</select>
|
||||
<input type="number" min={0} value={qtyN} onChange={(e) => setQtyN(Number(e.target.value))} placeholder="정상" className="col-span-1 h-10 px-2 rounded-lg border border-slate-200 text-right" />
|
||||
<input type="number" min={0} value={qtyD} onChange={(e) => setQtyD(Number(e.target.value))} placeholder="불량" className="col-span-1 h-10 px-2 rounded-lg border border-slate-200 text-right" />
|
||||
<input type="text" value={defectReason} onChange={(e) => setDefectReason(e.target.value)} placeholder="불량사유" className="col-span-3 h-10 px-3 rounded-lg border border-slate-200" />
|
||||
<input type="number" min={0} value={costPrice} onChange={(e) => setCostPrice(Number(e.target.value))} placeholder="단가" className="col-span-2 h-10 px-2 rounded-lg border border-slate-200 text-right" />
|
||||
<button onClick={addManual} className="col-span-1 h-10 rounded-lg bg-slate-800 text-white text-sm font-bold flex items-center justify-center"><Plus size={14} /></button>
|
||||
</div>
|
||||
|
||||
<table className="w-full text-sm border">
|
||||
<thead className="bg-slate-50 text-xs">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">품목</th>
|
||||
<th className="px-2 py-2 text-right text-emerald-700">정상</th>
|
||||
<th className="px-2 py-2 text-right text-rose-600">불량</th>
|
||||
<th className="px-2 py-2 text-left">불량사유</th>
|
||||
<th className="px-2 py-2 text-right">단가</th>
|
||||
<th className="px-2 py-2 text-right">합계</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.length === 0 ? (
|
||||
<tr><td colSpan={7} className="text-center py-6 text-slate-400">매입발주 선택 또는 라인 추가</td></tr>
|
||||
) : lines.map((ln, i) => (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="px-3 py-1.5 text-sm">{ln.itemName}</td>
|
||||
<td className="px-1"><input type="number" min={0} value={ln.qtyNormal} onChange={(e) => update(i, "qtyNormal", Number(e.target.value))} className="w-20 h-8 px-2 text-right border border-emerald-200 rounded text-emerald-700 font-semibold" /></td>
|
||||
<td className="px-1"><input type="number" min={0} value={ln.qtyDefect} onChange={(e) => update(i, "qtyDefect", Number(e.target.value))} className="w-20 h-8 px-2 text-right border border-rose-200 rounded text-rose-700 font-semibold" /></td>
|
||||
<td className="px-1"><input type="text" value={ln.defectReason ?? ""} onChange={(e) => update(i, "defectReason", e.target.value)} className="w-full h-8 px-2 border border-slate-200 rounded text-xs" placeholder="-" /></td>
|
||||
<td className="px-1"><input type="number" min={0} value={ln.costPrice} onChange={(e) => update(i, "costPrice", Number(e.target.value))} className="w-24 h-8 px-2 text-right border border-slate-200 rounded" /></td>
|
||||
<td className="px-2 py-1.5 text-right tabular-nums font-bold">₩{fmt(Math.round(ln.costPrice * (ln.qtyNormal + ln.qtyDefect)))}</td>
|
||||
<td className="px-2 py-1.5 text-right"><button onClick={() => setLines(lines.filter((_, j) => j !== i))} className="text-slate-400 hover:text-rose-500"><Trash2 size={14} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{lines.length > 0 && (
|
||||
<tfoot className="bg-slate-50">
|
||||
<tr><td colSpan={5} className="px-3 py-2 text-right font-bold">합계</td><td className="px-2 py-2 text-right font-bold tabular-nums text-emerald-700">₩{fmt(total)}</td><td></td></tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
<textarea placeholder="메모 (선택)" value={memo} onChange={(e) => setMemo(e.target.value)} rows={2} className="w-full mt-4 px-3 py-2 rounded-lg border border-slate-200 text-sm" />
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded p-3 text-xs text-amber-800 mt-3">
|
||||
ⓘ 정상 수량만 재고에 +됩니다. 불량 수량은 별도 기록되어 재고 미반영.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => router.back()} className="px-5 h-11 rounded-xl border border-slate-200 font-semibold">취소</button>
|
||||
<button onClick={submit} className="px-6 h-11 rounded-xl bg-emerald-700 text-white font-bold">입고 처리</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
interface Inbound { OBJID: string; INBOUND_NO: string; INBOUND_DATE: string; VENDOR_NAME: string; WH_NAME: string; PROC_NO: string; STATUS: string; QTY_NORMAL: number; QTY_DEFECT: number; TOTAL_AMOUNT: number }
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
|
||||
export default function InboundsPage() {
|
||||
const [list, setList] = useState<Inbound[]>([]);
|
||||
|
||||
const load = async () => {
|
||||
const res = await fetch("/api/m/inbounds/list", { method: "POST", body: "{}", headers: { "Content-Type": "application/json" } });
|
||||
setList((await res.json()).RESULTLIST ?? []);
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">입고 처리</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">매입발주 후 도매처에서 받은 물품을 창고에 입고. 정상/불량 분리 기록.</p>
|
||||
</div>
|
||||
<Link href="/m/admin/inbounds/new" className="px-4 h-10 inline-flex items-center gap-2 rounded-lg bg-emerald-700 text-white text-sm font-bold">
|
||||
<Plus size={16} /> 입고 처리
|
||||
</Link>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">입고번호</th>
|
||||
<th className="text-left px-4 py-3">입고일</th>
|
||||
<th className="text-left px-4 py-3">매입처</th>
|
||||
<th className="text-left px-4 py-3">창고</th>
|
||||
<th className="text-left px-4 py-3">매입발주</th>
|
||||
<th className="text-right px-4 py-3">정상</th>
|
||||
<th className="text-right px-4 py-3">불량</th>
|
||||
<th className="text-right px-4 py-3">합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.length === 0 ? (
|
||||
<tr><td colSpan={8} className="text-center py-12 text-slate-400">입고 이력이 없습니다.</td></tr>
|
||||
) : list.map((b) => (
|
||||
<tr key={b.OBJID} className="border-t border-slate-100">
|
||||
<td className="px-4 py-3 font-semibold">{b.INBOUND_NO}</td>
|
||||
<td className="px-4 py-3">{b.INBOUND_DATE}</td>
|
||||
<td className="px-4 py-3">{b.VENDOR_NAME || "-"}</td>
|
||||
<td className="px-4 py-3">{b.WH_NAME}</td>
|
||||
<td className="px-4 py-3 text-slate-500 text-xs">{b.PROC_NO || "-"}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-emerald-700 font-semibold">{fmt(b.QTY_NORMAL)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-rose-600 font-semibold">{fmt(b.QTY_DEFECT)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-bold">₩{fmt(b.TOTAL_AMOUNT)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
interface Order { OBJID: string; ORDER_NO: string; ORDER_DATE: string; COMPANY_NAME: string; STATUS: string; TOTAL_AMOUNT: number; INVOICE_NO: string | null; INVOICE_DATE: string | null }
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
const STATUS_LABEL: Record<string, string> = { APPROVED: "출고완료", PAID: "입금완료", INVOICED: "계산서발행" };
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const [list, setList] = useState<Order[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const load = async () => {
|
||||
const res = await fetch("/api/m/orders/list", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}) });
|
||||
const all = ((await res.json()).RESULTLIST ?? []) as Order[];
|
||||
setList(all.filter((o) => ["APPROVED", "PAID", "INVOICED"].includes(o.STATUS)));
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const issue = async () => {
|
||||
const targets = list.filter((o) => selected.has(o.OBJID) && !o.INVOICE_NO);
|
||||
if (targets.length === 0) return Swal.fire({ icon: "warning", title: "발행 대상 없음", text: "이미 발행된 건은 제외됩니다." });
|
||||
const r = await Swal.fire({
|
||||
icon: "question", title: `계산서 ${targets.length}건 발행`,
|
||||
text: `합계 ₩${fmt(targets.reduce((a, o) => a + Number(o.TOTAL_AMOUNT), 0))}`,
|
||||
showCancelButton: true, confirmButtonText: "발행", confirmButtonColor: "#0f766e",
|
||||
});
|
||||
if (!r.isConfirmed) return;
|
||||
const res = await fetch("/api/m/orders/invoice", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objids: targets.map((o) => o.OBJID) }),
|
||||
});
|
||||
if ((await res.json()).success) {
|
||||
Swal.fire({ icon: "success", title: "계산서 발행 완료", timer: 1500, showConfirmButton: false });
|
||||
setSelected(new Set()); load();
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (id: string) => {
|
||||
const s = new Set(selected);
|
||||
s.has(id) ? s.delete(id) : s.add(id);
|
||||
setSelected(s);
|
||||
};
|
||||
|
||||
const unissued = list.filter((o) => !o.INVOICE_NO).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">계산서 발행</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">미발행 {unissued}건. 체크박스 선택 후 일괄 발행.</p>
|
||||
</div>
|
||||
<button onClick={issue} disabled={selected.size === 0} className="px-4 h-10 rounded-lg bg-emerald-700 text-white text-sm font-bold disabled:opacity-50">
|
||||
선택 {selected.size}건 일괄 발행
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="px-3 py-3 w-10"></th>
|
||||
<th className="text-left px-4 py-3">발주번호</th>
|
||||
<th className="text-left px-4 py-3">발주일</th>
|
||||
<th className="text-left px-4 py-3">업체명</th>
|
||||
<th className="text-right px-4 py-3">합계</th>
|
||||
<th className="text-center px-4 py-3">상태</th>
|
||||
<th className="text-left px-4 py-3">계산서번호</th>
|
||||
<th className="text-left px-4 py-3">발행일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.length === 0 ? (
|
||||
<tr><td colSpan={8} className="text-center py-12 text-slate-400">대상 발주가 없습니다.</td></tr>
|
||||
) : list.map((o) => (
|
||||
<tr key={o.OBJID} className="border-t border-slate-100">
|
||||
<td className="px-3 py-3 text-center">
|
||||
{!o.INVOICE_NO && <input type="checkbox" checked={selected.has(o.OBJID)} onChange={() => toggle(o.OBJID)} className="w-4 h-4 accent-emerald-600" />}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-semibold">{o.ORDER_NO}</td>
|
||||
<td className="px-4 py-3">{o.ORDER_DATE}</td>
|
||||
<td className="px-4 py-3">{o.COMPANY_NAME}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-bold">₩{fmt(o.TOTAL_AMOUNT)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-1 rounded text-xs font-semibold ${o.STATUS === "INVOICED" ? "bg-violet-100 text-violet-700" : "bg-amber-100 text-amber-700"}`}>
|
||||
{STATUS_LABEL[o.STATUS]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{o.INVOICE_NO || "-"}</td>
|
||||
<td className="px-4 py-3 text-xs text-slate-500">{o.INVOICE_DATE || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,8 +16,8 @@ interface DetailLine {
|
||||
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
REQUESTED: "발주요청", APPROVED: "발주완료", SHIPPED: "출고완료",
|
||||
INVOICED: "계산서발행", PAID: "완납", CANCELLED: "취소",
|
||||
REQUESTED: "출고요청", APPROVED: "출고완료", SHIPPED: "출고완료",
|
||||
PAID: "입금완료", INVOICED: "계산서발행", CANCELLED: "취소",
|
||||
};
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
REQUESTED: "bg-amber-100 text-amber-700",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
interface Order { OBJID: string; ORDER_NO: string; ORDER_DATE: string; COMPANY_NAME: string; STATUS: string; TOTAL_AMOUNT: number; PAID_AMOUNT: number }
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
const STATUS_LABEL: Record<string, string> = { APPROVED: "출고완료", PAID: "입금완료", INVOICED: "계산서발행" };
|
||||
|
||||
export default function PaymentsPage() {
|
||||
const [list, setList] = useState<Order[]>([]);
|
||||
|
||||
const load = async () => {
|
||||
const res = await fetch("/api/m/orders/list", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}) });
|
||||
const all = ((await res.json()).RESULTLIST ?? []) as Order[];
|
||||
setList(all.filter((o) => ["APPROVED", "PAID", "INVOICED"].includes(o.STATUS)));
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const onPay = async (o: Order) => {
|
||||
const remain = Number(o.TOTAL_AMOUNT) - Number(o.PAID_AMOUNT || 0);
|
||||
const r = await Swal.fire({
|
||||
title: `${o.COMPANY_NAME} 입금 등록`,
|
||||
html: `미입금 ₩${fmt(remain)}`, input: "number", inputValue: remain,
|
||||
showCancelButton: true, confirmButtonText: "입금 처리", confirmButtonColor: "#0f766e",
|
||||
});
|
||||
if (!r.isConfirmed) return;
|
||||
const amt = Number(r.value);
|
||||
const res = await fetch("/api/m/orders/payment", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objid: o.OBJID, paidAmount: amt }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) {
|
||||
Swal.fire({ icon: "success", title: "입금 등록", timer: 1200, showConfirmButton: false });
|
||||
load();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">입금 관리</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">출고완료 후 거래처 입금 등록 — 완납 시 상태가 입금완료로 자동 변경됩니다.</p>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">발주번호</th>
|
||||
<th className="text-left px-4 py-3">발주일</th>
|
||||
<th className="text-left px-4 py-3">업체명</th>
|
||||
<th className="text-right px-4 py-3">합계</th>
|
||||
<th className="text-right px-4 py-3">입금액</th>
|
||||
<th className="text-right px-4 py-3">미수금</th>
|
||||
<th className="text-center px-4 py-3">상태</th>
|
||||
<th className="text-right px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.length === 0 ? (
|
||||
<tr><td colSpan={8} className="text-center py-12 text-slate-400">출고완료 건이 없습니다.</td></tr>
|
||||
) : list.map((o) => {
|
||||
const remain = Number(o.TOTAL_AMOUNT) - Number(o.PAID_AMOUNT || 0);
|
||||
return (
|
||||
<tr key={o.OBJID} className="border-t border-slate-100">
|
||||
<td className="px-4 py-3 font-semibold">{o.ORDER_NO}</td>
|
||||
<td className="px-4 py-3">{o.ORDER_DATE}</td>
|
||||
<td className="px-4 py-3">{o.COMPANY_NAME}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">₩{fmt(o.TOTAL_AMOUNT)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-emerald-700">₩{fmt(o.PAID_AMOUNT || 0)}</td>
|
||||
<td className={`px-4 py-3 text-right tabular-nums font-bold ${remain > 0 ? "text-rose-700" : "text-slate-400"}`}>₩{fmt(remain)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-1 rounded text-xs font-semibold ${o.STATUS === "PAID" || o.STATUS === "INVOICED" ? "bg-emerald-100 text-emerald-700" : "bg-amber-100 text-amber-700"}`}>
|
||||
{STATUS_LABEL[o.STATUS]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{o.STATUS !== "INVOICED" && (
|
||||
<button onClick={() => onPay(o)} className="text-xs px-3 h-8 rounded-md bg-emerald-700 text-white hover:bg-emerald-800 font-semibold">
|
||||
입금 등록
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
interface Vendor { OBJID: string; VENDOR_NAME: string }
|
||||
interface Item { OBJID: string; ITEM_CODE: string; ITEM_NAME: string; COST_PRICE: number }
|
||||
interface Line { itemObjid: string; itemName: string; qty: number; costPrice: number }
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
|
||||
export default function NewProcPage() {
|
||||
const router = useRouter();
|
||||
const [vendors, setVendors] = useState<Vendor[]>([]);
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [vendorObjid, setVendorObjid] = useState("");
|
||||
const [procDate, setProcDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [lines, setLines] = useState<Line[]>([]);
|
||||
const [pickItem, setPickItem] = useState("");
|
||||
const [pickQty, setPickQty] = useState(10);
|
||||
const [pickPrice, setPickPrice] = useState(0);
|
||||
const [memo, setMemo] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/m/vendors/list", { method: "POST" }).then((r) => r.json()).then((j) => setVendors(j.RESULTLIST || []));
|
||||
fetch("/api/m/items/list", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }).then((r) => r.json()).then((j) => setItems(j.RESULTLIST || []));
|
||||
}, []);
|
||||
|
||||
const addLine = () => {
|
||||
if (!pickItem) return;
|
||||
const it = items.find((x) => x.OBJID === pickItem);
|
||||
if (!it) return;
|
||||
setLines([...lines, { itemObjid: it.OBJID, itemName: it.ITEM_NAME, qty: pickQty, costPrice: pickPrice || Number(it.COST_PRICE) || 0 }]);
|
||||
setPickItem(""); setPickQty(10); setPickPrice(0);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!vendorObjid) return Swal.fire({ icon: "warning", title: "매입처 선택" });
|
||||
if (lines.length === 0) return Swal.fire({ icon: "warning", title: "발주 라인 추가" });
|
||||
const res = await fetch("/api/m/procurements/save", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ vendorObjid, procDate, lines, memo }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) {
|
||||
await Swal.fire({ icon: "success", title: "발주 등록", text: `발주번호: ${j.procNo}` });
|
||||
router.push("/m/admin/procurements");
|
||||
} else Swal.fire({ icon: "error", title: "오류", text: j.message });
|
||||
};
|
||||
|
||||
const total = lines.reduce((a, l) => a + Math.round(l.costPrice * l.qty), 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-3xl">
|
||||
<h1 className="text-2xl font-bold">매입 발주 작성</h1>
|
||||
<div className="bg-white border rounded-xl p-5 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-600">매입처 *</label>
|
||||
<select value={vendorObjid} onChange={(e) => setVendorObjid(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 mt-1">
|
||||
<option value="">선택</option>
|
||||
{vendors.map((v) => <option key={v.OBJID} value={v.OBJID}>{v.VENDOR_NAME}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-600">발주일</label>
|
||||
<input type="date" value={procDate} onChange={(e) => setProcDate(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-xl p-5">
|
||||
<h3 className="font-bold mb-3">발주 라인</h3>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<select value={pickItem} onChange={(e) => setPickItem(e.target.value)} className="flex-1 h-10 px-3 rounded-lg border border-slate-200">
|
||||
<option value="">품목 선택</option>
|
||||
{items.map((i) => <option key={i.OBJID} value={i.OBJID}>{i.ITEM_NAME}</option>)}
|
||||
</select>
|
||||
<input type="number" min={1} value={pickQty} onChange={(e) => setPickQty(Number(e.target.value))} placeholder="수량" className="w-24 h-10 px-3 rounded-lg border border-slate-200" />
|
||||
<input type="number" min={0} value={pickPrice} onChange={(e) => setPickPrice(Number(e.target.value))} placeholder="단가" className="w-32 h-10 px-3 rounded-lg border border-slate-200" />
|
||||
<button onClick={addLine} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-bold flex items-center gap-1"><Plus size={14} />추가</button>
|
||||
</div>
|
||||
<table className="w-full text-sm border">
|
||||
<thead className="bg-slate-50">
|
||||
<tr><th className="px-3 py-2 text-left text-xs">품목</th><th className="px-3 py-2 text-right text-xs">수량</th><th className="px-3 py-2 text-right text-xs">단가</th><th className="px-3 py-2 text-right text-xs">합계</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.length === 0 ? (
|
||||
<tr><td colSpan={5} className="text-center py-6 text-slate-400">라인을 추가하세요</td></tr>
|
||||
) : lines.map((ln, i) => (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="px-3 py-2">{ln.itemName}</td>
|
||||
<td className="px-3 py-2 text-right">{ln.qty}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">₩{fmt(ln.costPrice)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums font-bold">₩{fmt(Math.round(ln.qty * ln.costPrice))}</td>
|
||||
<td className="px-3 py-2 text-right"><button onClick={() => setLines(lines.filter((_, j) => j !== i))} className="text-slate-400 hover:text-rose-500"><Trash2 size={14} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{lines.length > 0 && (
|
||||
<tfoot className="bg-slate-50">
|
||||
<tr><td colSpan={3} className="px-3 py-2 text-right font-bold">합계</td><td className="px-3 py-2 text-right font-bold tabular-nums text-emerald-700">₩{fmt(total)}</td><td></td></tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
<textarea placeholder="메모 (선택)" value={memo} onChange={(e) => setMemo(e.target.value)} rows={2} className="w-full mt-4 px-3 py-2 rounded-lg border border-slate-200 text-sm" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => router.back()} className="px-5 h-11 rounded-xl border border-slate-200 font-semibold">취소</button>
|
||||
<button onClick={submit} className="px-6 h-11 rounded-xl bg-emerald-700 text-white font-bold">발주 등록</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Plus, Eye } from "lucide-react";
|
||||
|
||||
interface Proc { OBJID: string; PROC_NO: string; PROC_DATE: string; VENDOR_NAME: string; STATUS: string; TOTAL_AMOUNT: number; LINE_CNT: number }
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = { OPEN: "진행중", RECEIVED: "입고완료", CLOSED: "마감" };
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
|
||||
export default function ProcurementsPage() {
|
||||
const [list, setList] = useState<Proc[]>([]);
|
||||
|
||||
const load = async () => {
|
||||
const res = await fetch("/api/m/procurements/list", { method: "POST", body: "{}", headers: { "Content-Type": "application/json" } });
|
||||
setList((await res.json()).RESULTLIST ?? []);
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">매입 발주</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">모모유통 → 도매처/제조사로 보내는 발주서</p>
|
||||
</div>
|
||||
<Link href="/m/admin/procurements/new" className="px-4 h-10 inline-flex items-center gap-2 rounded-lg bg-emerald-700 text-white text-sm font-bold">
|
||||
<Plus size={16} /> 매입발주 작성
|
||||
</Link>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">발주번호</th>
|
||||
<th className="text-left px-4 py-3">발주일</th>
|
||||
<th className="text-left px-4 py-3">매입처</th>
|
||||
<th className="text-right px-4 py-3">라인</th>
|
||||
<th className="text-right px-4 py-3">합계</th>
|
||||
<th className="text-center px-4 py-3">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.length === 0 ? (
|
||||
<tr><td colSpan={6} className="text-center py-12 text-slate-400">매입발주가 없습니다.</td></tr>
|
||||
) : list.map((p) => (
|
||||
<tr key={p.OBJID} className="border-t border-slate-100">
|
||||
<td className="px-4 py-3 font-semibold">{p.PROC_NO}</td>
|
||||
<td className="px-4 py-3">{p.PROC_DATE}</td>
|
||||
<td className="px-4 py-3">{p.VENDOR_NAME || "-"}</td>
|
||||
<td className="px-4 py-3 text-right">{p.LINE_CNT}건</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-bold">₩{fmt(p.TOTAL_AMOUNT)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-1 rounded text-xs font-semibold ${p.STATUS === "RECEIVED" ? "bg-emerald-100 text-emerald-700" : "bg-amber-100 text-amber-700"}`}>
|
||||
{STATUS_LABEL[p.STATUS] || p.STATUS}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Row { DAY: string; ORDER_CNT: number; TOTAL: number; TAX_FREE: number; TAXABLE: number }
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
|
||||
function defaultRange() {
|
||||
const e = new Date(), s = new Date();
|
||||
s.setDate(s.getDate() - 29);
|
||||
return [s.toISOString().slice(0, 10), e.toISOString().slice(0, 10)];
|
||||
}
|
||||
|
||||
export default function DailyStatsPage() {
|
||||
const [[from, to], setRange] = useState(defaultRange());
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
|
||||
const load = async () => {
|
||||
const res = await fetch("/api/m/statistics/daily", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dateFrom: from, dateTo: to }),
|
||||
});
|
||||
setRows((await res.json()).RESULTLIST ?? []);
|
||||
};
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line
|
||||
|
||||
const max = Math.max(1, ...rows.map((r) => Number(r.TOTAL)));
|
||||
const total = rows.reduce((a, r) => a + Number(r.TOTAL), 0);
|
||||
const totalFree = rows.reduce((a, r) => a + Number(r.TAX_FREE), 0);
|
||||
const totalTaxable = rows.reduce((a, r) => a + Number(r.TAXABLE), 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">통계 — 일자별 매출</h1>
|
||||
<div className="flex gap-2 flex-wrap items-end">
|
||||
<input type="date" value={from} onChange={(e) => setRange([e.target.value, to])} className="h-10 px-3 rounded-lg border border-slate-200" />
|
||||
<input type="date" value={to} onChange={(e) => setRange([from, e.target.value])} className="h-10 px-3 rounded-lg border border-slate-200" />
|
||||
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold">조회</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="rounded-xl bg-violet-50 border border-violet-200 p-5"><div className="text-xs font-semibold text-violet-700">면세 합계</div><div className="text-2xl font-bold text-violet-900">₩{fmt(totalFree)}</div></div>
|
||||
<div className="rounded-xl bg-rose-50 border border-rose-200 p-5"><div className="text-xs font-semibold text-rose-700">과세 공급가</div><div className="text-2xl font-bold text-rose-900">₩{fmt(totalTaxable)}</div></div>
|
||||
<div className="rounded-xl bg-emerald-50 border border-emerald-200 p-5"><div className="text-xs font-semibold text-emerald-700">총 매출 (VAT)</div><div className="text-2xl font-bold text-emerald-900">₩{fmt(total)}</div></div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-xl p-5">
|
||||
<h3 className="font-bold mb-3">일별 매출 그래프</h3>
|
||||
<div className="flex items-end gap-1 h-48 px-2 overflow-x-auto">
|
||||
{rows.length === 0 ? <div className="m-auto text-slate-400">데이터가 없습니다.</div> : rows.map((r, i) => (
|
||||
<div key={i} className="flex flex-col items-center gap-1 min-w-[35px]">
|
||||
<div className="w-full bg-emerald-500/80 rounded-t hover:bg-emerald-700 transition" style={{ height: `${(Number(r.TOTAL) / max) * 100}%` }} title={`${r.DAY}: ₩${fmt(r.TOTAL)}`} />
|
||||
<div className="text-[9px] text-slate-500 -rotate-45 origin-top-left whitespace-nowrap">{r.DAY.slice(5)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr><th className="text-left px-4 py-3">일자</th><th className="text-right px-4 py-3">건수</th><th className="text-right px-4 py-3">면세</th><th className="text-right px-4 py-3">과세</th><th className="text-right px-4 py-3">합계</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.DAY} className="border-t border-slate-100">
|
||||
<td className="px-4 py-2.5 font-semibold">{r.DAY}</td>
|
||||
<td className="px-4 py-2.5 text-right">{r.ORDER_CNT}건</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-violet-700">₩{fmt(r.TAX_FREE)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-rose-700">₩{fmt(r.TAXABLE)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums font-bold">₩{fmt(r.TOTAL)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Row { ITEM_CODE: string; ITEM_NAME: string; QTY: number; REVENUE: number; COST: number; MARGIN: number }
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
|
||||
export default function MarginPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear());
|
||||
const [month, setMonth] = useState(new Date().getMonth() + 1);
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
|
||||
const load = async () => {
|
||||
const res = await fetch("/api/m/statistics/margin", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ year, month }),
|
||||
});
|
||||
setRows((await res.json()).RESULTLIST ?? []);
|
||||
};
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line
|
||||
|
||||
const totalRev = rows.reduce((a, r) => a + Number(r.REVENUE), 0);
|
||||
const totalCost = rows.reduce((a, r) => a + Number(r.COST), 0);
|
||||
const totalMargin = totalRev - totalCost;
|
||||
const marginPct = totalRev ? ((totalMargin / totalRev) * 100).toFixed(1) : "0.0";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">통계 — 원가 / 마진 (월간 품목별)</h1>
|
||||
<div className="flex gap-2">
|
||||
<select value={year} onChange={(e) => setYear(Number(e.target.value))} className="h-10 px-3 rounded-lg border border-slate-200 text-sm">
|
||||
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => <option key={y} value={y}>{y}년</option>)}
|
||||
</select>
|
||||
<select value={month} onChange={(e) => setMonth(Number(e.target.value))} className="h-10 px-3 rounded-lg border border-slate-200 text-sm">
|
||||
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => <option key={m} value={m}>{m}월</option>)}
|
||||
</select>
|
||||
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold">조회</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="rounded-xl bg-emerald-50 border border-emerald-200 p-5"><div className="text-xs font-semibold text-emerald-700">매출(공급가)</div><div className="text-2xl font-bold text-emerald-900">₩{fmt(totalRev)}</div></div>
|
||||
<div className="rounded-xl bg-amber-50 border border-amber-200 p-5"><div className="text-xs font-semibold text-amber-700">매입원가</div><div className="text-2xl font-bold text-amber-900">₩{fmt(totalCost)}</div></div>
|
||||
<div className="rounded-xl bg-blue-50 border border-blue-200 p-5"><div className="text-xs font-semibold text-blue-700">마진</div><div className="text-2xl font-bold text-blue-900">₩{fmt(totalMargin)}</div></div>
|
||||
<div className="rounded-xl bg-violet-50 border border-violet-200 p-5"><div className="text-xs font-semibold text-violet-700">마진율</div><div className="text-2xl font-bold text-violet-900">{marginPct}%</div></div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">품목</th>
|
||||
<th className="text-right px-4 py-3">판매수량</th>
|
||||
<th className="text-right px-4 py-3">매출</th>
|
||||
<th className="text-right px-4 py-3">원가</th>
|
||||
<th className="text-right px-4 py-3">마진</th>
|
||||
<th className="text-right px-4 py-3">마진율</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={6} className="text-center py-12 text-slate-400">데이터가 없습니다.</td></tr>
|
||||
) : rows.map((r) => {
|
||||
const pct = Number(r.REVENUE) ? ((Number(r.MARGIN) / Number(r.REVENUE)) * 100).toFixed(1) : "0.0";
|
||||
return (
|
||||
<tr key={r.ITEM_CODE} className="border-t border-slate-100">
|
||||
<td className="px-4 py-2.5 font-semibold">{r.ITEM_NAME}</td>
|
||||
<td className="px-4 py-2.5 text-right">{fmt(r.QTY)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums">₩{fmt(r.REVENUE)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-amber-700">₩{fmt(r.COST)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums font-bold text-emerald-700">₩{fmt(r.MARGIN)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums">{pct}%</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+99
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, FormEvent } from "react";
|
||||
import { Plus, Pencil } from "lucide-react";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
interface Vendor { OBJID: string; VENDOR_NAME: string; CONTACT: string; PHONE: string; BIZ_NO: string; EMAIL: string; ADDRESS: string }
|
||||
|
||||
export default function VendorsPage() {
|
||||
const [list, setList] = useState<Vendor[]>([]);
|
||||
const [editing, setEditing] = useState<Partial<Vendor> | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
const res = await fetch("/api/m/vendors/list", { method: "POST" });
|
||||
setList((await res.json()).RESULTLIST ?? []);
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const save = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editing) return;
|
||||
const res = await fetch("/api/m/vendors/save", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
objid: editing.OBJID, actionType: editing.OBJID ? "update" : "regist",
|
||||
vendorName: editing.VENDOR_NAME, contact: editing.CONTACT, phone: editing.PHONE,
|
||||
bizNo: editing.BIZ_NO, email: editing.EMAIL, address: editing.ADDRESS,
|
||||
}),
|
||||
});
|
||||
if ((await res.json()).success) {
|
||||
Swal.fire({ icon: "success", title: "저장됨", timer: 1200, showConfirmButton: false });
|
||||
setEditing(null); load();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">매입처 관리</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">모모유통이 제품을 매입하는 도매처 / 제조사 목록</p>
|
||||
</div>
|
||||
<button onClick={() => setEditing({})} className="px-4 h-10 inline-flex items-center gap-2 rounded-lg bg-emerald-700 text-white text-sm font-bold">
|
||||
<Plus size={16} /> 매입처 추가
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">매입처명</th>
|
||||
<th className="text-left px-4 py-3">담당자</th>
|
||||
<th className="text-left px-4 py-3">연락처</th>
|
||||
<th className="text-left px-4 py-3">사업자번호</th>
|
||||
<th className="text-left px-4 py-3">이메일</th>
|
||||
<th className="text-right px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.length === 0 ? (
|
||||
<tr><td colSpan={6} className="text-center py-12 text-slate-400">등록된 매입처가 없습니다.</td></tr>
|
||||
) : list.map((v) => (
|
||||
<tr key={v.OBJID} className="border-t border-slate-100">
|
||||
<td className="px-4 py-3 font-semibold">{v.VENDOR_NAME}</td>
|
||||
<td className="px-4 py-3">{v.CONTACT || "-"}</td>
|
||||
<td className="px-4 py-3">{v.PHONE || "-"}</td>
|
||||
<td className="px-4 py-3">{v.BIZ_NO || "-"}</td>
|
||||
<td className="px-4 py-3 text-slate-500 text-xs">{v.EMAIL || "-"}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button onClick={() => setEditing(v)} className="text-slate-500 hover:text-emerald-700 p-1"><Pencil size={14} /></button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<div className="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-4" onClick={() => setEditing(null)}>
|
||||
<form onSubmit={save} onClick={(e) => e.stopPropagation()} className="bg-white rounded-xl max-w-lg w-full p-6">
|
||||
<h3 className="font-bold mb-4">{editing.OBJID ? "매입처 수정" : "매입처 추가"}</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input required placeholder="매입처명 *" value={editing.VENDOR_NAME ?? ""} onChange={(e) => setEditing({ ...editing, VENDOR_NAME: e.target.value })} className="col-span-2 h-10 px-3 rounded-lg border border-slate-200" />
|
||||
<input placeholder="담당자" value={editing.CONTACT ?? ""} onChange={(e) => setEditing({ ...editing, CONTACT: e.target.value })} className="h-10 px-3 rounded-lg border border-slate-200" />
|
||||
<input placeholder="연락처" value={editing.PHONE ?? ""} onChange={(e) => setEditing({ ...editing, PHONE: e.target.value })} className="h-10 px-3 rounded-lg border border-slate-200" />
|
||||
<input placeholder="사업자번호" value={editing.BIZ_NO ?? ""} onChange={(e) => setEditing({ ...editing, BIZ_NO: e.target.value })} className="h-10 px-3 rounded-lg border border-slate-200" />
|
||||
<input placeholder="이메일" value={editing.EMAIL ?? ""} onChange={(e) => setEditing({ ...editing, EMAIL: e.target.value })} className="h-10 px-3 rounded-lg border border-slate-200" />
|
||||
<input placeholder="주소" value={editing.ADDRESS ?? ""} onChange={(e) => setEditing({ ...editing, ADDRESS: e.target.value })} className="col-span-2 h-10 px-3 rounded-lg border border-slate-200" />
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end mt-5">
|
||||
<button type="button" onClick={() => setEditing(null)} className="px-4 h-10 rounded-lg border border-slate-200 text-sm font-semibold">취소</button>
|
||||
<button type="submit" className="px-5 h-10 rounded-lg bg-emerald-700 text-white text-sm font-bold">저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,8 @@ type DashboardData =
|
||||
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
REQUESTED: "발주요청", APPROVED: "발주완료", SHIPPED: "출고완료",
|
||||
INVOICED: "계산서발행", PAID: "완납", CANCELLED: "취소",
|
||||
REQUESTED: "출고요청", APPROVED: "출고완료", SHIPPED: "출고완료",
|
||||
PAID: "입금완료", INVOICED: "계산서발행", CANCELLED: "취소",
|
||||
};
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
REQUESTED: "bg-amber-100 text-amber-700",
|
||||
|
||||
@@ -17,8 +17,8 @@ interface Order {
|
||||
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
REQUESTED: "발주요청", APPROVED: "발주완료", SHIPPED: "출고완료",
|
||||
INVOICED: "계산서발행", PAID: "완납", CANCELLED: "취소",
|
||||
REQUESTED: "출고요청", APPROVED: "출고완료", SHIPPED: "출고완료",
|
||||
PAID: "입금완료", INVOICED: "계산서발행", CANCELLED: "취소",
|
||||
};
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
REQUESTED: "bg-amber-100 text-amber-700",
|
||||
|
||||
@@ -82,6 +82,34 @@ export default function LandingPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 전체 업무 흐름 — 모모유통 시점 */}
|
||||
<section className="bg-slate-50 border-y border-slate-200">
|
||||
<div className="max-w-6xl mx-auto px-6 py-16">
|
||||
<div className="text-center mb-10">
|
||||
<div className="inline-flex items-center gap-2 text-emerald-700 text-xs font-bold tracking-widest mb-3">
|
||||
<span className="w-6 h-[2px] bg-emerald-600" /> WORKFLOW
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-3">전체 업무 흐름</h2>
|
||||
<p className="text-slate-500">매입(도매처) → 입고(창고) → 출고(거래처) → 입금 → 계산서까지 단계별로 추적</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
|
||||
{[
|
||||
{ n: "1", t: "매입 발주", s: "모모 → 도매처", c: "from-amber-500 to-orange-600" },
|
||||
{ n: "2", t: "입고 처리", s: "정상/불량 분리, 재고 +", c: "from-orange-500 to-rose-500" },
|
||||
{ n: "3", t: "출고 요청 → 승인", s: "거래처 → 모모, 메일발송", c: "from-rose-500 to-pink-500" },
|
||||
{ n: "4", t: "입금 등록", s: "거래처 → 모모", c: "from-pink-500 to-violet-500" },
|
||||
{ n: "5", t: "계산서 발행", s: "월말 일괄", c: "from-violet-500 to-emerald-500" },
|
||||
].map((x) => (
|
||||
<div key={x.n} className="rounded-2xl bg-white border border-slate-200 p-5 shadow-sm relative">
|
||||
<div className={`absolute -top-3 left-5 w-7 h-7 rounded-full bg-gradient-to-br ${x.c} text-white font-bold flex items-center justify-center text-sm shadow`}>{x.n}</div>
|
||||
<h3 className="font-bold mt-3 mb-1 text-sm">{x.t}</h3>
|
||||
<p className="text-xs text-slate-500">{x.s}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 사용 방법 (거래처 6단계) */}
|
||||
<section className="max-w-6xl mx-auto px-6 py-20">
|
||||
<div className="text-center mb-14">
|
||||
|
||||
@@ -3,41 +3,52 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
ShoppingCart,
|
||||
Warehouse,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Building2,
|
||||
ClipboardList,
|
||||
LayoutDashboard, Package, ShoppingCart, Warehouse, TrendingUp, Users,
|
||||
Building2, ClipboardList, Truck, Receipt, PackagePlus, Wallet,
|
||||
} from "lucide-react";
|
||||
|
||||
interface MenuLink {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ size?: number }>;
|
||||
admin?: boolean;
|
||||
user?: boolean;
|
||||
roles: ("USER" | "ADMIN")[];
|
||||
group?: string;
|
||||
}
|
||||
|
||||
const MENU: MenuLink[] = [
|
||||
{ href: "/m/dashboard", label: "대시보드", icon: LayoutDashboard, user: true, admin: true },
|
||||
{ href: "/m/items", label: "품목 검색", icon: Package, user: true },
|
||||
{ href: "/m/orders/new", label: "발주 요청", icon: ShoppingCart, user: true },
|
||||
{ href: "/m/orders", label: "내 발주 이력", icon: ClipboardList, user: true },
|
||||
{ href: "/m/dashboard", label: "대시보드", icon: LayoutDashboard, roles: ["USER", "ADMIN"] },
|
||||
|
||||
{ href: "/m/admin/items", label: "품목 관리", icon: Package, admin: true },
|
||||
{ href: "/m/admin/warehouses", label: "창고 관리", icon: Building2, admin: true },
|
||||
{ href: "/m/admin/inventory", label: "재고 관리", icon: Warehouse, admin: true },
|
||||
{ href: "/m/admin/orders", label: "발주서 관리", icon: ClipboardList, admin: true },
|
||||
{ href: "/m/admin/statistics", label: "통계", icon: TrendingUp, admin: true },
|
||||
{ href: "/m/admin/users", label: "회원 관리", icon: Users, admin: true },
|
||||
{ href: "/m/items", label: "품목 검색", icon: Package, roles: ["USER"], group: "주문" },
|
||||
{ href: "/m/orders/new", label: "출고 요청", icon: ShoppingCart, roles: ["USER"], group: "주문" },
|
||||
{ href: "/m/orders", label: "내 출고 이력", icon: ClipboardList, roles: ["USER"], group: "주문" },
|
||||
|
||||
{ href: "/m/admin/items", label: "품목 관리", icon: Package, roles: ["ADMIN"], group: "마스터" },
|
||||
{ href: "/m/admin/vendors", label: "매입처 관리", icon: Building2, roles: ["ADMIN"], group: "마스터" },
|
||||
{ href: "/m/admin/warehouses", label: "창고 관리", icon: Warehouse, roles: ["ADMIN"], group: "마스터" },
|
||||
{ href: "/m/admin/users", label: "회원 관리", icon: Users, roles: ["ADMIN"], group: "마스터" },
|
||||
|
||||
{ href: "/m/admin/procurements", label: "매입 발주", icon: Truck, roles: ["ADMIN"], group: "매입" },
|
||||
{ href: "/m/admin/inbounds", label: "입고 처리", icon: PackagePlus, roles: ["ADMIN"], group: "매입" },
|
||||
{ href: "/m/admin/inventory", label: "재고 관리", icon: Warehouse, roles: ["ADMIN"], group: "매입" },
|
||||
|
||||
{ href: "/m/admin/orders", label: "출고 관리", icon: ClipboardList, roles: ["ADMIN"], group: "출고/정산" },
|
||||
{ href: "/m/admin/payments", label: "입금 관리", icon: Wallet, roles: ["ADMIN"], group: "출고/정산" },
|
||||
{ href: "/m/admin/invoices", label: "계산서 발행", icon: Receipt, roles: ["ADMIN"], group: "출고/정산" },
|
||||
|
||||
{ href: "/m/admin/statistics", label: "월간 매출", icon: TrendingUp, roles: ["ADMIN"], group: "통계" },
|
||||
{ href: "/m/admin/statistics/daily", label: "일자별", icon: TrendingUp, roles: ["ADMIN"], group: "통계" },
|
||||
{ href: "/m/admin/statistics/margin", label: "원가/마진", icon: TrendingUp, roles: ["ADMIN"], group: "통계" },
|
||||
];
|
||||
|
||||
export function MomoSidebar({ role }: { role: "USER" | "ADMIN" }) {
|
||||
const pathname = usePathname();
|
||||
const items = MENU.filter((m) => (role === "ADMIN" ? m.admin : m.user));
|
||||
const items = MENU.filter((m) => m.roles.includes(role));
|
||||
|
||||
const grouped: Record<string, MenuLink[]> = {};
|
||||
for (const m of items) {
|
||||
const g = m.group || "_top";
|
||||
(grouped[g] ||= []).push(m);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="w-60 shrink-0 bg-gradient-to-b from-[#0d3b24] to-[#1b5e3a] text-white flex flex-col">
|
||||
@@ -49,31 +60,40 @@ export function MomoSidebar({ role }: { role: "USER" | "ADMIN" }) {
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<nav className="flex-1 py-4 px-2 space-y-0.5 overflow-y-auto">
|
||||
{items.map((it) => {
|
||||
const Icon = it.icon;
|
||||
const active = pathname === it.href || pathname.startsWith(it.href + "/");
|
||||
return (
|
||||
<Link
|
||||
key={it.href}
|
||||
href={it.href}
|
||||
className={
|
||||
"flex items-center gap-3 px-4 h-10 rounded-lg text-sm transition " +
|
||||
(active
|
||||
? "bg-white/15 text-white font-semibold"
|
||||
: "text-emerald-100/80 hover:bg-white/10 hover:text-white")
|
||||
}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{it.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<nav className="flex-1 py-4 px-2 space-y-2 overflow-y-auto">
|
||||
{Object.entries(grouped).map(([group, list]) => (
|
||||
<div key={group}>
|
||||
{group !== "_top" && (
|
||||
<div className="px-3 py-1.5 text-[10px] font-bold tracking-widest text-emerald-300/60 uppercase">
|
||||
{group}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
{list.map((it) => {
|
||||
const Icon = it.icon;
|
||||
const active = pathname === it.href || pathname.startsWith(it.href + "/");
|
||||
return (
|
||||
<Link
|
||||
key={it.href}
|
||||
href={it.href}
|
||||
className={
|
||||
"flex items-center gap-3 px-4 h-9 rounded-lg text-sm transition " +
|
||||
(active
|
||||
? "bg-white/15 text-white font-semibold"
|
||||
: "text-emerald-100/80 hover:bg-white/10 hover:text-white")
|
||||
}
|
||||
>
|
||||
<Icon size={15} />
|
||||
{it.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-white/10 text-[11px] text-emerald-200/60">
|
||||
© 2026 MOMO Distribution
|
||||
</div>
|
||||
<div className="p-4 border-t border-white/10 text-[11px] text-emerald-200/60">© 2026 MOMO Distribution</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user