feat(momo): 매입/입고/출고/정산 메뉴 분리 + secret-free 자동배포
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:
chpark
2026-04-25 21:37:48 +09:00
parent b97e7b63a4
commit 0e38a6f127
27 changed files with 1502 additions and 74 deletions
+17 -24
View File
@@ -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
+63
View File
@@ -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;
+32
View File
@@ -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 });
}
+118
View File
@@ -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");
}
+33
View File
@@ -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 });
}
+28
View File
@@ -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 });
}
+30
View File
@@ -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 });
}
+64
View File
@@ -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");
}
+23
View File
@@ -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 });
}
+33
View File
@@ -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 });
}
+16
View File
@@ -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 });
}
+26
View File
@@ -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 });
}
+191
View File
@@ -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>
);
}
+64
View File
@@ -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>
);
}
+99
View File
@@ -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>
);
}
+2 -2
View File
@@ -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",
+93
View File
@@ -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>
);
}
+116
View File
@@ -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>
);
}
+66
View File
@@ -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>
);
}
+79
View File
@@ -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>
);
}
+99
View File
@@ -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>
);
}
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+28
View File
@@ -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">
+64 -44
View File
@@ -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>
);
}