feat(statistics+admin-panel): 통계 4개 거래명세서 필터 + 거래명세서 관리 메뉴
Deploy momo-erp / deploy (push) Failing after 4m39s

통계 페이지 4개에 거래명세서 기준(전체/본사/김포) 필터 추가:
- /m/admin/statistics (월간 매출)
- /m/admin/statistics/daily (일자별)
- /m/admin/statistics/margin (원가/마진)
- /m/admin/statistics/pivot (거래처×일자)

각 API 의 WHERE 절에
  COALESCE(O.supplier_branch, U.statement_branch, 'HQ') = $N
추가. supplier_branch snapshot 우선, 옛 발주는 user_info.statement_branch
폴백. ALL/생략 시 전체.

admin-panel 권한 및 사용자 관리 섹션에 '거래명세서 관리' 항목 추가
— activeTab='statement-branches' 시 /m/admin/statement-branches iframe
으로 로드 (기존 페이지 재사용, 별도 컴포넌트 중복 없음).
This commit is contained in:
chpark
2026-05-14 16:09:41 +09:00
parent 3e2d8572f1
commit 8d8bb17345
9 changed files with 89 additions and 21 deletions
@@ -18,6 +18,7 @@ function defaultRange() {
export default function DailyStatsPage() {
const [[from, to], setRange] = useState(defaultRange());
const [branch, setBranch] = useState<"ALL" | "HQ" | "KIMPO">("ALL");
const [rows, setRows] = useState<Row[]>([]);
const [loading, setLoading] = useState(false);
@@ -26,12 +27,12 @@ export default function DailyStatsPage() {
try {
const res = await fetch("/api/m/statistics/daily", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dateFrom: from, dateTo: to }),
body: JSON.stringify({ dateFrom: from, dateTo: to, branch }),
});
setRows((await res.json()).RESULTLIST ?? []);
} finally { setLoading(false); }
};
useEffect(() => { load(); }, []); // eslint-disable-line
useEffect(() => { load(); }, [from, to, branch]); // eslint-disable-line
const total = rows.reduce((a, r) => a + Number(r.TOTAL), 0);
const totalFree = rows.reduce((a, r) => a + Number(r.TAX_FREE), 0);
@@ -78,6 +79,11 @@ export default function DailyStatsPage() {
<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 text-sm" />
<input type="date" value={to} onChange={(e) => setRange([from, e.target.value])} className="h-10 px-3 rounded-lg border border-slate-200 text-sm" />
<select value={branch} onChange={(e) => setBranch(e.target.value as "ALL" | "HQ" | "KIMPO")} className="h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
<option value="ALL"> ( )</option>
<option value="HQ"> </option>
<option value="KIMPO"> </option>
</select>
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold"></button>
</div>
@@ -13,6 +13,7 @@ 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 [branch, setBranch] = useState<"ALL" | "HQ" | "KIMPO">("ALL");
const [rows, setRows] = useState<Row[]>([]);
const [loading, setLoading] = useState(false);
@@ -21,12 +22,12 @@ export default function MarginPage() {
try {
const res = await fetch("/api/m/statistics/margin", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ year, month }),
body: JSON.stringify({ year, month, branch }),
});
setRows((await res.json()).RESULTLIST ?? []);
} finally { setLoading(false); }
};
useEffect(() => { load(); }, []); // eslint-disable-line
useEffect(() => { load(); }, [year, month, branch]); // 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);
@@ -82,6 +83,11 @@ export default function MarginPage() {
<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>
<select value={branch} onChange={(e) => setBranch(e.target.value as "ALL" | "HQ" | "KIMPO")} className="h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
<option value="ALL"> ( )</option>
<option value="HQ"> </option>
<option value="KIMPO"> </option>
</select>
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold"></button>
</div>
+8 -2
View File
@@ -20,6 +20,7 @@ const COLORS = ["#10b981", "#0ea5e9", "#8b5cf6", "#f59e0b", "#ef4444", "#14b8a6"
export default function StatisticsPage() {
const [year, setYear] = useState(new Date().getFullYear());
const [month, setMonth] = useState(new Date().getMonth() + 1);
const [branch, setBranch] = useState<"ALL" | "HQ" | "KIMPO">("ALL");
const [rows, setRows] = useState<MonthlyRow[]>([]);
const [loading, setLoading] = useState(false);
@@ -28,12 +29,12 @@ export default function StatisticsPage() {
try {
const res = await fetch("/api/m/statistics/monthly", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ year, month }),
body: JSON.stringify({ year, month, branch }),
});
setRows((await res.json()).RESULTLIST ?? []);
} finally { setLoading(false); }
};
useEffect(() => { load(); }, []); // eslint-disable-line
useEffect(() => { load(); }, [year, month, branch]); // eslint-disable-line
const grandTotal = rows.reduce((a, r) => a + Number(r.TOTAL), 0);
const grandFree = rows.reduce((a, r) => a + Number(r.TAX_FREE), 0);
@@ -85,6 +86,11 @@ export default function StatisticsPage() {
<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>
<select value={branch} onChange={(e) => setBranch(e.target.value as "ALL" | "HQ" | "KIMPO")} className="h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
<option value="ALL"> ( )</option>
<option value="HQ"> </option>
<option value="KIMPO"> </option>
</select>
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold"></button>
</div>
@@ -18,6 +18,7 @@ const fmt = (n: number | undefined | null) => Number(n || 0).toLocaleString("ko-
export default function PivotStatsPage() {
const [year, setYear] = useState(new Date().getFullYear());
const [month, setMonth] = useState(new Date().getMonth() + 1);
const [branch, setBranch] = useState<"ALL" | "HQ" | "KIMPO">("ALL");
const [data, setData] = useState<PivotResp | null>(null);
const [loading, setLoading] = useState(false);
@@ -26,14 +27,14 @@ export default function PivotStatsPage() {
try {
const res = await fetch("/api/m/statistics/monthly-pivot", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ year, month }),
body: JSON.stringify({ year, month, branch }),
});
const j = await res.json();
if (j.success) setData(j);
} finally { setLoading(false); }
}, [year, month]);
}, [year, month, branch]);
useEffect(() => { load(); }, []); // eslint-disable-line
useEffect(() => { load(); }, [load]);
const dayLabel = (d: string) => {
const [, mm, dd] = d.split("-");
@@ -96,6 +97,12 @@ export default function PivotStatsPage() {
className="h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
{Array.from({ length: 12 }, (_, i) => i + 1).map((mm) => <option key={mm} value={mm}>{mm}</option>)}
</select>
<select value={branch} onChange={(e) => setBranch(e.target.value as "ALL" | "HQ" | "KIMPO")}
className="h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
<option value="ALL"> ( )</option>
<option value="HQ"> </option>
<option value="KIMPO"> </option>
</select>
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold"></button>
</div>
+10 -3
View File
@@ -14,7 +14,7 @@ import {
} from "lucide-react";
// admin/adminMainFS.do 대응 - 관리자 팝업 페이지
type AdminTab = "menu" | "auth" | "user" | "dept" | "supply" | "template" | "exchange" | "log-file" | "log-login" | "log-mail" | "ref-customer" | "ref-material" | "ref-car" | "ref-car-grade" | "ref-product-group" | "ref-product" | "spec-data-category" | "car-option";
type AdminTab = "menu" | "auth" | "user" | "dept" | "statement-branches" | "supply" | "template" | "exchange" | "log-file" | "log-login" | "log-mail" | "ref-customer" | "ref-material" | "ref-car" | "ref-car-grade" | "ref-product-group" | "ref-product" | "spec-data-category" | "car-option";
const ADMIN_MENUS = [
{
@@ -27,6 +27,7 @@ const ADMIN_MENUS = [
{ key: "auth" as AdminTab, label: "권한 관리" },
{ key: "dept" as AdminTab, label: "부서 관리" },
{ key: "user" as AdminTab, label: "사용자 관리" },
{ key: "statement-branches" as AdminTab, label: "거래명세서 관리" },
],
},
{
@@ -66,6 +67,7 @@ const LABEL_TO_TAB: Record<string, AdminTab> = {
"권한 관리": "auth",
"부서 관리": "dept",
"사용자 관리": "user",
"거래명세서 관리": "statement-branches",
"공급업체관리": "supply",
"템플릿 관리": "template",
"환율관리": "exchange",
@@ -97,7 +99,7 @@ interface SidebarGroup {
}
const VALID_TABS: AdminTab[] = [
"menu","auth","user","dept","supply","template","exchange",
"menu","auth","user","dept","statement-branches","supply","template","exchange",
"log-file","log-login","log-mail",
"ref-customer","ref-material","ref-car","ref-car-grade",
"ref-product-group","ref-product","spec-data-category","car-option",
@@ -228,6 +230,11 @@ export default function AdminPanelPage() {
{/* 우측 콘텐츠 */}
<main className="flex-1 overflow-auto p-4">
{activeTab === "user" && <UserManagement />}
{activeTab === "statement-branches" && (
<iframe src="/m/admin/statement-branches"
title="거래명세서 관리"
className="w-full h-[calc(100vh-100px)] border-0 rounded" />
)}
{activeTab === "menu" && <MenuManagement />}
{activeTab === "auth" && <AuthManagement />}
{activeTab === "dept" && <DeptManagement />}
@@ -245,7 +252,7 @@ export default function AdminPanelPage() {
{activeTab === "spec-data-category" && <SpecDataCategoryManagement />}
{activeTab === "car-option" && <CarOptionManagement />}
{/* 기타 탭은 공통 Placeholder (DB 테이블 없음) */}
{!["user","menu","auth","dept","supply","log-login","log-file","log-mail","template","exchange","ref-customer","ref-material","ref-car","ref-product-group","ref-product","spec-data-category","car-option"].includes(activeTab) && (
{!["user","menu","auth","dept","statement-branches","supply","log-login","log-file","log-mail","template","exchange","ref-customer","ref-material","ref-car","ref-product-group","ref-product","spec-data-category","car-option"].includes(activeTab) && (
<PlaceholderContent title={groups.flatMap(g => g.items).find(i => LABEL_TO_TAB[i.label] === activeTab)?.label || activeTab} />
)}
</main>
+12 -2
View File
@@ -5,7 +5,15 @@ 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 { dateFrom, dateTo, branch } = await req.json();
const params: unknown[] = [dateFrom, dateTo];
let branchClause = "";
let branchJoin = "";
if (branch && branch !== "ALL") {
branchJoin = "LEFT JOIN user_info U ON U.user_id = O.customer_objid";
branchClause = `AND COALESCE(O.supplier_branch, U.statement_branch, 'HQ') = $3`;
params.push(branch);
}
const rows = await queryRows(
`SELECT TO_CHAR(O.order_date,'YYYY-MM-DD') AS "DAY",
COUNT(*) AS "ORDER_CNT",
@@ -13,11 +21,13 @@ export async function POST(req: NextRequest) {
COALESCE(SUM(O.total_taxfree),0) AS "TAX_FREE",
COALESCE(SUM(O.total_taxable),0) AS "TAXABLE"
FROM momo_orders O
${branchJoin}
WHERE O.order_date BETWEEN $1::date AND $2::date
AND O.status IN ('APPROVED','PAID','INVOICED')
AND COALESCE(O.is_del,'N') != 'Y'
${branchClause}
GROUP BY O.order_date ORDER BY O.order_date ASC`,
[dateFrom, dateTo]
params
);
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
}
+13 -3
View File
@@ -5,11 +5,19 @@ 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 { year, month, branch } = await req.json();
const y = Number(year) || new Date().getFullYear();
const m = Number(month) || new Date().getMonth() + 1;
// 월간 마진 = 매출(공급가) - 원가(qty × cost_price)
const params: unknown[] = [y, m];
let branchJoin = "";
let branchClause = "";
if (branch && branch !== "ALL") {
branchJoin = "LEFT JOIN user_info U ON U.user_id = O.customer_objid";
branchClause = `AND COALESCE(O.supplier_branch, U.statement_branch, 'HQ') = $3`;
params.push(branch);
}
const rows = await queryRows(
`SELECT
I.item_code AS "ITEM_CODE",
@@ -21,13 +29,15 @@ export async function POST(req: NextRequest) {
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
${branchJoin}
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'
${branchClause}
GROUP BY I.item_code, I.item_name
ORDER BY "MARGIN" DESC`,
[y, m]
params
);
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
}
@@ -11,10 +11,17 @@ export async function POST(req: NextRequest) {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
const { year, month } = await req.json().catch(() => ({}));
const { year, month, branch } = await req.json().catch(() => ({}));
const y = Number(year) || new Date().getFullYear();
const m = Number(month) || new Date().getMonth() + 1;
const params: unknown[] = [y, m];
let branchClause = "";
if (branch && branch !== "ALL") {
branchClause = `AND COALESCE(O.supplier_branch, U.statement_branch, 'HQ') = $3`;
params.push(branch);
}
// 거래처별 + 일자별 면세/과세 합계 (출고완료 이상만)
const rows = await queryRows<{
DAY: string; CUSTOMER_OBJID: string; COMPANY_NAME: string;
@@ -32,9 +39,10 @@ export async function POST(req: NextRequest) {
AND EXTRACT(MONTH FROM O.order_date) = $2
AND O.status IN ('APPROVED','SHIPPED','PAID','INVOICED')
AND COALESCE(O.is_del, 'N') != 'Y'
${branchClause}
GROUP BY 1, 2, 3
ORDER BY "COMPANY_NAME"`,
[y, m]
params
);
// 거래처별로 묶기
+10 -2
View File
@@ -6,10 +6,17 @@ export async function POST(req: NextRequest) {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
const { year, month } = await req.json();
const { year, month, branch } = await req.json();
const y = Number(year) || new Date().getFullYear();
const m = Number(month) || new Date().getMonth() + 1;
const params: unknown[] = [y, m];
let branchClause = "";
if (branch && branch !== "ALL") {
branchClause = `AND COALESCE(O.supplier_branch, U.statement_branch, 'HQ') = $3`;
params.push(branch);
}
const rows = await queryRows(
`SELECT
U.user_name AS "COMPANY_NAME",
@@ -22,9 +29,10 @@ export async function POST(req: NextRequest) {
AND EXTRACT(MONTH FROM O.order_date) = $2
AND O.status IN ('APPROVED', 'INVOICED', 'PAID')
AND COALESCE(O.is_del,'N') != 'Y'
${branchClause}
GROUP BY U.user_name
ORDER BY "TOTAL" DESC`,
[y, m]
params
);
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
}