매입 흐름: 매입발주(REQUESTED) → 매입 입금관리(PAID) → 입고 처리(RECEIVED) DB (마이그레이션 024): - momo_procurements 에 paid_date, paid_amount, paid_method, paid_memo 컬럼 추가 - 매입 그룹 메뉴 sort 재정렬: 매입발주 30, 입금관리(신설) 31, 입고처리 32, 재고관리 33 - M_APROCPAY '매입 입금관리' /m/admin/proc-payments 메뉴 INSERT (ON CONFLICT idempotent) UI/API: - /m/admin/proc-payments 페이지 — 발주요청/입금완료 분리 카드 + 입금 처리 모달 (금액/방법/메모) - 조회조건: 날짜 from~to + 공급업체 + 상태 (즉시 반영) - POST /api/m/admin/proc-payments/list — REQUESTED|PAID 만 노출 - POST /api/m/admin/proc-payments/confirm — REQUESTED → PAID 전환 + paid_* 채움 다음 단계 (별도 batch): 입고 처리 페이지에서 PAID 만 노출 + 입고 시 RECEIVED 전환 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
-- 매입 입금관리: 메뉴 신설 + procurements 에 paid_* 컬럼 추가
|
||||
-- 1) 컬럼 추가 (idempotent)
|
||||
ALTER TABLE momo_procurements
|
||||
ADD COLUMN IF NOT EXISTS paid_date TIMESTAMP,
|
||||
ADD COLUMN IF NOT EXISTS paid_amount NUMERIC(15,2),
|
||||
ADD COLUMN IF NOT EXISTS paid_method VARCHAR(40),
|
||||
ADD COLUMN IF NOT EXISTS paid_memo TEXT;
|
||||
|
||||
-- 2) 매입 그룹의 sort 재정렬 — 입금관리(신설) 끼울 자리 확보
|
||||
UPDATE momo_menus SET sort_order = 33 WHERE objid = 'M_AINV'; -- 재고 관리 32→33
|
||||
UPDATE momo_menus SET sort_order = 32 WHERE objid = 'M_AINB'; -- 입고 처리 31→32
|
||||
-- (M_APROC 매입 발주는 30 그대로)
|
||||
|
||||
-- 3) 매입 입금관리 메뉴 신설 (insert OR update; idempotent)
|
||||
INSERT INTO momo_menus (objid, menu_code, menu_name, menu_url, parent_code, sort_order, group_name, is_system, is_del, regdate)
|
||||
VALUES ('M_APROCPAY', 'A_PROCPAY', '매입 입금관리', '/m/admin/proc-payments', NULL, 31, '매입', 'Y', 'N', NOW())
|
||||
ON CONFLICT (objid) DO UPDATE
|
||||
SET menu_name = EXCLUDED.menu_name,
|
||||
menu_url = EXCLUDED.menu_url,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
group_name = EXCLUDED.group_name,
|
||||
is_del = 'N';
|
||||
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import Swal from "sweetalert2";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
|
||||
interface Proc {
|
||||
OBJID: string;
|
||||
PROC_NO: string;
|
||||
PROC_DATE: string;
|
||||
VENDOR_OBJID: string;
|
||||
VENDOR_NAME: string;
|
||||
VENDOR_CONTACT: string;
|
||||
TOTAL_AMOUNT: number;
|
||||
STATUS: string;
|
||||
PAYMENT_TERMS: string | null;
|
||||
PAID_DATE: string | null;
|
||||
PAID_AMOUNT: number | null;
|
||||
PAID_METHOD: string | null;
|
||||
PAID_MEMO: string | null;
|
||||
}
|
||||
interface Vendor { OBJID: string; VENDOR_NAME: string }
|
||||
|
||||
const fmt = (n: number | string | null | undefined) => Number(n || 0).toLocaleString("ko-KR");
|
||||
const STATUS_LABEL: Record<string, string> = { REQUESTED: "발주요청", PAID: "입금완료" };
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
REQUESTED: "bg-amber-100 text-amber-700",
|
||||
PAID: "bg-emerald-100 text-emerald-700",
|
||||
};
|
||||
|
||||
function defaultRange() {
|
||||
const e = new Date(), s = new Date();
|
||||
s.setDate(s.getDate() - 60);
|
||||
return [s.toISOString().slice(0, 10), e.toISOString().slice(0, 10)];
|
||||
}
|
||||
|
||||
export default function ProcPaymentsPage() {
|
||||
const [list, setList] = useState<Proc[]>([]);
|
||||
const [vendors, setVendors] = useState<Vendor[]>([]);
|
||||
const [[from, to], setRange] = useState(defaultRange());
|
||||
const [vendorFilter, setVendorFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const res = await fetch("/api/m/admin/proc-payments/list", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
dateFrom: from || undefined,
|
||||
dateTo: to || undefined,
|
||||
vendorObjid: vendorFilter || undefined,
|
||||
status: statusFilter || undefined,
|
||||
}),
|
||||
});
|
||||
setList((await res.json()).RESULTLIST ?? []);
|
||||
}, [from, to, vendorFilter, statusFilter]);
|
||||
|
||||
const loadVendors = useCallback(async () => {
|
||||
const res = await fetch("/api/m/vendors/list", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
setVendors((await res.json()).RESULTLIST ?? []);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => { loadVendors(); }, [loadVendors]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
let requested = 0, requestedAmt = 0, paid = 0, paidAmt = 0;
|
||||
for (const p of list) {
|
||||
if (p.STATUS === "REQUESTED") { requested++; requestedAmt += Number(p.TOTAL_AMOUNT) || 0; }
|
||||
if (p.STATUS === "PAID") { paid++; paidAmt += Number(p.PAID_AMOUNT || p.TOTAL_AMOUNT) || 0; }
|
||||
}
|
||||
return { requested, requestedAmt, paid, paidAmt };
|
||||
}, [list]);
|
||||
|
||||
const onPay = async (p: Proc) => {
|
||||
const result = await Swal.fire({
|
||||
title: "입금 처리",
|
||||
html: `
|
||||
<div class="text-left text-sm space-y-2">
|
||||
<div><b>발주번호</b> ${p.PROC_NO}</div>
|
||||
<div><b>공급업체</b> ${p.VENDOR_NAME ?? "-"}</div>
|
||||
<div><b>발주금액</b> ₩${fmt(p.TOTAL_AMOUNT)}</div>
|
||||
</div>
|
||||
<div class="mt-3 space-y-2 text-left">
|
||||
<input id="sw-amount" class="swal2-input" placeholder="입금 금액 (기본 발주금액)" value="${p.TOTAL_AMOUNT}" type="number" />
|
||||
<input id="sw-method" class="swal2-input" placeholder="입금 방법 (예: 계좌이체)" />
|
||||
<input id="sw-memo" class="swal2-input" placeholder="메모 (선택)" />
|
||||
</div>
|
||||
`,
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "입금 완료",
|
||||
confirmButtonColor: "#0f766e",
|
||||
cancelButtonText: "취소",
|
||||
focusConfirm: false,
|
||||
preConfirm: () => {
|
||||
const a = (document.getElementById("sw-amount") as HTMLInputElement)?.value;
|
||||
const m = (document.getElementById("sw-method") as HTMLInputElement)?.value;
|
||||
const memo = (document.getElementById("sw-memo") as HTMLInputElement)?.value;
|
||||
return { amount: Number(a) || Number(p.TOTAL_AMOUNT), method: m, memo };
|
||||
},
|
||||
});
|
||||
if (!result.isConfirmed || !result.value) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/m/admin/proc-payments/confirm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objid: p.OBJID, ...result.value }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) {
|
||||
await Swal.fire({ icon: "success", title: "입금 처리 완료", timer: 1200, showConfirmButton: false });
|
||||
load();
|
||||
} else {
|
||||
Swal.fire({ icon: "error", title: "처리 실패", text: j.message });
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold">매입 입금관리</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">발주 요청된 매입에 대해 입금 처리합니다. 입금 완료된 건은 입고 처리 메뉴에서 입고 등록할 수 있습니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 조회조건 — 입력 즉시 반영 */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-3 flex flex-wrap gap-2 items-center text-xs">
|
||||
<span className="text-slate-500 font-semibold mr-1">조회조건</span>
|
||||
<input type="date" value={from} onChange={(e) => setRange([e.target.value, to])}
|
||||
className="h-9 px-2 rounded border border-slate-200" />
|
||||
<span className="text-slate-400">~</span>
|
||||
<input type="date" value={to} onChange={(e) => setRange([from, e.target.value])}
|
||||
className="h-9 px-2 rounded border border-slate-200" />
|
||||
<div className="min-w-[200px]">
|
||||
<SearchableSelect
|
||||
options={[{ value: "", label: "전체 공급업체" }, ...vendors.map((v) => ({ value: v.OBJID, label: v.VENDOR_NAME }))]}
|
||||
value={vendorFilter}
|
||||
onChange={setVendorFilter}
|
||||
placeholder="공급업체"
|
||||
/>
|
||||
</div>
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="h-9 px-2 rounded border border-slate-200 bg-white">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="REQUESTED">발주요청 (미입금)</option>
|
||||
<option value="PAID">입금완료</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 합계 카드 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<div className="text-[11px] text-amber-700 font-semibold mb-1.5">미입금 ({summary.requested}건)</div>
|
||||
<div className="text-lg font-bold tabular-nums text-amber-700">₩{fmt(summary.requestedAmt)}</div>
|
||||
</div>
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-3">
|
||||
<div className="text-[11px] text-emerald-700 font-semibold mb-1.5">입금완료 ({summary.paid}건)</div>
|
||||
<div className="text-lg font-bold tabular-nums text-emerald-700">₩{fmt(summary.paidAmt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모바일: 카드 */}
|
||||
<div className="space-y-2 sm:hidden">
|
||||
{list.length === 0 ? (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6 text-center text-slate-400 text-sm">발주가 없습니다.</div>
|
||||
) : list.map((p) => (
|
||||
<div key={p.OBJID} className="bg-white border border-slate-200 rounded-xl p-3 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-sm truncate">{p.PROC_NO}</div>
|
||||
<div className="text-[11px] text-slate-500">{p.PROC_DATE} · {p.VENDOR_NAME}</div>
|
||||
</div>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-semibold ${STATUS_COLOR[p.STATUS]}`}>
|
||||
{STATUS_LABEL[p.STATUS] || p.STATUS}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right text-sm font-bold tabular-nums mb-2">₩{fmt(p.TOTAL_AMOUNT)}</div>
|
||||
{p.STATUS === "REQUESTED" ? (
|
||||
<button onClick={() => onPay(p)} disabled={busy}
|
||||
className="w-full h-8 rounded bg-emerald-700 text-white text-xs font-bold disabled:opacity-50">입금 처리</button>
|
||||
) : (
|
||||
<div className="text-[11px] text-emerald-700">
|
||||
입금일 {p.PAID_DATE} · ₩{fmt(p.PAID_AMOUNT)} {p.PAID_METHOD && `· ${p.PAID_METHOD}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 데스크탑: 표 */}
|
||||
<div className="hidden sm:block bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-600 text-xs">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2.5">발주번호</th>
|
||||
<th className="text-left px-3 py-2.5">발주일</th>
|
||||
<th className="text-left px-3 py-2.5">공급업체</th>
|
||||
<th className="text-right px-3 py-2.5">발주금액</th>
|
||||
<th className="text-center px-3 py-2.5">상태</th>
|
||||
<th className="text-left px-3 py-2.5">입금일</th>
|
||||
<th className="text-right px-3 py-2.5">입금액</th>
|
||||
<th className="text-center px-3 py-2.5 w-[120px]">동작</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.length === 0 ? (
|
||||
<tr><td colSpan={8} className="text-center py-10 text-slate-400">발주가 없습니다.</td></tr>
|
||||
) : list.map((p) => (
|
||||
<tr key={p.OBJID} className="border-t border-slate-100 hover:bg-slate-50">
|
||||
<td className="px-3 py-2.5 font-semibold">{p.PROC_NO}</td>
|
||||
<td className="px-3 py-2.5">{p.PROC_DATE}</td>
|
||||
<td className="px-3 py-2.5">{p.VENDOR_NAME ?? "-"}</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums font-bold">₩{fmt(p.TOTAL_AMOUNT)}</td>
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-semibold ${STATUS_COLOR[p.STATUS]}`}>
|
||||
{STATUS_LABEL[p.STATUS] || p.STATUS}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[11px] text-slate-600">{p.PAID_DATE || "-"}</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-emerald-700">{p.PAID_AMOUNT ? `₩${fmt(p.PAID_AMOUNT)}` : "-"}</td>
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
{p.STATUS === "REQUESTED" ? (
|
||||
<button onClick={() => onPay(p)} disabled={busy}
|
||||
className="h-7 px-2 rounded bg-emerald-700 text-white text-[11px] font-bold disabled:opacity-50">
|
||||
입금 처리
|
||||
</button>
|
||||
) : <span className="text-[11px] text-slate-400">완료</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// 매입 입금처리 — procurement 의 status 를 PAID 로 변경하고 paid_* 컬럼 채움
|
||||
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 body = await req.json().catch(() => ({}));
|
||||
const { objid, amount, method, memo } = body as {
|
||||
objid?: string; amount?: number; method?: string; memo?: string;
|
||||
};
|
||||
if (!objid) {
|
||||
return NextResponse.json({ success: false, message: "objid 필수" }, { status: 400 });
|
||||
}
|
||||
|
||||
const proc = await queryOne<{ status: string; total_amount: number }>(
|
||||
`SELECT status, total_amount FROM momo_procurements WHERE objid::text = $1`,
|
||||
[objid]
|
||||
);
|
||||
if (!proc) return NextResponse.json({ success: false, message: "발주를 찾을 수 없습니다." }, { status: 404 });
|
||||
if (proc.status !== "REQUESTED") {
|
||||
return NextResponse.json({ success: false, message: `현재 상태(${proc.status})에서는 입금 처리할 수 없습니다.` }, { status: 400 });
|
||||
}
|
||||
|
||||
const paidAmount = Number(amount) > 0 ? Number(amount) : Number(proc.total_amount);
|
||||
|
||||
await execute(
|
||||
`UPDATE momo_procurements
|
||||
SET status = 'PAID',
|
||||
paid_date = NOW(),
|
||||
paid_amount = $1,
|
||||
paid_method = $2,
|
||||
paid_memo = $3
|
||||
WHERE objid::text = $4`,
|
||||
[paidAmount, method || null, memo || null, objid]
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// 매입 입금관리 목록 — 발주요청(REQUESTED) 또는 입금완료(PAID) 매입발주 노출
|
||||
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 body = await req.json().catch(() => ({}));
|
||||
const { dateFrom, dateTo, vendorObjid, status } = body as {
|
||||
dateFrom?: string; dateTo?: string; vendorObjid?: string; status?: string;
|
||||
};
|
||||
|
||||
const cond: string[] = ["COALESCE(P.is_del,'N') != 'Y'", "P.status IN ('REQUESTED','PAID')"];
|
||||
const params: unknown[] = [];
|
||||
let i = 1;
|
||||
if (dateFrom) { cond.push(`P.proc_date >= $${i++}::date`); params.push(dateFrom); }
|
||||
if (dateTo) { cond.push(`P.proc_date <= $${i++}::date`); params.push(dateTo); }
|
||||
if (vendorObjid) { cond.push(`P.vendor_objid = $${i++}::text`); params.push(vendorObjid); }
|
||||
if (status) { cond.push(`P.status = $${i++}`); params.push(status); }
|
||||
|
||||
const rows = await queryRows(
|
||||
`SELECT
|
||||
P.objid::text 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.supply_name AS "VENDOR_NAME",
|
||||
V.charge_user_name AS "VENDOR_CONTACT",
|
||||
P.total_amount AS "TOTAL_AMOUNT",
|
||||
P.status AS "STATUS",
|
||||
P.payment_terms AS "PAYMENT_TERMS",
|
||||
TO_CHAR(P.paid_date,'YYYY-MM-DD') AS "PAID_DATE",
|
||||
P.paid_amount AS "PAID_AMOUNT",
|
||||
P.paid_method AS "PAID_METHOD",
|
||||
P.paid_memo AS "PAID_MEMO"
|
||||
FROM momo_procurements P
|
||||
LEFT JOIN supply_mng V ON V.objid::text = P.vendor_objid
|
||||
WHERE ${cond.join(" AND ")}
|
||||
ORDER BY P.proc_date DESC, P.regdate DESC
|
||||
LIMIT 500`,
|
||||
params
|
||||
);
|
||||
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
|
||||
}
|
||||
Reference in New Issue
Block a user