Merge remote-tracking branch 'origin/main' into feat/kakao-login
# Conflicts: # src/app/(main)/m/orders/new/page.tsx
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
-- 010_delivery_charter.sql
|
||||
-- v0.4 (2026-04-27)
|
||||
-- 발주서에 택배비/용차비 라인 + 택배 전용 품목 자동 라인 지원
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. momo_items: 택배 전용 플래그
|
||||
ALTER TABLE momo_items
|
||||
ADD COLUMN IF NOT EXISTS requires_delivery CHAR(1) NOT NULL DEFAULT 'N';
|
||||
COMMENT ON COLUMN momo_items.requires_delivery
|
||||
IS '택배 전용 품목 (Y) — 카트에 담기면 택배 라인이 자동으로 추가됨';
|
||||
|
||||
-- 2. momo_order_items: 라인 종류 + 라벨
|
||||
-- kind: 'ITEM'(품목) / 'DELIVERY'(택배비) / 'CHARTER'(용차비)
|
||||
ALTER TABLE momo_order_items
|
||||
ADD COLUMN IF NOT EXISTS kind VARCHAR(16) NOT NULL DEFAULT 'ITEM',
|
||||
ADD COLUMN IF NOT EXISTS extra_label VARCHAR(100);
|
||||
COMMENT ON COLUMN momo_order_items.kind
|
||||
IS 'ITEM=품목 / DELIVERY=택배비 / CHARTER=용차비';
|
||||
COMMENT ON COLUMN momo_order_items.extra_label
|
||||
IS '택배비/용차비 라인의 담당자명 또는 부가 메모';
|
||||
|
||||
-- 기존 가맹 데이터는 ITEM 으로 간주
|
||||
UPDATE momo_order_items SET kind = 'ITEM' WHERE kind IS NULL;
|
||||
|
||||
-- 3. momo_orders: 택배비/용차비 합계 (집계 편의용)
|
||||
ALTER TABLE momo_orders
|
||||
ADD COLUMN IF NOT EXISTS total_delivery NUMERIC(15,2) DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS total_charter NUMERIC(15,2) DEFAULT 0;
|
||||
COMMENT ON COLUMN momo_orders.total_delivery IS '택배비 라인 합계';
|
||||
COMMENT ON COLUMN momo_orders.total_charter IS '용차비 라인 합계';
|
||||
|
||||
COMMIT;
|
||||
@@ -6,6 +6,10 @@ const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
// 프로젝트 루트 강제 고정 (상위 디렉토리 오탐 방지)
|
||||
outputFileTracingRoot: path.join(__dirname),
|
||||
// standalone 빌드 시 마이그레이션 SQL/스크립트도 함께 포함 (컨테이너에서 실행되도록)
|
||||
outputFileTracingIncludes: {
|
||||
"*": ["./db/migrations/**/*", "./scripts/migrate-momo.mjs"],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -21,6 +21,7 @@ interface Item {
|
||||
ATTRIBUTES: Record<string, unknown> | null;
|
||||
MAX_ORDER_QTY: number | null;
|
||||
IS_HIDDEN: string;
|
||||
REQUIRES_DELIVERY: string;
|
||||
}
|
||||
|
||||
interface Maker { OBJID: string; MAKER_NAME: string }
|
||||
@@ -80,7 +81,7 @@ export default function AdminItemsPage() {
|
||||
};
|
||||
|
||||
const openNew = () => {
|
||||
setEditing({ ITEM_NAME: "", UNIT: "EA", IS_TAX_FREE: "N", STATUS: "ACTIVE", IS_HIDDEN: "N", MAX_ORDER_QTY: null });
|
||||
setEditing({ ITEM_NAME: "", UNIT: "EA", IS_TAX_FREE: "N", STATUS: "ACTIVE", IS_HIDDEN: "N", MAX_ORDER_QTY: null, REQUIRES_DELIVERY: "N" });
|
||||
setAttrs({});
|
||||
};
|
||||
|
||||
@@ -103,6 +104,7 @@ export default function AdminItemsPage() {
|
||||
attributes: Object.keys(attrs).length > 0 ? attrs : null,
|
||||
maxOrderQty: editing.MAX_ORDER_QTY ?? null,
|
||||
isHidden: editing.IS_HIDDEN === "Y" ? "Y" : "N",
|
||||
requiresDelivery: editing.REQUIRES_DELIVERY === "Y" ? "Y" : "N",
|
||||
};
|
||||
const res = await fetch("/api/m/items/save", {
|
||||
method: "POST",
|
||||
@@ -260,6 +262,9 @@ export default function AdminItemsPage() {
|
||||
{it.MAX_ORDER_QTY != null && Number(it.MAX_ORDER_QTY) > 0 && (
|
||||
<span className="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-sky-100 text-sky-700 font-bold">≤{it.MAX_ORDER_QTY}</span>
|
||||
)}
|
||||
{it.REQUIRES_DELIVERY === "Y" && (
|
||||
<span className="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 font-bold">택배</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<button onClick={() => openEdit(it)} className="text-slate-400 hover:text-emerald-700 p-1">
|
||||
@@ -405,6 +410,26 @@ export default function AdminItemsPage() {
|
||||
</label>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="택배 전용 (담기면 자동 택배 라인 추가)">
|
||||
<div className="flex gap-2 h-10">
|
||||
<label className="flex-1 inline-flex items-center justify-center rounded-lg border cursor-pointer text-sm font-semibold hover:bg-slate-50">
|
||||
<input
|
||||
type="radio" name="requires_delivery" checked={editing.REQUIRES_DELIVERY !== "Y"}
|
||||
onChange={() => setEditing({ ...editing, REQUIRES_DELIVERY: "N" })}
|
||||
className="mr-1.5"
|
||||
/>
|
||||
일반
|
||||
</label>
|
||||
<label className="flex-1 inline-flex items-center justify-center rounded-lg border cursor-pointer text-sm font-semibold hover:bg-slate-50">
|
||||
<input
|
||||
type="radio" name="requires_delivery" checked={editing.REQUIRES_DELIVERY === "Y"}
|
||||
onChange={() => setEditing({ ...editing, REQUIRES_DELIVERY: "Y" })}
|
||||
className="mr-1.5"
|
||||
/>
|
||||
택배전용
|
||||
</label>
|
||||
</div>
|
||||
</Field>
|
||||
<div className="sm:col-span-2">
|
||||
<Field label="상세 설명">
|
||||
<textarea
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Download } from "lucide-react";
|
||||
import {
|
||||
ResponsiveContainer, ComposedChart, Bar, Line, XAxis, YAxis, Tooltip, Legend, CartesianGrid,
|
||||
} from "recharts";
|
||||
import { downloadXlsx } from "@/lib/xlsx-export";
|
||||
|
||||
interface Row { DAY: string; ORDER_CNT: number; TOTAL: number; TAX_FREE: number; TAXABLE: number }
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
@@ -14,55 +19,117 @@ function defaultRange() {
|
||||
export default function DailyStatsPage() {
|
||||
const [[from, to], setRange] = useState(defaultRange());
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
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 ?? []);
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
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);
|
||||
const totalCnt = rows.reduce((a, r) => a + Number(r.ORDER_CNT), 0);
|
||||
|
||||
const chartData = rows.map((r) => ({
|
||||
day: r.DAY.slice(5),
|
||||
면세: Number(r.TAX_FREE),
|
||||
과세: Number(r.TAXABLE),
|
||||
합계: Number(r.TOTAL),
|
||||
건수: Number(r.ORDER_CNT),
|
||||
}));
|
||||
|
||||
const onExport = () => {
|
||||
if (rows.length === 0) return;
|
||||
downloadXlsx(
|
||||
`일별매출_${from}_${to}`,
|
||||
rows,
|
||||
[
|
||||
{ header: "일자", key: "DAY", width: 12 },
|
||||
{ header: "주문건수", key: (r) => Number(r.ORDER_CNT), width: 10 },
|
||||
{ header: "면세 합계", key: (r) => Number(r.TAX_FREE), width: 14 },
|
||||
{ header: "과세 공급가", key: (r) => Number(r.TAXABLE), width: 14 },
|
||||
{ header: "총 매출", key: (r) => Number(r.TOTAL), width: 14 },
|
||||
],
|
||||
"일별매출"
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">통계 — 일자별 매출</h1>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h1 className="text-xl sm:text-2xl font-bold">통계 — 일자별 매출</h1>
|
||||
<button
|
||||
onClick={onExport}
|
||||
disabled={rows.length === 0}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-lg bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 disabled:opacity-50"
|
||||
>
|
||||
<Download size={14} /> 엑셀 다운로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
<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" />
|
||||
<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 className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<Card label="주문 건수" value={`${fmt(totalCnt)}건`} color="slate" />
|
||||
<Card label="면세 합계" value={`₩${fmt(totalFree)}`} color="violet" />
|
||||
<Card label="과세 공급가" value={`₩${fmt(totalTaxable)}`} color="rose" />
|
||||
<Card label="총 매출 (VAT)" value={`₩${fmt(total)}`} color="emerald" />
|
||||
</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 className="bg-white border rounded-xl p-4">
|
||||
<h3 className="font-bold text-slate-700 mb-3 text-sm">일별 매출 추이</h3>
|
||||
<div className="w-full h-72 sm:h-80">
|
||||
{loading ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">불러오는 중...</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">데이터가 없습니다.</div>
|
||||
) : (
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={chartData} margin={{ top: 10, right: 20, bottom: 0, left: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="day" tick={{ fontSize: 10 }} />
|
||||
<YAxis yAxisId="left" tick={{ fontSize: 10 }} tickFormatter={(v) => `${(v / 10000).toFixed(0)}만`} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10 }} />
|
||||
<Tooltip
|
||||
formatter={(v, name) => name === "건수" ? `${Number(v)}건` : `₩${fmt(Number(v))}`}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar yAxisId="left" dataKey="면세" stackId="a" fill="#8b5cf6" />
|
||||
<Bar yAxisId="left" dataKey="과세" stackId="a" fill="#f43f5e" />
|
||||
<Line yAxisId="right" type="monotone" dataKey="건수" stroke="#0ea5e9" strokeWidth={2} dot={{ r: 3 }} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<div className="bg-white border rounded-xl overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[600px]">
|
||||
<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>
|
||||
<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) => (
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={5} className="text-center py-12 text-slate-400">선택한 기간의 매출 데이터가 없습니다.</td></tr>
|
||||
) : 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>
|
||||
@@ -77,3 +144,18 @@ export default function DailyStatsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ label, value, color }: { label: string; value: string; color: "slate" | "violet" | "rose" | "emerald" }) {
|
||||
const cls = {
|
||||
slate: "bg-slate-50 border-slate-200 text-slate-800",
|
||||
violet: "bg-violet-50 border-violet-200 text-violet-800",
|
||||
rose: "bg-rose-50 border-rose-200 text-rose-800",
|
||||
emerald: "bg-emerald-50 border-emerald-200 text-emerald-800",
|
||||
}[color];
|
||||
return (
|
||||
<div className={`rounded-xl border ${cls} p-4 sm:p-5`}>
|
||||
<div className="text-xs font-semibold opacity-80 mb-1">{label}</div>
|
||||
<div className="text-lg sm:text-2xl font-bold tabular-nums">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Download } from "lucide-react";
|
||||
import {
|
||||
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, Legend, CartesianGrid,
|
||||
} from "recharts";
|
||||
import { downloadXlsx } from "@/lib/xlsx-export";
|
||||
|
||||
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");
|
||||
@@ -9,13 +14,17 @@ 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 [loading, setLoading] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
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 ?? []);
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line
|
||||
|
||||
@@ -24,10 +33,49 @@ export default function MarginPage() {
|
||||
const totalMargin = totalRev - totalCost;
|
||||
const marginPct = totalRev ? ((totalMargin / totalRev) * 100).toFixed(1) : "0.0";
|
||||
|
||||
const chartData = [...rows]
|
||||
.sort((a, b) => Number(b.MARGIN) - Number(a.MARGIN))
|
||||
.slice(0, 10)
|
||||
.map((r) => ({
|
||||
name: r.ITEM_NAME?.length > 8 ? r.ITEM_NAME.slice(0, 8) + "…" : r.ITEM_NAME,
|
||||
fullName: r.ITEM_NAME,
|
||||
매출: Number(r.REVENUE),
|
||||
원가: Number(r.COST),
|
||||
마진: Number(r.MARGIN),
|
||||
}));
|
||||
|
||||
const onExport = () => {
|
||||
if (rows.length === 0) return;
|
||||
downloadXlsx(
|
||||
`원가마진_${year}년${month}월`,
|
||||
rows,
|
||||
[
|
||||
{ header: "품목코드", key: "ITEM_CODE", width: 16 },
|
||||
{ header: "품목명", key: "ITEM_NAME", width: 24 },
|
||||
{ header: "판매수량", key: (r) => Number(r.QTY), width: 10 },
|
||||
{ header: "매출(공급가)", key: (r) => Number(r.REVENUE), width: 14 },
|
||||
{ header: "매입원가", key: (r) => Number(r.COST), width: 14 },
|
||||
{ header: "마진", key: (r) => Number(r.MARGIN), width: 14 },
|
||||
{ header: "마진율(%)", key: (r) => Number(r.REVENUE) ? Number(((Number(r.MARGIN) / Number(r.REVENUE)) * 100).toFixed(2)) : 0, width: 10 },
|
||||
],
|
||||
`${year}_${month}`
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">통계 — 원가 / 마진 (월간 품목별)</h1>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h1 className="text-xl sm:text-2xl font-bold">통계 — 원가 / 마진</h1>
|
||||
<button
|
||||
onClick={onExport}
|
||||
disabled={rows.length === 0}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-lg bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 disabled:opacity-50"
|
||||
>
|
||||
<Download size={14} /> 엑셀 다운로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<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>
|
||||
@@ -37,15 +85,41 @@ export default function MarginPage() {
|
||||
<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 className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<Card label="매출(공급가)" value={`₩${fmt(totalRev)}`} color="emerald" />
|
||||
<Card label="매입원가" value={`₩${fmt(totalCost)}`} color="amber" />
|
||||
<Card label="마진" value={`₩${fmt(totalMargin)}`} color="blue" />
|
||||
<Card label="마진율" value={`${marginPct}%`} color="violet" />
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<div className="bg-white border rounded-xl p-4">
|
||||
<h3 className="font-bold text-slate-700 mb-3 text-sm">마진 TOP 10 품목</h3>
|
||||
<div className="w-full h-72 sm:h-80">
|
||||
{loading ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">불러오는 중...</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">데이터가 없습니다.</div>
|
||||
) : (
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={chartData} margin={{ top: 10, right: 20, bottom: 30, left: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 10 }} interval={0} angle={-25} textAnchor="end" height={50} />
|
||||
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => `${(v / 10000).toFixed(0)}만`} />
|
||||
<Tooltip
|
||||
formatter={(v) => `₩${fmt(Number(v))}`}
|
||||
labelFormatter={(_, payload) => (payload?.[0]?.payload as { fullName: string })?.fullName ?? ""}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar dataKey="원가" fill="#f59e0b" />
|
||||
<Bar dataKey="마진" fill="#10b981" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-xl overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[700px]">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">품목</th>
|
||||
@@ -78,3 +152,18 @@ export default function MarginPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ label, value, color }: { label: string; value: string; color: "amber" | "blue" | "violet" | "emerald" }) {
|
||||
const cls = {
|
||||
amber: "bg-amber-50 border-amber-200 text-amber-900",
|
||||
blue: "bg-blue-50 border-blue-200 text-blue-900",
|
||||
violet: "bg-violet-50 border-violet-200 text-violet-900",
|
||||
emerald: "bg-emerald-50 border-emerald-200 text-emerald-900",
|
||||
}[color];
|
||||
return (
|
||||
<div className={`rounded-xl border ${cls} p-4 sm:p-5`}>
|
||||
<div className="text-xs font-semibold opacity-80 mb-1">{label}</div>
|
||||
<div className="text-lg sm:text-2xl font-bold tabular-nums">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Download } from "lucide-react";
|
||||
import {
|
||||
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, Legend, CartesianGrid, Cell,
|
||||
} from "recharts";
|
||||
import { downloadXlsx } from "@/lib/xlsx-export";
|
||||
|
||||
interface MonthlyRow {
|
||||
COMPANY_NAME: string;
|
||||
TAX_FREE: number;
|
||||
TAXABLE: number;
|
||||
TOTAL: number;
|
||||
}
|
||||
|
||||
interface MonthlyRow { COMPANY_NAME: string; TAX_FREE: number; TAXABLE: number; TOTAL: number }
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
const COLORS = ["#10b981", "#0ea5e9", "#8b5cf6", "#f59e0b", "#ef4444", "#14b8a6", "#6366f1", "#ec4899", "#84cc16"];
|
||||
|
||||
export default function StatisticsPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear());
|
||||
const [month, setMonth] = useState(new Date().getMonth() + 1);
|
||||
const [rows, setRows] = useState<MonthlyRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/m/statistics/monthly", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ year, month }),
|
||||
});
|
||||
setRows((await res.json()).RESULTLIST ?? []);
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line
|
||||
|
||||
@@ -23,10 +39,46 @@ export default function StatisticsPage() {
|
||||
const grandFree = rows.reduce((a, r) => a + Number(r.TAX_FREE), 0);
|
||||
const grandTaxable = rows.reduce((a, r) => a + Number(r.TAXABLE), 0);
|
||||
|
||||
const chartData = rows
|
||||
.map((r) => ({
|
||||
name: r.COMPANY_NAME?.length > 8 ? r.COMPANY_NAME.slice(0, 8) + "…" : r.COMPANY_NAME,
|
||||
fullName: r.COMPANY_NAME,
|
||||
면세: Number(r.TAX_FREE),
|
||||
과세: Number(r.TAXABLE),
|
||||
합계: Number(r.TOTAL),
|
||||
}))
|
||||
.sort((a, b) => b.합계 - a.합계)
|
||||
.slice(0, 15);
|
||||
|
||||
const onExport = () => {
|
||||
if (rows.length === 0) return;
|
||||
downloadXlsx(
|
||||
`업체별매출_${year}년${month}월`,
|
||||
rows,
|
||||
[
|
||||
{ header: "업체명", key: "COMPANY_NAME", width: 24 },
|
||||
{ header: "면세 합계", key: (r) => Number(r.TAX_FREE), width: 15 },
|
||||
{ header: "과세 공급가", key: (r) => Number(r.TAXABLE), width: 15 },
|
||||
{ header: "총 매출", key: (r) => Number(r.TOTAL), width: 15 },
|
||||
],
|
||||
`${year}_${month}`
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">통계 — 업체별 월간 매출</h1>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h1 className="text-xl sm:text-2xl font-bold">통계 — 업체별 월간 매출</h1>
|
||||
<button
|
||||
onClick={onExport}
|
||||
disabled={rows.length === 0}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-lg bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 disabled:opacity-50"
|
||||
>
|
||||
<Download size={14} /> 엑셀 다운로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<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>
|
||||
@@ -36,14 +88,44 @@ export default function StatisticsPage() {
|
||||
<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="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<Card label="면세 합계" value={fmt(grandFree)} color="violet" />
|
||||
<Card label="과세 공급가" value={fmt(grandTaxable)} color="rose" />
|
||||
<Card label="총 매출 (VAT포함)" value={fmt(grandTotal)} color="emerald" />
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
{/* 차트 */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<h3 className="font-bold text-slate-700 mb-3 text-sm">업체별 매출 (TOP 15)</h3>
|
||||
<div className="w-full h-72 sm:h-80">
|
||||
{loading ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">불러오는 중...</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">데이터가 없습니다.</div>
|
||||
) : (
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={chartData} margin={{ top: 10, right: 10, bottom: 30, left: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 11 }} interval={0} angle={-25} textAnchor="end" height={50} />
|
||||
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => `${(v / 10000).toFixed(0)}만`} />
|
||||
<Tooltip
|
||||
formatter={(v) => `₩${fmt(Number(v))}`}
|
||||
labelFormatter={(_, payload) => (payload?.[0]?.payload as { fullName: string })?.fullName ?? ""}
|
||||
cursor={{ fill: "rgba(16, 185, 129, 0.05)" }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar dataKey="면세" stackId="a" fill="#8b5cf6" />
|
||||
<Bar dataKey="과세" stackId="a" fill="#f43f5e">
|
||||
{chartData.map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} fillOpacity={0.7} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[600px]">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">업체명</th>
|
||||
@@ -77,9 +159,9 @@ function Card({ label, value, color }: { label: string; value: string; color: "v
|
||||
emerald: "from-emerald-50 to-emerald-100 text-emerald-800 border-emerald-200",
|
||||
}[color];
|
||||
return (
|
||||
<div className={`rounded-xl border bg-gradient-to-br ${cls} p-5`}>
|
||||
<div className={`rounded-xl border bg-gradient-to-br ${cls} p-4 sm:p-5`}>
|
||||
<div className="text-xs font-semibold opacity-80 mb-1">{label}</div>
|
||||
<div className="text-2xl font-bold tabular-nums">₩{value}</div>
|
||||
<div className="text-xl sm:text-2xl font-bold tabular-nums">₩{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Search, ShoppingCart, Plus, Minus, X, LayoutGrid, List } from "lucide-react";
|
||||
import { Search, ShoppingCart, Plus, Minus, X, LayoutGrid, List, Truck, Package } from "lucide-react";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
interface Item {
|
||||
@@ -16,10 +16,15 @@ interface Item {
|
||||
IS_TAX_FREE: string;
|
||||
IMAGE_URL: string;
|
||||
STOCK_QTY: number;
|
||||
MAX_ORDER_QTY: number | null;
|
||||
IS_HIDDEN: string;
|
||||
REQUIRES_DELIVERY: string;
|
||||
}
|
||||
interface CartLine { item: Item; qty: number }
|
||||
interface ExtraLine { id: string; kind: "DELIVERY" | "CHARTER"; amount: number; label: string }
|
||||
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
const newKey = () => Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
||||
|
||||
// 모바일 발주 페이지의 뷰 모드 (card/list) 사용자 선택 기억
|
||||
const VIEW_MODE_KEY = "momo_orders_new_view_mode";
|
||||
@@ -33,6 +38,7 @@ export default function ItemsBrowse() {
|
||||
const [taxFilter, setTaxFilter] = useState<"" | "Y" | "N">("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [cart, setCart] = useState<CartLine[]>([]);
|
||||
const [extras, setExtras] = useState<ExtraLine[]>([]);
|
||||
const [cartOpen, setCartOpen] = useState(false);
|
||||
// 모바일 뷰 모드 — card(기본, 2열 그리드) / list(가로 한 줄)
|
||||
const [viewMode, setViewMode] = useState<"card" | "list">("card");
|
||||
@@ -48,7 +54,7 @@ export default function ItemsBrowse() {
|
||||
if (typeof window !== "undefined") window.localStorage.setItem(VIEW_MODE_KEY, m);
|
||||
};
|
||||
|
||||
const fetchItems = async () => {
|
||||
const fetchItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const res = await fetch("/api/m/items/list", {
|
||||
method: "POST",
|
||||
@@ -58,19 +64,40 @@ export default function ItemsBrowse() {
|
||||
const j = await res.json();
|
||||
setItems(j.RESULTLIST ?? []);
|
||||
setLoading(false);
|
||||
};
|
||||
}, [keyword, taxFilter]);
|
||||
|
||||
useEffect(() => { fetchItems(); }, []); // eslint-disable-line
|
||||
|
||||
// 카트에 택배전용 품목이 있는지
|
||||
const cartNeedsDelivery = useMemo(
|
||||
() => cart.some((c) => c.item.REQUIRES_DELIVERY === "Y"),
|
||||
[cart]
|
||||
);
|
||||
const hasDeliveryLine = extras.some((e) => e.kind === "DELIVERY");
|
||||
|
||||
// 택배전용 품목이 카트에 들어왔는데 택배 라인이 없으면 자동 한 줄 추가
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
if (cartNeedsDelivery && !hasDeliveryLine) {
|
||||
setExtras((prev) => [
|
||||
{ id: newKey(), kind: "DELIVERY", amount: 0, label: "택배비" },
|
||||
...prev,
|
||||
]);
|
||||
}
|
||||
}, [cartNeedsDelivery, hasDeliveryLine]);
|
||||
|
||||
const addToCart = (item: Item) => {
|
||||
setCart((c) => {
|
||||
const found = c.find((x) => x.item.OBJID === item.OBJID);
|
||||
const stock = Number(item.STOCK_QTY);
|
||||
const maxQ = Number(item.MAX_ORDER_QTY ?? 0);
|
||||
const limit = maxQ > 0 ? Math.min(stock, maxQ) : stock;
|
||||
if (found) {
|
||||
if (found.qty + 1 > Number(item.STOCK_QTY)) {
|
||||
Swal.fire({ icon: "warning", title: "재고 부족", text: `현재고는 ${fmt(item.STOCK_QTY)}개입니다.` });
|
||||
if (found.qty + 1 > limit) {
|
||||
Swal.fire({
|
||||
icon: "warning",
|
||||
title: maxQ > 0 && stock > maxQ ? "1회 발주 한도 초과" : "재고 부족",
|
||||
text: `현재 ${maxQ > 0 && stock > maxQ ? `1회 한도는 ${fmt(maxQ)}개` : `재고는 ${fmt(stock)}개`}입니다.`,
|
||||
});
|
||||
return c;
|
||||
}
|
||||
return c.map((x) => x.item.OBJID === item.OBJID ? { ...x, qty: x.qty + 1 } : x);
|
||||
@@ -80,7 +107,7 @@ export default function ItemsBrowse() {
|
||||
Swal.fire({
|
||||
toast: true, position: "top-end", icon: "success",
|
||||
title: `장바구니에 추가됨: ${item.ITEM_NAME}`,
|
||||
showConfirmButton: false, timer: 1500, timerProgressBar: true,
|
||||
showConfirmButton: false, timer: 1200, timerProgressBar: true,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -90,16 +117,47 @@ export default function ItemsBrowse() {
|
||||
if (x.item.OBJID !== objid) return x;
|
||||
const newQty = x.qty + delta;
|
||||
if (newQty <= 0) return x;
|
||||
if (newQty > Number(x.item.STOCK_QTY)) return x;
|
||||
const stock = Number(x.item.STOCK_QTY);
|
||||
const maxQ = Number(x.item.MAX_ORDER_QTY ?? 0);
|
||||
const limit = maxQ > 0 ? Math.min(stock, maxQ) : stock;
|
||||
if (newQty > limit) return x;
|
||||
return { ...x, qty: newQty };
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const setQty = (objid: string, value: number) => {
|
||||
setCart((c) =>
|
||||
c.map((x) => {
|
||||
if (x.item.OBJID !== objid) return x;
|
||||
const stock = Number(x.item.STOCK_QTY);
|
||||
const maxQ = Number(x.item.MAX_ORDER_QTY ?? 0);
|
||||
const limit = maxQ > 0 ? Math.min(stock, maxQ) : stock;
|
||||
const clamped = Math.max(1, Math.min(limit, Math.floor(value || 0)));
|
||||
return { ...x, qty: clamped };
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const removeLine = (objid: string) => setCart((c) => c.filter((x) => x.item.OBJID !== objid));
|
||||
|
||||
const addExtra = (kind: "DELIVERY" | "CHARTER") => {
|
||||
setExtras((p) => [...p, { id: newKey(), kind, amount: 0, label: kind === "DELIVERY" ? "택배비" : "용차비" }]);
|
||||
};
|
||||
const updateExtra = (id: string, field: keyof ExtraLine, value: string | number) => {
|
||||
setExtras((p) => p.map((e) => (e.id === id ? { ...e, [field]: value } as ExtraLine : e)));
|
||||
};
|
||||
const removeExtra = (id: string) => {
|
||||
const target = extras.find((e) => e.id === id);
|
||||
if (target?.kind === "DELIVERY" && cartNeedsDelivery) {
|
||||
Swal.fire({ icon: "warning", title: "택배 전용 품목이 있어 택배 라인을 제거할 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
setExtras((p) => p.filter((e) => e.id !== id));
|
||||
};
|
||||
|
||||
const totals = useMemo(() => {
|
||||
let supply = 0, vat = 0, total = 0, taxFree = 0, taxable = 0, count = 0;
|
||||
let supply = 0, vat = 0, total = 0, taxFree = 0, taxable = 0, delivery = 0, charter = 0, count = 0;
|
||||
for (const ln of cart) {
|
||||
const lineTotal = Math.round(Number(ln.item.UNIT_PRICE) * ln.qty);
|
||||
total += lineTotal;
|
||||
@@ -114,18 +172,36 @@ export default function ItemsBrowse() {
|
||||
taxable += s;
|
||||
}
|
||||
}
|
||||
return { supply, vat, total, taxFree, taxable, count };
|
||||
}, [cart]);
|
||||
for (const ex of extras) {
|
||||
const amt = Number(ex.amount) || 0;
|
||||
total += amt;
|
||||
const s = Math.round(amt / 1.1);
|
||||
supply += s;
|
||||
vat += amt - s;
|
||||
taxable += s;
|
||||
if (ex.kind === "DELIVERY") delivery += amt;
|
||||
if (ex.kind === "CHARTER") charter += amt;
|
||||
}
|
||||
return { supply, vat, total, taxFree, taxable, delivery, charter, count };
|
||||
}, [cart, extras]);
|
||||
|
||||
const submitOrder = async () => {
|
||||
if (cart.length === 0) {
|
||||
Swal.fire({ icon: "warning", title: "발주 품목을 추가하세요." });
|
||||
return;
|
||||
}
|
||||
if (cartNeedsDelivery && !hasDeliveryLine) {
|
||||
Swal.fire({ icon: "warning", title: "택배 전용 품목이 포함되어 택배 라인이 필요합니다." });
|
||||
return;
|
||||
}
|
||||
if (extras.some((e) => Number(e.amount) <= 0)) {
|
||||
Swal.fire({ icon: "warning", title: "택배/용차 금액을 입력하세요." });
|
||||
return;
|
||||
}
|
||||
const ok = await Swal.fire({
|
||||
icon: "question",
|
||||
title: "발주를 요청하시겠습니까?",
|
||||
text: `합계 ₩${fmt(totals.total)} (${cart.length}개 품목)`,
|
||||
text: `합계 ₩${fmt(totals.total)} (품목 ${cart.length}, 부가 ${extras.length})`,
|
||||
showCancelButton: true, confirmButtonText: "발주", cancelButtonText: "취소",
|
||||
confirmButtonColor: "#0f766e",
|
||||
});
|
||||
@@ -135,12 +211,13 @@ export default function ItemsBrowse() {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
lines: cart.map((c) => ({ itemObjid: c.item.OBJID, qty: c.qty })),
|
||||
extras: extras.map((e) => ({ kind: e.kind, amount: Number(e.amount), label: e.label })),
|
||||
}),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) {
|
||||
await Swal.fire({ icon: "success", title: "발주 요청 완료", text: `발주번호: ${j.orderNo}` });
|
||||
setCart([]);
|
||||
setCart([]); setExtras([]);
|
||||
router.push("/m/orders");
|
||||
} else {
|
||||
Swal.fire({ icon: "error", title: "오류", text: j.message });
|
||||
@@ -175,7 +252,7 @@ export default function ItemsBrowse() {
|
||||
<ShoppingCart size={18} className="text-emerald-700" />
|
||||
발주 장바구니
|
||||
<span className="px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 text-xs font-bold tabular-nums">
|
||||
{cart.length}
|
||||
{cart.length + extras.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -194,35 +271,106 @@ export default function ItemsBrowse() {
|
||||
</div>
|
||||
|
||||
{cartOpen && (
|
||||
<div className="border-t border-emerald-100 px-4 py-3 max-h-[40vh] overflow-y-auto bg-slate-50/50">
|
||||
{cart.length === 0 ? (
|
||||
<div className="border-t border-emerald-100 px-4 py-3 max-h-[55vh] overflow-y-auto bg-slate-50/50 space-y-3">
|
||||
{/* 택배/용차 추가 버튼 */}
|
||||
<div className="flex flex-wrap gap-2 items-center justify-between">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addExtra("DELIVERY")}
|
||||
className="inline-flex items-center gap-1 h-8 px-3 rounded-md bg-orange-100 text-orange-700 text-xs font-bold hover:bg-orange-200"
|
||||
>
|
||||
<Truck size={13} /> + 택배 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addExtra("CHARTER")}
|
||||
className="inline-flex items-center gap-1 h-8 px-3 rounded-md bg-sky-100 text-sky-700 text-xs font-bold hover:bg-sky-200"
|
||||
>
|
||||
<Package size={13} /> + 용차 추가
|
||||
</button>
|
||||
</div>
|
||||
{(cart.length > 0 || extras.length > 0) && (
|
||||
<button
|
||||
onClick={() => { setCart([]); setExtras([]); }}
|
||||
className="text-xs text-slate-400 hover:text-rose-500"
|
||||
>
|
||||
전체 삭제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 택배/용차 라인 */}
|
||||
{extras.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{extras.map((ex) => (
|
||||
<div
|
||||
key={ex.id}
|
||||
className={`flex items-center gap-2 p-2 rounded-lg border ${ex.kind === "DELIVERY" ? "bg-orange-50/60 border-orange-200" : "bg-sky-50/60 border-sky-200"}`}
|
||||
>
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded shrink-0 ${ex.kind === "DELIVERY" ? "bg-orange-200 text-orange-800" : "bg-sky-200 text-sky-800"}`}>
|
||||
{ex.kind === "DELIVERY" ? "택배" : "용차"}
|
||||
</span>
|
||||
<input
|
||||
value={ex.label}
|
||||
onChange={(e) => updateExtra(ex.id, "label", e.target.value)}
|
||||
placeholder="담당자/메모"
|
||||
className="flex-1 min-w-0 h-8 px-2 rounded border border-slate-200 text-xs bg-white"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={ex.amount || ""}
|
||||
onChange={(e) => updateExtra(ex.id, "amount", Number(e.target.value))}
|
||||
placeholder="금액"
|
||||
className="w-24 sm:w-32 h-8 px-2 rounded border border-slate-200 text-xs text-right tabular-nums bg-white"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeExtra(ex.id)}
|
||||
className="text-slate-300 hover:text-rose-500 shrink-0"
|
||||
title="삭제"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 품목 라인 */}
|
||||
{cart.length === 0 && extras.length === 0 ? (
|
||||
<div className="text-slate-400 text-sm text-center py-6">
|
||||
아래 품목 카드의 <span className="font-bold text-emerald-700">+ 담기</span> 버튼으로 추가하세요.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-end mb-2">
|
||||
<button onClick={() => setCart([])} className="text-xs text-slate-400 hover:text-rose-500">
|
||||
전체 삭제
|
||||
</button>
|
||||
</div>
|
||||
) : cart.length > 0 && (
|
||||
<div className="grid sm:grid-cols-2 gap-2">
|
||||
{cart.map((ln) => {
|
||||
const lineTotal = Math.round(Number(ln.item.UNIT_PRICE) * ln.qty);
|
||||
return (
|
||||
<div key={ln.item.OBJID} className="bg-white border border-slate-100 rounded-lg p-2.5">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="text-sm font-semibold leading-tight">{ln.item.ITEM_NAME}</div>
|
||||
<button onClick={() => removeLine(ln.item.OBJID)} className="text-slate-300 hover:text-rose-500">
|
||||
<div className="text-sm font-semibold leading-tight min-w-0">
|
||||
<div className="truncate">{ln.item.ITEM_NAME}</div>
|
||||
{ln.item.REQUIRES_DELIVERY === "Y" && (
|
||||
<span className="inline-block mt-0.5 text-[9px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 font-bold">택배전용</span>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => removeLine(ln.item.OBJID)} className="text-slate-300 hover:text-rose-500 shrink-0">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => updateQty(ln.item.OBJID, -1)} className="w-7 h-7 rounded-md bg-slate-100 hover:bg-slate-200 flex items-center justify-center">
|
||||
<Minus size={12} />
|
||||
</button>
|
||||
<span className="w-10 text-center text-sm font-bold tabular-nums">{ln.qty}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={ln.qty}
|
||||
onChange={(e) => setQty(ln.item.OBJID, Number(e.target.value))}
|
||||
className="w-12 h-7 text-center text-sm font-bold tabular-nums border border-slate-200 rounded"
|
||||
/>
|
||||
<button onClick={() => updateQty(ln.item.OBJID, 1)} className="w-7 h-7 rounded-md bg-slate-100 hover:bg-slate-200 flex items-center justify-center">
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
@@ -233,14 +381,16 @@ export default function ItemsBrowse() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="border-t border-slate-200 mt-3 pt-2 grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
|
||||
)}
|
||||
|
||||
<div className="border-t border-slate-200 pt-2 grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
|
||||
<Row label="면세 합계" value={`₩${fmt(totals.taxFree)}`} color="violet" />
|
||||
<Row label="과세 공급가" value={`₩${fmt(totals.taxable)}`} color="rose" />
|
||||
<Row label="세액" value={`₩${fmt(totals.vat)}`} />
|
||||
<Row label="총 합계" value={`₩${fmt(totals.total)}`} />
|
||||
{totals.delivery > 0 && <Row label="택배비" value={`₩${fmt(totals.delivery)}`} color="orange" />}
|
||||
{totals.charter > 0 && <Row label="용차비" value={`₩${fmt(totals.charter)}`} color="sky" />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -323,9 +473,14 @@ export default function ItemsBrowse() {
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<div className="font-bold text-sm text-slate-900 leading-tight">{it.ITEM_NAME}</div>
|
||||
<div className="flex flex-col gap-0.5 items-end shrink-0">
|
||||
{it.IS_TAX_FREE === "Y" && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 text-[10px] font-bold">면세</span>
|
||||
)}
|
||||
{it.REQUIRES_DELIVERY === "Y" && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 text-[10px] font-bold">택배</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mb-2">{it.MAKER_NAME || "-"}</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
@@ -334,6 +489,9 @@ export default function ItemsBrowse() {
|
||||
재고 {fmt(it.STOCK_QTY)} {it.UNIT}
|
||||
</div>
|
||||
</div>
|
||||
{it.MAX_ORDER_QTY != null && Number(it.MAX_ORDER_QTY) > 0 && (
|
||||
<div className="text-[10px] text-sky-700 mt-1">1회 한도 ≤ {fmt(it.MAX_ORDER_QTY)}</div>
|
||||
)}
|
||||
<button
|
||||
disabled={Number(it.STOCK_QTY) === 0}
|
||||
onClick={() => addToCart(it)}
|
||||
@@ -366,6 +524,9 @@ export default function ItemsBrowse() {
|
||||
{it.IS_TAX_FREE === "Y" && (
|
||||
<span className="shrink-0 px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 text-[10px] font-bold">면세</span>
|
||||
)}
|
||||
{it.REQUIRES_DELIVERY === "Y" && (
|
||||
<span className="shrink-0 px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 text-[10px] font-bold">택배</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 truncate">{it.MAKER_NAME || "-"}</div>
|
||||
</div>
|
||||
@@ -374,6 +535,9 @@ export default function ItemsBrowse() {
|
||||
<div className={`text-xs font-semibold ${Number(it.STOCK_QTY) > 0 ? "text-emerald-700" : "text-rose-500"}`}>
|
||||
재고 {fmt(it.STOCK_QTY)} {it.UNIT}
|
||||
</div>
|
||||
{it.MAX_ORDER_QTY != null && Number(it.MAX_ORDER_QTY) > 0 && (
|
||||
<div className="text-[10px] text-sky-700">1회 한도 ≤ {fmt(it.MAX_ORDER_QTY)}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
disabled={Number(it.STOCK_QTY) === 0}
|
||||
@@ -465,9 +629,19 @@ export default function ItemsBrowse() {
|
||||
<div className="text-slate-300 text-xs">이미지 없음</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-bold text-base text-slate-900 leading-snug line-clamp-2 min-h-[2.5rem]">
|
||||
<div className="flex items-start justify-between gap-1">
|
||||
<div className="font-bold text-base text-slate-900 leading-snug line-clamp-2 min-h-[2.5rem] flex-1 min-w-0">
|
||||
{it.ITEM_NAME}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 items-end shrink-0">
|
||||
{it.IS_TAX_FREE === "Y" && (
|
||||
<span className="px-1 py-0.5 rounded bg-violet-100 text-violet-700 text-[10px] font-bold">면세</span>
|
||||
)}
|
||||
{it.REQUIRES_DELIVERY === "Y" && (
|
||||
<span className="px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[10px] font-bold">택배</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 mt-1 truncate">{it.MAKER_NAME || "-"}</div>
|
||||
<div className="font-extrabold text-slate-900 tabular-nums text-lg mt-2">
|
||||
₩{fmt(it.UNIT_PRICE)}
|
||||
@@ -475,6 +649,9 @@ export default function ItemsBrowse() {
|
||||
<div className={`text-sm font-semibold mt-1 ${Number(it.STOCK_QTY) > 0 ? "text-emerald-700" : "text-rose-500"}`}>
|
||||
{Number(it.STOCK_QTY) > 0 ? `재고 ${fmt(it.STOCK_QTY)}${it.UNIT || ""}` : "재고 없음"}
|
||||
</div>
|
||||
{it.MAX_ORDER_QTY != null && Number(it.MAX_ORDER_QTY) > 0 && (
|
||||
<div className="text-xs text-sky-700 mt-0.5">1회 한도 ≤ {fmt(it.MAX_ORDER_QTY)}</div>
|
||||
)}
|
||||
<button
|
||||
disabled={Number(it.STOCK_QTY) === 0}
|
||||
onClick={() => addToCart(it)}
|
||||
@@ -502,11 +679,19 @@ export default function ItemsBrowse() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-bold text-base text-slate-900 leading-snug line-clamp-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="font-bold text-base text-slate-900 leading-snug line-clamp-1 flex-1 min-w-0">
|
||||
{it.ITEM_NAME}
|
||||
</div>
|
||||
{it.IS_TAX_FREE === "Y" && (
|
||||
<span className="shrink-0 px-1 py-0.5 rounded bg-violet-100 text-violet-700 text-[10px] font-bold">면세</span>
|
||||
)}
|
||||
{it.REQUIRES_DELIVERY === "Y" && (
|
||||
<span className="shrink-0 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[10px] font-bold">택배</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 truncate mt-0.5">{it.MAKER_NAME || "-"}</div>
|
||||
<div className="flex items-baseline gap-2 mt-1.5">
|
||||
<div className="flex items-baseline gap-2 mt-1.5 flex-wrap">
|
||||
<div className="font-extrabold text-slate-900 tabular-nums text-lg">
|
||||
₩{fmt(it.UNIT_PRICE)}
|
||||
</div>
|
||||
@@ -520,6 +705,9 @@ export default function ItemsBrowse() {
|
||||
? `재고 ${fmt(it.STOCK_QTY)}${it.UNIT || ""}`
|
||||
: "재고 없음"}
|
||||
</div>
|
||||
{it.MAX_ORDER_QTY != null && Number(it.MAX_ORDER_QTY) > 0 && (
|
||||
<div className="text-xs text-sky-700">1회 한도 ≤ {fmt(it.MAX_ORDER_QTY)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -546,7 +734,7 @@ export default function ItemsBrowse() {
|
||||
<ShoppingCart size={22} className="text-emerald-700" />
|
||||
<span className="text-base">장바구니</span>
|
||||
<span className="px-2.5 py-0.5 rounded-full bg-emerald-100 text-emerald-700 text-sm font-extrabold tabular-nums min-w-[28px] text-center">
|
||||
{totals.count}
|
||||
{cart.length + extras.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -558,39 +746,99 @@ export default function ItemsBrowse() {
|
||||
</button>
|
||||
|
||||
{cartOpen && (
|
||||
<div className="border-t border-emerald-100 px-3 py-3 max-h-[50vh] overflow-y-auto bg-slate-50/60">
|
||||
{cart.length === 0 ? (
|
||||
<div className="text-slate-500 text-base text-center py-6">
|
||||
위 품목의 <span className="font-bold text-emerald-700">담기</span> 버튼을 눌러주세요.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-end mb-2">
|
||||
<div className="border-t border-emerald-100 px-3 py-3 max-h-[60vh] overflow-y-auto bg-slate-50/60 space-y-3">
|
||||
{/* 택배/용차 추가 버튼 + 전체 비우기 */}
|
||||
<div className="flex flex-wrap gap-2 items-center justify-between">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setCart([])}
|
||||
type="button"
|
||||
onClick={() => addExtra("DELIVERY")}
|
||||
className="inline-flex items-center gap-1 h-10 px-3 rounded-lg bg-orange-100 text-orange-700 text-sm font-bold active:bg-orange-200"
|
||||
>
|
||||
<Truck size={16} /> + 택배
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addExtra("CHARTER")}
|
||||
className="inline-flex items-center gap-1 h-10 px-3 rounded-lg bg-sky-100 text-sky-700 text-sm font-bold active:bg-sky-200"
|
||||
>
|
||||
<Package size={16} /> + 용차
|
||||
</button>
|
||||
</div>
|
||||
{(cart.length > 0 || extras.length > 0) && (
|
||||
<button
|
||||
onClick={() => { setCart([]); setExtras([]); }}
|
||||
className="text-sm text-slate-500 active:text-rose-500 px-2 py-1"
|
||||
>
|
||||
전체 비우기
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 택배/용차 라인 */}
|
||||
{extras.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{extras.map((ex) => (
|
||||
<div
|
||||
key={ex.id}
|
||||
className={`flex items-center gap-2 p-2 rounded-xl border ${ex.kind === "DELIVERY" ? "bg-orange-50/60 border-orange-200" : "bg-sky-50/60 border-sky-200"}`}
|
||||
>
|
||||
<span className={`text-xs font-bold px-2 py-1 rounded shrink-0 ${ex.kind === "DELIVERY" ? "bg-orange-200 text-orange-800" : "bg-sky-200 text-sky-800"}`}>
|
||||
{ex.kind === "DELIVERY" ? "택배" : "용차"}
|
||||
</span>
|
||||
<input
|
||||
value={ex.label}
|
||||
onChange={(e) => updateExtra(ex.id, "label", e.target.value)}
|
||||
placeholder="담당자/메모"
|
||||
className="flex-1 min-w-0 h-10 px-2 rounded-lg border border-slate-200 text-sm bg-white"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={ex.amount || ""}
|
||||
onChange={(e) => updateExtra(ex.id, "amount", Number(e.target.value))}
|
||||
placeholder="금액"
|
||||
className="w-24 h-10 px-2 rounded-lg border border-slate-200 text-sm text-right tabular-nums bg-white"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeExtra(ex.id)}
|
||||
className="w-9 h-9 flex items-center justify-center text-slate-400 active:text-rose-500 shrink-0"
|
||||
aria-label="삭제"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 품목 라인 */}
|
||||
{cart.length === 0 && extras.length === 0 ? (
|
||||
<div className="text-slate-500 text-base text-center py-6">
|
||||
위 품목의 <span className="font-bold text-emerald-700">담기</span> 버튼을 눌러주세요.
|
||||
</div>
|
||||
) : cart.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{cart.map((ln) => {
|
||||
const lineTotal = Math.round(Number(ln.item.UNIT_PRICE) * ln.qty);
|
||||
return (
|
||||
<div key={ln.item.OBJID} className="bg-white border border-slate-200 rounded-xl p-3">
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<div className="text-base font-semibold leading-snug flex-1">
|
||||
{ln.item.ITEM_NAME}
|
||||
<div className="text-base font-semibold leading-snug flex-1 min-w-0">
|
||||
<div>{ln.item.ITEM_NAME}</div>
|
||||
{ln.item.REQUIRES_DELIVERY === "Y" && (
|
||||
<span className="inline-block mt-0.5 text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 font-bold">택배전용</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeLine(ln.item.OBJID)}
|
||||
className="w-9 h-9 -m-1 flex items-center justify-center text-slate-400 active:text-rose-500"
|
||||
className="w-9 h-9 -m-1 flex items-center justify-center text-slate-400 active:text-rose-500 shrink-0"
|
||||
aria-label="삭제"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateQty(ln.item.OBJID, -1)}
|
||||
@@ -599,9 +847,13 @@ export default function ItemsBrowse() {
|
||||
>
|
||||
<Minus size={20} strokeWidth={2.5} />
|
||||
</button>
|
||||
<span className="w-12 text-center text-xl font-extrabold tabular-nums">
|
||||
{ln.qty}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={ln.qty}
|
||||
onChange={(e) => setQty(ln.item.OBJID, Number(e.target.value))}
|
||||
className="w-14 h-11 text-center text-lg font-extrabold tabular-nums border border-slate-200 rounded-lg"
|
||||
/>
|
||||
<button
|
||||
onClick={() => updateQty(ln.item.OBJID, 1)}
|
||||
className="w-11 h-11 rounded-lg bg-slate-100 active:bg-slate-200 flex items-center justify-center"
|
||||
@@ -616,13 +868,24 @@ export default function ItemsBrowse() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 합계 */}
|
||||
{(cart.length > 0 || extras.length > 0) && (
|
||||
<div className="border-t border-slate-200 pt-2 grid grid-cols-2 gap-2 text-sm">
|
||||
<Row label="면세 합계" value={`₩${fmt(totals.taxFree)}`} color="violet" />
|
||||
<Row label="과세 공급가" value={`₩${fmt(totals.taxable)}`} color="rose" />
|
||||
<Row label="세액" value={`₩${fmt(totals.vat)}`} />
|
||||
<Row label="총 합계" value={`₩${fmt(totals.total)}`} />
|
||||
{totals.delivery > 0 && <Row label="택배비" value={`₩${fmt(totals.delivery)}`} color="orange" />}
|
||||
{totals.charter > 0 && <Row label="용차비" value={`₩${fmt(totals.charter)}`} color="sky" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={submitOrder}
|
||||
disabled={cart.length === 0}
|
||||
className="w-full mt-3 h-14 rounded-xl bg-emerald-700 text-white text-lg font-extrabold active:bg-emerald-800 disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
className="w-full mt-1 h-14 rounded-xl bg-emerald-700 text-white text-lg font-extrabold active:bg-emerald-800 disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<ShoppingCart size={20} /> 발주 보내기 ({totals.count}개)
|
||||
</button>
|
||||
@@ -636,8 +899,12 @@ export default function ItemsBrowse() {
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, color }: { label: string; value: string; color?: "violet" | "rose" }) {
|
||||
const cls = color === "violet" ? "text-violet-700" : color === "rose" ? "text-rose-700" : "text-slate-700";
|
||||
function Row({ label, value, color }: { label: string; value: string; color?: "violet" | "rose" | "orange" | "sky" }) {
|
||||
const cls = color === "violet" ? "text-violet-700"
|
||||
: color === "rose" ? "text-rose-700"
|
||||
: color === "orange" ? "text-orange-700"
|
||||
: color === "sky" ? "text-sky-700"
|
||||
: "text-slate-700";
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<span className={cls}>{label}</span>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Download, FileText } from "lucide-react";
|
||||
import { Download } from "lucide-react";
|
||||
import { downloadXlsx } from "@/lib/xlsx-export";
|
||||
|
||||
interface Order {
|
||||
OBJID: string;
|
||||
@@ -44,19 +45,44 @@ export default function MyOrdersPage() {
|
||||
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const onExport = () => {
|
||||
if (orders.length === 0) return;
|
||||
downloadXlsx(
|
||||
"발주이력",
|
||||
orders,
|
||||
[
|
||||
{ header: "발주번호", key: "ORDER_NO", width: 18 },
|
||||
{ header: "발주일", key: "ORDER_DATE", width: 12 },
|
||||
{ header: "면세", key: (r) => Number(r.TOTAL_TAXFREE), width: 14 },
|
||||
{ header: "과세", key: (r) => Number(r.TOTAL_TAXABLE), width: 14 },
|
||||
{ header: "합계", key: (r) => Number(r.TOTAL_AMOUNT), width: 14 },
|
||||
{ header: "상태", key: (r) => STATUS_LABEL[String(r.STATUS)] || String(r.STATUS), width: 10 },
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">내 발주 이력</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">전체 {orders.length}건</p>
|
||||
<h1 className="text-xl sm:text-2xl font-bold">내 발주 이력</h1>
|
||||
<p className="text-xs sm:text-sm text-slate-500 mt-1">전체 {orders.length}건</p>
|
||||
</div>
|
||||
<Link href="/m/orders/new" className="px-4 h-10 inline-flex items-center gap-2 rounded-lg bg-emerald-700 text-white text-sm font-bold">
|
||||
새 발주 요청
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onExport}
|
||||
disabled={orders.length === 0}
|
||||
className="inline-flex items-center gap-1.5 h-10 px-3 rounded-lg bg-emerald-700 text-white text-xs sm:text-sm font-bold hover:bg-emerald-800 disabled:opacity-50"
|
||||
>
|
||||
<Download size={14} /> 엑셀
|
||||
</button>
|
||||
<Link href="/m/orders/new" className="px-3 sm:px-4 h-10 inline-flex items-center gap-2 rounded-lg bg-emerald-700 text-white text-xs sm:text-sm font-bold">
|
||||
새 발주
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)} className="h-10 px-3 rounded-lg border border-slate-200 text-sm">
|
||||
<option value="">전체 상태</option>
|
||||
{Object.entries(STATUS_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
@@ -64,8 +90,8 @@ export default function MyOrdersPage() {
|
||||
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold">조회</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[700px]">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-semibold">발주번호</th>
|
||||
|
||||
@@ -78,6 +78,7 @@ export async function POST(req: NextRequest) {
|
||||
I.attributes AS "ATTRIBUTES",
|
||||
I.max_order_qty AS "MAX_ORDER_QTY",
|
||||
COALESCE(I.is_hidden, 'N') AS "IS_HIDDEN",
|
||||
COALESCE(I.requires_delivery, 'N') AS "REQUIRES_DELIVERY",
|
||||
COALESCE((
|
||||
SELECT SUM(S.qty) FROM momo_stocks S
|
||||
JOIN momo_warehouses W ON S.wh_objid = W.objid
|
||||
|
||||
@@ -24,9 +24,11 @@ export async function POST(req: NextRequest) {
|
||||
status,
|
||||
maxOrderQty,
|
||||
isHidden,
|
||||
requiresDelivery,
|
||||
} = body;
|
||||
const maxQty = maxOrderQty == null || maxOrderQty === "" ? null : Number(maxOrderQty);
|
||||
const hidden = isHidden === "Y" ? "Y" : "N";
|
||||
const reqDelivery = requiresDelivery === "Y" ? "Y" : "N";
|
||||
|
||||
if (!itemName) {
|
||||
return NextResponse.json({ success: false, message: "품목명은 필수입니다." }, { status: 400 });
|
||||
@@ -45,15 +47,15 @@ export async function POST(req: NextRequest) {
|
||||
`INSERT INTO momo_items (
|
||||
objid, item_code, item_name, item_detail, maker_objid,
|
||||
unit, unit_price, cost_price, is_tax_free, image_url, attributes, status,
|
||||
max_order_qty, is_hidden,
|
||||
max_order_qty, is_hidden, requires_delivery,
|
||||
is_del, regdate, regid
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,$13,$14,'N',NOW(),$15)`,
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,$13,$14,$15,'N',NOW(),$16)`,
|
||||
[newId, itemCode, cleanName, itemDetail ?? null, makerObjid ?? null,
|
||||
unit ?? "EA", Number(unitPrice ?? 0), Number(costPrice ?? 0),
|
||||
taxFree, imageUrl ?? null,
|
||||
attributes ? JSON.stringify(attributes) : null,
|
||||
status ?? "ACTIVE",
|
||||
maxQty, hidden,
|
||||
maxQty, hidden, reqDelivery,
|
||||
userId]
|
||||
);
|
||||
return NextResponse.json({ success: true, objId: newId, itemCode });
|
||||
@@ -65,15 +67,15 @@ export async function POST(req: NextRequest) {
|
||||
item_name=$2, item_detail=$3, maker_objid=$4, unit=$5,
|
||||
unit_price=$6, cost_price=$7, is_tax_free=$8, image_url=$9,
|
||||
attributes=$10::jsonb, status=$11,
|
||||
max_order_qty=$12, is_hidden=$13,
|
||||
update_date=NOW(), update_id=$14
|
||||
max_order_qty=$12, is_hidden=$13, requires_delivery=$14,
|
||||
update_date=NOW(), update_id=$15
|
||||
WHERE objid=$1`,
|
||||
[objid, cleanName, itemDetail ?? null, makerObjid ?? null, unit ?? "EA",
|
||||
Number(unitPrice ?? 0), Number(costPrice ?? 0),
|
||||
taxFree, imageUrl ?? null,
|
||||
attributes ? JSON.stringify(attributes) : null,
|
||||
status ?? "ACTIVE",
|
||||
maxQty, hidden,
|
||||
maxQty, hidden, reqDelivery,
|
||||
userId]
|
||||
);
|
||||
return NextResponse.json({ success: true, objId: objid });
|
||||
|
||||
@@ -21,6 +21,8 @@ export async function POST(req: NextRequest) {
|
||||
O.total_supply AS "TOTAL_SUPPLY", O.total_vat AS "TOTAL_VAT",
|
||||
O.total_amount AS "TOTAL_AMOUNT",
|
||||
O.total_taxfree AS "TOTAL_TAXFREE", O.total_taxable AS "TOTAL_TAXABLE",
|
||||
COALESCE(O.total_delivery, 0) AS "TOTAL_DELIVERY",
|
||||
COALESCE(O.total_charter, 0) AS "TOTAL_CHARTER",
|
||||
O.invoice_no AS "INVOICE_NO",
|
||||
TO_CHAR(O.invoice_date,'YYYY-MM-DD') AS "INVOICE_DATE",
|
||||
O.paid_amount AS "PAID_AMOUNT",
|
||||
@@ -47,6 +49,8 @@ export async function POST(req: NextRequest) {
|
||||
OI.supply_amount AS "SUPPLY_AMOUNT",
|
||||
OI.vat_amount AS "VAT_AMOUNT",
|
||||
OI.total_amount AS "TOTAL_AMOUNT",
|
||||
COALESCE(OI.kind, 'ITEM') AS "KIND",
|
||||
OI.extra_label AS "EXTRA_LABEL",
|
||||
I.unit AS "UNIT",
|
||||
I.image_url AS "IMAGE_URL",
|
||||
COALESCE(
|
||||
|
||||
@@ -1,30 +1,36 @@
|
||||
// 출고요청서 작성 (대리점) — status=REQUESTED 로 저장
|
||||
// v0.4: 택배비/용차비 라인 + 택배 전용 품목 자동 검증 지원
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { pool, queryOne } from "@/lib/db";
|
||||
import { createObjectId } from "@/lib/utils";
|
||||
import { requireMomoUser } from "@/lib/momo-guard";
|
||||
import { calcLine, sumTotals } from "@/lib/momo-pricing";
|
||||
|
||||
interface InputLine {
|
||||
interface InputItemLine {
|
||||
itemObjid: string;
|
||||
qty: number;
|
||||
}
|
||||
interface InputExtraLine {
|
||||
kind: "DELIVERY" | "CHARTER";
|
||||
amount: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const r = await requireMomoUser();
|
||||
if (r instanceof NextResponse) return r;
|
||||
// MOMO 가입자는 r.user.objid 가 user_id 와 동일하게 채워지지만,
|
||||
// FITO 사용자(예: plm_admin)는 objid 가 undefined 이므로 userId 로 폴백.
|
||||
const customerObjid = r.user.objid || r.user.userId;
|
||||
if (!customerObjid) {
|
||||
return NextResponse.json({ success: false, message: "사용자 식별자를 확인할 수 없습니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
let lines: InputLine[];
|
||||
let lines: InputItemLine[];
|
||||
let extras: InputExtraLine[];
|
||||
let memo: string | undefined;
|
||||
try {
|
||||
const body = await req.json() as { lines: InputLine[]; memo?: string };
|
||||
const body = await req.json() as { lines: InputItemLine[]; extras?: InputExtraLine[]; memo?: string };
|
||||
lines = body.lines;
|
||||
extras = Array.isArray(body.extras) ? body.extras : [];
|
||||
memo = body.memo;
|
||||
} catch {
|
||||
return NextResponse.json({ success: false, message: "요청 본문을 해석할 수 없습니다." }, { status: 400 });
|
||||
@@ -37,9 +43,16 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ success: false, message: "품목/수량 형식이 올바르지 않습니다." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
for (const ex of extras) {
|
||||
if (ex.kind !== "DELIVERY" && ex.kind !== "CHARTER") {
|
||||
return NextResponse.json({ success: false, message: "택배/용차 라인 종류가 올바르지 않습니다." }, { status: 400 });
|
||||
}
|
||||
if (!Number.isFinite(Number(ex.amount)) || Number(ex.amount) < 0) {
|
||||
return NextResponse.json({ success: false, message: "택배/용차 금액 형식이 올바르지 않습니다." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 발주자(회원) 권한 — unlimited_qty='Y' 면 max_order_qty 무시
|
||||
const customerRow = await pool.query(
|
||||
`SELECT COALESCE(unlimited_qty, 'N') AS unlimited_qty FROM user_info WHERE user_id = $1`,
|
||||
[customerObjid]
|
||||
@@ -51,7 +64,9 @@ export async function POST(req: NextRequest) {
|
||||
const items = await pool.query(
|
||||
`SELECT
|
||||
I.objid, I.item_name, I.unit_price, I.is_tax_free,
|
||||
I.max_order_qty, COALESCE(I.is_hidden, 'N') AS is_hidden,
|
||||
I.max_order_qty,
|
||||
COALESCE(I.is_hidden, 'N') AS is_hidden,
|
||||
COALESCE(I.requires_delivery, 'N') AS requires_delivery,
|
||||
COALESCE((
|
||||
SELECT SUM(S.qty) FROM momo_stocks S
|
||||
JOIN momo_warehouses W ON S.wh_objid = W.objid
|
||||
@@ -68,7 +83,8 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ success: false, message: `존재하지 않는 품목입니다: ${missing.itemObjid}` }, { status: 400 });
|
||||
}
|
||||
|
||||
// 수량 검증: 재고 한도 + (unlimited_qty 가 아니면) max_order_qty 한도
|
||||
// 수량/숨김/택배 검증
|
||||
let needsDelivery = false;
|
||||
for (const ln of lines) {
|
||||
const it = itemMap.get(ln.itemObjid)!;
|
||||
const stock = Number(it.stock_qty ?? 0);
|
||||
@@ -87,10 +103,22 @@ export async function POST(req: NextRequest) {
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
if (it.requires_delivery === "Y") needsDelivery = true;
|
||||
}
|
||||
|
||||
// 택배 전용 품목이 있는데 택배 라인이 없으면 차단
|
||||
const hasDeliveryLine = extras.some((e) => e.kind === "DELIVERY");
|
||||
if (needsDelivery && !hasDeliveryLine) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: "택배 전용 품목이 포함되어 택배 라인이 필요합니다. 택배 추가 후 다시 시도하세요.",
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const orderObjid = createObjectId();
|
||||
const orderNo = await genOrderNo();
|
||||
|
||||
// 품목 라인
|
||||
const enriched = lines.map((ln, idx) => {
|
||||
const it = itemMap.get(ln.itemObjid)!;
|
||||
const isFree = it.is_tax_free === "Y";
|
||||
@@ -105,7 +133,30 @@ export async function POST(req: NextRequest) {
|
||||
...calc,
|
||||
};
|
||||
});
|
||||
const totals = sumTotals(enriched);
|
||||
|
||||
// 택배/용차 라인 — 입력 금액이 VAT 포함 합계라고 가정, 일반 과세 처리
|
||||
let totalDelivery = 0;
|
||||
let totalCharter = 0;
|
||||
const extraEnriched = extras.map((ex, idx) => {
|
||||
const amount = Math.round(Number(ex.amount));
|
||||
const calc = calcLine({ unitPrice: amount, qty: 1, isTaxFree: false });
|
||||
if (ex.kind === "DELIVERY") totalDelivery += amount;
|
||||
if (ex.kind === "CHARTER") totalCharter += amount;
|
||||
return {
|
||||
seq: enriched.length + idx + 1,
|
||||
kind: ex.kind,
|
||||
label: ex.label?.trim() || (ex.kind === "DELIVERY" ? "택배비" : "용차비"),
|
||||
unitPrice: amount,
|
||||
qty: 1,
|
||||
isTaxFree: false,
|
||||
...calc,
|
||||
};
|
||||
});
|
||||
|
||||
const totals = sumTotals([
|
||||
...enriched,
|
||||
...extraEnriched,
|
||||
]);
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
@@ -114,22 +165,35 @@ export async function POST(req: NextRequest) {
|
||||
`INSERT INTO momo_orders (
|
||||
objid, order_no, customer_objid, order_date, status,
|
||||
total_supply, total_vat, total_amount, total_taxfree, total_taxable,
|
||||
total_delivery, total_charter,
|
||||
memo, regdate, regid
|
||||
) VALUES ($1,$2,$3,CURRENT_DATE,'REQUESTED',$4,$5,$6,$7,$8,$9,NOW(),$10)`,
|
||||
) VALUES ($1,$2,$3,CURRENT_DATE,'REQUESTED',$4,$5,$6,$7,$8,$9,$10,$11,NOW(),$12)`,
|
||||
[orderObjid, orderNo, customerObjid,
|
||||
totals.supply, totals.vat, totals.total, totals.taxFree, totals.taxable, memo ?? null,
|
||||
totals.supply, totals.vat, totals.total, totals.taxFree, totals.taxable,
|
||||
totalDelivery, totalCharter,
|
||||
memo ?? null,
|
||||
customerObjid]
|
||||
);
|
||||
for (const ln of enriched) {
|
||||
await client.query(
|
||||
`INSERT INTO momo_order_items (
|
||||
objid, order_objid, item_objid, item_name_snap, unit_price, qty,
|
||||
is_tax_free, supply_amount, vat_amount, total_amount, seq
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
|
||||
is_tax_free, supply_amount, vat_amount, total_amount, seq, kind
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'ITEM')`,
|
||||
[createObjectId(), orderObjid, ln.itemObjid, ln.itemName, ln.unitPrice, ln.qty,
|
||||
ln.isTaxFree ? "Y" : "N", ln.supplyAmount, ln.vatAmount, ln.totalAmount, ln.seq]
|
||||
);
|
||||
}
|
||||
for (const ex of extraEnriched) {
|
||||
await client.query(
|
||||
`INSERT INTO momo_order_items (
|
||||
objid, order_objid, item_objid, item_name_snap, unit_price, qty,
|
||||
is_tax_free, supply_amount, vat_amount, total_amount, seq, kind, extra_label
|
||||
) VALUES ($1,$2,NULL,$3,$4,1,'N',$5,$6,$7,$8,$9,$10)`,
|
||||
[createObjectId(), orderObjid, ex.label, ex.unitPrice,
|
||||
ex.supplyAmount, ex.vatAmount, ex.totalAmount, ex.seq, ex.kind, ex.label]
|
||||
);
|
||||
}
|
||||
await client.query("COMMIT");
|
||||
return NextResponse.json({ success: true, objId: orderObjid, orderNo });
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// 엑셀 다운로드 헬퍼 (xlsx)
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
export interface XlsxColumn<T> {
|
||||
header: string;
|
||||
key: keyof T | ((row: T) => string | number);
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export function downloadXlsx<T>(
|
||||
filename: string,
|
||||
rows: T[],
|
||||
columns: XlsxColumn<T>[],
|
||||
sheetName = "Sheet1"
|
||||
): void {
|
||||
const data = rows.map((r) => {
|
||||
const o: Record<string, string | number> = {};
|
||||
for (const c of columns) {
|
||||
const v = typeof c.key === "function"
|
||||
? c.key(r)
|
||||
: ((r as Record<string, unknown>)[c.key as string] as string | number);
|
||||
o[c.header] = v ?? "";
|
||||
}
|
||||
return o;
|
||||
});
|
||||
const ws = XLSX.utils.json_to_sheet(data);
|
||||
if (columns.some((c) => c.width)) {
|
||||
ws["!cols"] = columns.map((c) => ({ wch: c.width ?? 14 }));
|
||||
}
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, sheetName);
|
||||
const stamp = new Date().toISOString().replace(/[-:]/g, "").slice(0, 13);
|
||||
XLSX.writeFile(wb, `${filename}_${stamp}.xlsx`);
|
||||
}
|
||||
Reference in New Issue
Block a user