feat(statistics+admin-panel): 통계 4개 거래명세서 필터 + 거래명세서 관리 메뉴
Deploy momo-erp / deploy (push) Failing after 4m39s
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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
// 거래처별로 묶기
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user