Merge remote-tracking branch 'origin/main' into feat/kakao-login

# Conflicts:
#	src/app/(main)/m/orders/new/page.tsx
This commit is contained in:
hjjeong
2026-05-06 15:01:19 +09:00
13 changed files with 916 additions and 203 deletions
+33
View File
@@ -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;
+4
View File
@@ -6,6 +6,10 @@ const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
// 프로젝트 루트 강제 고정 (상위 디렉토리 오탐 방지) // 프로젝트 루트 강제 고정 (상위 디렉토리 오탐 방지)
outputFileTracingRoot: path.join(__dirname), outputFileTracingRoot: path.join(__dirname),
// standalone 빌드 시 마이그레이션 SQL/스크립트도 함께 포함 (컨테이너에서 실행되도록)
outputFileTracingIncludes: {
"*": ["./db/migrations/**/*", "./scripts/migrate-momo.mjs"],
},
}; };
export default nextConfig; export default nextConfig;
+26 -1
View File
@@ -21,6 +21,7 @@ interface Item {
ATTRIBUTES: Record<string, unknown> | null; ATTRIBUTES: Record<string, unknown> | null;
MAX_ORDER_QTY: number | null; MAX_ORDER_QTY: number | null;
IS_HIDDEN: string; IS_HIDDEN: string;
REQUIRES_DELIVERY: string;
} }
interface Maker { OBJID: string; MAKER_NAME: string } interface Maker { OBJID: string; MAKER_NAME: string }
@@ -80,7 +81,7 @@ export default function AdminItemsPage() {
}; };
const openNew = () => { 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({}); setAttrs({});
}; };
@@ -103,6 +104,7 @@ export default function AdminItemsPage() {
attributes: Object.keys(attrs).length > 0 ? attrs : null, attributes: Object.keys(attrs).length > 0 ? attrs : null,
maxOrderQty: editing.MAX_ORDER_QTY ?? null, maxOrderQty: editing.MAX_ORDER_QTY ?? null,
isHidden: editing.IS_HIDDEN === "Y" ? "Y" : "N", isHidden: editing.IS_HIDDEN === "Y" ? "Y" : "N",
requiresDelivery: editing.REQUIRES_DELIVERY === "Y" ? "Y" : "N",
}; };
const res = await fetch("/api/m/items/save", { const res = await fetch("/api/m/items/save", {
method: "POST", method: "POST",
@@ -260,6 +262,9 @@ export default function AdminItemsPage() {
{it.MAX_ORDER_QTY != null && Number(it.MAX_ORDER_QTY) > 0 && ( {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> <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>
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
<button onClick={() => openEdit(it)} className="text-slate-400 hover:text-emerald-700 p-1"> <button onClick={() => openEdit(it)} className="text-slate-400 hover:text-emerald-700 p-1">
@@ -405,6 +410,26 @@ export default function AdminItemsPage() {
</label> </label>
</div> </div>
</Field> </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"> <div className="sm:col-span-2">
<Field label="상세 설명"> <Field label="상세 설명">
<textarea <textarea
@@ -1,6 +1,11 @@
"use client"; "use client";
import { useEffect, useState } from "react"; 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 } interface Row { DAY: string; ORDER_CNT: number; TOTAL: number; TAX_FREE: number; TAXABLE: number }
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR"); const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
@@ -14,55 +19,117 @@ function defaultRange() {
export default function DailyStatsPage() { export default function DailyStatsPage() {
const [[from, to], setRange] = useState(defaultRange()); const [[from, to], setRange] = useState(defaultRange());
const [rows, setRows] = useState<Row[]>([]); const [rows, setRows] = useState<Row[]>([]);
const [loading, setLoading] = useState(false);
const load = async () => { const load = async () => {
const res = await fetch("/api/m/statistics/daily", { setLoading(true);
method: "POST", headers: { "Content-Type": "application/json" }, try {
body: JSON.stringify({ dateFrom: from, dateTo: to }), const res = await fetch("/api/m/statistics/daily", {
}); method: "POST", headers: { "Content-Type": "application/json" },
setRows((await res.json()).RESULTLIST ?? []); body: JSON.stringify({ dateFrom: from, dateTo: to }),
});
setRows((await res.json()).RESULTLIST ?? []);
} finally { setLoading(false); }
}; };
useEffect(() => { load(); }, []); // eslint-disable-line 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 total = rows.reduce((a, r) => a + Number(r.TOTAL), 0);
const totalFree = rows.reduce((a, r) => a + Number(r.TAX_FREE), 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 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 ( return (
<div className="space-y-4"> <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"> <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={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" /> <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> <button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold"></button>
</div> </div>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-4 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> <Card label="주문 건수" value={`${fmt(totalCnt)}`} color="slate" />
<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> <Card label="면세 합계" value={`${fmt(totalFree)}`} color="violet" />
<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> <Card label="과세 공급가" value={`${fmt(totalTaxable)}`} color="rose" />
<Card label="총 매출 (VAT)" value={`${fmt(total)}`} color="emerald" />
</div> </div>
<div className="bg-white border rounded-xl p-5"> <div className="bg-white border rounded-xl p-4">
<h3 className="font-bold mb-3"> </h3> <h3 className="font-bold text-slate-700 mb-3 text-sm"> </h3>
<div className="flex items-end gap-1 h-48 px-2 overflow-x-auto"> <div className="w-full h-72 sm:h-80">
{rows.length === 0 ? <div className="m-auto text-slate-400"> .</div> : rows.map((r, i) => ( {loading ? (
<div key={i} className="flex flex-col items-center gap-1 min-w-[35px]"> <div className="h-full flex items-center justify-center text-slate-400"> ...</div>
<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)}`} /> ) : chartData.length === 0 ? (
<div className="text-[9px] text-slate-500 -rotate-45 origin-top-left whitespace-nowrap">{r.DAY.slice(5)}</div> <div className="h-full flex items-center justify-center text-slate-400"> .</div>
</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> </div>
<div className="bg-white border rounded-xl overflow-hidden"> <div className="bg-white border rounded-xl overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm min-w-[600px]">
<thead className="bg-slate-50 text-slate-600"> <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> </thead>
<tbody> <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"> <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 font-semibold">{r.DAY}</td>
<td className="px-4 py-2.5 text-right">{r.ORDER_CNT}</td> <td className="px-4 py-2.5 text-right">{r.ORDER_CNT}</td>
@@ -77,3 +144,18 @@ export default function DailyStatsPage() {
</div> </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"; "use client";
import { useEffect, useState } from "react"; 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 } 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"); 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 [year, setYear] = useState(new Date().getFullYear());
const [month, setMonth] = useState(new Date().getMonth() + 1); const [month, setMonth] = useState(new Date().getMonth() + 1);
const [rows, setRows] = useState<Row[]>([]); const [rows, setRows] = useState<Row[]>([]);
const [loading, setLoading] = useState(false);
const load = async () => { const load = async () => {
const res = await fetch("/api/m/statistics/margin", { setLoading(true);
method: "POST", headers: { "Content-Type": "application/json" }, try {
body: JSON.stringify({ year, month }), const res = await fetch("/api/m/statistics/margin", {
}); method: "POST", headers: { "Content-Type": "application/json" },
setRows((await res.json()).RESULTLIST ?? []); body: JSON.stringify({ year, month }),
});
setRows((await res.json()).RESULTLIST ?? []);
} finally { setLoading(false); }
}; };
useEffect(() => { load(); }, []); // eslint-disable-line useEffect(() => { load(); }, []); // eslint-disable-line
@@ -24,10 +33,49 @@ export default function MarginPage() {
const totalMargin = totalRev - totalCost; const totalMargin = totalRev - totalCost;
const marginPct = totalRev ? ((totalMargin / totalRev) * 100).toFixed(1) : "0.0"; 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<h1 className="text-2xl font-bold"> / ( )</h1> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex 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"> <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>)} {Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => <option key={y} value={y}>{y}</option>)}
</select> </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> <button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold"></button>
</div> </div>
<div className="grid grid-cols-4 gap-3"> <div className="grid grid-cols-2 sm: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> <Card label="매출(공급가)" value={`${fmt(totalRev)}`} color="emerald" />
<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> <Card label="매입원가" value={`${fmt(totalCost)}`} color="amber" />
<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> <Card label="마진" value={`${fmt(totalMargin)}`} color="blue" />
<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> <Card label="마진율" value={`${marginPct}%`} color="violet" />
</div> </div>
<div className="bg-white border rounded-xl overflow-hidden"> <div className="bg-white border rounded-xl p-4">
<table className="w-full text-sm"> <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"> <thead className="bg-slate-50 text-slate-600">
<tr> <tr>
<th className="text-left px-4 py-3"></th> <th className="text-left px-4 py-3"></th>
@@ -78,3 +152,18 @@ export default function MarginPage() {
</div> </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>
);
}
+95 -13
View File
@@ -1,21 +1,37 @@
"use client"; "use client";
import { useEffect, useState } from "react"; 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 fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
const COLORS = ["#10b981", "#0ea5e9", "#8b5cf6", "#f59e0b", "#ef4444", "#14b8a6", "#6366f1", "#ec4899", "#84cc16"];
export default function StatisticsPage() { export default function StatisticsPage() {
const [year, setYear] = useState(new Date().getFullYear()); const [year, setYear] = useState(new Date().getFullYear());
const [month, setMonth] = useState(new Date().getMonth() + 1); const [month, setMonth] = useState(new Date().getMonth() + 1);
const [rows, setRows] = useState<MonthlyRow[]>([]); const [rows, setRows] = useState<MonthlyRow[]>([]);
const [loading, setLoading] = useState(false);
const load = async () => { const load = async () => {
const res = await fetch("/api/m/statistics/monthly", { setLoading(true);
method: "POST", headers: { "Content-Type": "application/json" }, try {
body: JSON.stringify({ year, month }), const res = await fetch("/api/m/statistics/monthly", {
}); method: "POST", headers: { "Content-Type": "application/json" },
setRows((await res.json()).RESULTLIST ?? []); body: JSON.stringify({ year, month }),
});
setRows((await res.json()).RESULTLIST ?? []);
} finally { setLoading(false); }
}; };
useEffect(() => { load(); }, []); // eslint-disable-line 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 grandFree = rows.reduce((a, r) => a + Number(r.TAX_FREE), 0);
const grandTaxable = rows.reduce((a, r) => a + Number(r.TAXABLE), 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<h1 className="text-2xl font-bold"> </h1> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex 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"> <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>)} {Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => <option key={y} value={y}>{y}</option>)}
</select> </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> <button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold"></button>
</div> </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(grandFree)} color="violet" />
<Card label="과세 공급가" value={fmt(grandTaxable)} color="rose" /> <Card label="과세 공급가" value={fmt(grandTaxable)} color="rose" />
<Card label="총 매출 (VAT포함)" value={fmt(grandTotal)} color="emerald" /> <Card label="총 매출 (VAT포함)" value={fmt(grandTotal)} color="emerald" />
</div> </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"> <thead className="bg-slate-50 text-slate-600">
<tr> <tr>
<th className="text-left px-4 py-3"></th> <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", emerald: "from-emerald-50 to-emerald-100 text-emerald-800 border-emerald-200",
}[color]; }[color];
return ( 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-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> </div>
); );
} }
+387 -120
View File
@@ -1,8 +1,8 @@
"use client"; "use client";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo, useCallback } from "react";
import { useRouter } from "next/navigation"; 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"; import Swal from "sweetalert2";
interface Item { interface Item {
@@ -16,10 +16,15 @@ interface Item {
IS_TAX_FREE: string; IS_TAX_FREE: string;
IMAGE_URL: string; IMAGE_URL: string;
STOCK_QTY: number; STOCK_QTY: number;
MAX_ORDER_QTY: number | null;
IS_HIDDEN: string;
REQUIRES_DELIVERY: string;
} }
interface CartLine { item: Item; qty: number } 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 fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
const newKey = () => Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
// 모바일 발주 페이지의 뷰 모드 (card/list) 사용자 선택 기억 // 모바일 발주 페이지의 뷰 모드 (card/list) 사용자 선택 기억
const VIEW_MODE_KEY = "momo_orders_new_view_mode"; const VIEW_MODE_KEY = "momo_orders_new_view_mode";
@@ -33,6 +38,7 @@ export default function ItemsBrowse() {
const [taxFilter, setTaxFilter] = useState<"" | "Y" | "N">(""); const [taxFilter, setTaxFilter] = useState<"" | "Y" | "N">("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [cart, setCart] = useState<CartLine[]>([]); const [cart, setCart] = useState<CartLine[]>([]);
const [extras, setExtras] = useState<ExtraLine[]>([]);
const [cartOpen, setCartOpen] = useState(false); const [cartOpen, setCartOpen] = useState(false);
// 모바일 뷰 모드 — card(기본, 2열 그리드) / list(가로 한 줄) // 모바일 뷰 모드 — card(기본, 2열 그리드) / list(가로 한 줄)
const [viewMode, setViewMode] = useState<"card" | "list">("card"); 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); if (typeof window !== "undefined") window.localStorage.setItem(VIEW_MODE_KEY, m);
}; };
const fetchItems = async () => { const fetchItems = useCallback(async () => {
setLoading(true); setLoading(true);
const res = await fetch("/api/m/items/list", { const res = await fetch("/api/m/items/list", {
method: "POST", method: "POST",
@@ -58,19 +64,40 @@ export default function ItemsBrowse() {
const j = await res.json(); const j = await res.json();
setItems(j.RESULTLIST ?? []); setItems(j.RESULTLIST ?? []);
setLoading(false); 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(() => { useEffect(() => {
fetchItems(); if (cartNeedsDelivery && !hasDeliveryLine) {
// eslint-disable-next-line react-hooks/exhaustive-deps setExtras((prev) => [
}, []); { id: newKey(), kind: "DELIVERY", amount: 0, label: "택배비" },
...prev,
]);
}
}, [cartNeedsDelivery, hasDeliveryLine]);
const addToCart = (item: Item) => { const addToCart = (item: Item) => {
setCart((c) => { setCart((c) => {
const found = c.find((x) => x.item.OBJID === item.OBJID); 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) {
if (found.qty + 1 > Number(item.STOCK_QTY)) { if (found.qty + 1 > limit) {
Swal.fire({ icon: "warning", title: "재고 부족", text: `현재고는 ${fmt(item.STOCK_QTY)}개입니다.` }); Swal.fire({
icon: "warning",
title: maxQ > 0 && stock > maxQ ? "1회 발주 한도 초과" : "재고 부족",
text: `현재 ${maxQ > 0 && stock > maxQ ? `1회 한도는 ${fmt(maxQ)}` : `재고는 ${fmt(stock)}`}입니다.`,
});
return c; return c;
} }
return c.map((x) => x.item.OBJID === item.OBJID ? { ...x, qty: x.qty + 1 } : x); 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({ Swal.fire({
toast: true, position: "top-end", icon: "success", toast: true, position: "top-end", icon: "success",
title: `장바구니에 추가됨: ${item.ITEM_NAME}`, 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; if (x.item.OBJID !== objid) return x;
const newQty = x.qty + delta; const newQty = x.qty + delta;
if (newQty <= 0) return x; 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 }; 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 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(() => { 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) { for (const ln of cart) {
const lineTotal = Math.round(Number(ln.item.UNIT_PRICE) * ln.qty); const lineTotal = Math.round(Number(ln.item.UNIT_PRICE) * ln.qty);
total += lineTotal; total += lineTotal;
@@ -114,18 +172,36 @@ export default function ItemsBrowse() {
taxable += s; taxable += s;
} }
} }
return { supply, vat, total, taxFree, taxable, count }; for (const ex of extras) {
}, [cart]); 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 () => { const submitOrder = async () => {
if (cart.length === 0) { if (cart.length === 0) {
Swal.fire({ icon: "warning", title: "발주 품목을 추가하세요." }); Swal.fire({ icon: "warning", title: "발주 품목을 추가하세요." });
return; 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({ const ok = await Swal.fire({
icon: "question", icon: "question",
title: "발주를 요청하시겠습니까?", title: "발주를 요청하시겠습니까?",
text: `합계 ₩${fmt(totals.total)} (${cart.length}개 품목)`, text: `합계 ₩${fmt(totals.total)} (품목 ${cart.length}, 부가 ${extras.length})`,
showCancelButton: true, confirmButtonText: "발주", cancelButtonText: "취소", showCancelButton: true, confirmButtonText: "발주", cancelButtonText: "취소",
confirmButtonColor: "#0f766e", confirmButtonColor: "#0f766e",
}); });
@@ -135,12 +211,13 @@ export default function ItemsBrowse() {
method: "POST", headers: { "Content-Type": "application/json" }, method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
lines: cart.map((c) => ({ itemObjid: c.item.OBJID, qty: c.qty })), 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(); const j = await res.json();
if (j.success) { if (j.success) {
await Swal.fire({ icon: "success", title: "발주 요청 완료", text: `발주번호: ${j.orderNo}` }); await Swal.fire({ icon: "success", title: "발주 요청 완료", text: `발주번호: ${j.orderNo}` });
setCart([]); setCart([]); setExtras([]);
router.push("/m/orders"); router.push("/m/orders");
} else { } else {
Swal.fire({ icon: "error", title: "오류", text: j.message }); Swal.fire({ icon: "error", title: "오류", text: j.message });
@@ -175,7 +252,7 @@ export default function ItemsBrowse() {
<ShoppingCart size={18} className="text-emerald-700" /> <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"> <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> </span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -194,53 +271,126 @@ export default function ItemsBrowse() {
</div> </div>
{cartOpen && ( {cartOpen && (
<div className="border-t border-emerald-100 px-4 py-3 max-h-[40vh] overflow-y-auto bg-slate-50/50"> <div className="border-t border-emerald-100 px-4 py-3 max-h-[55vh] overflow-y-auto bg-slate-50/50 space-y-3">
{cart.length === 0 ? ( {/* 택배/용차 추가 버튼 */}
<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"> <div className="text-slate-400 text-sm text-center py-6">
<span className="font-bold text-emerald-700">+ </span> . <span className="font-bold text-emerald-700">+ </span> .
</div> </div>
) : ( ) : cart.length > 0 && (
<> <div className="grid sm:grid-cols-2 gap-2">
<div className="flex justify-end mb-2"> {cart.map((ln) => {
<button onClick={() => setCart([])} className="text-xs text-slate-400 hover:text-rose-500"> const lineTotal = Math.round(Number(ln.item.UNIT_PRICE) * ln.qty);
return (
</button> <div key={ln.item.OBJID} className="bg-white border border-slate-100 rounded-lg p-2.5">
</div> <div className="flex items-start justify-between gap-2 mb-2">
<div className="grid sm:grid-cols-2 gap-2"> <div className="text-sm font-semibold leading-tight min-w-0">
{cart.map((ln) => { <div className="truncate">{ln.item.ITEM_NAME}</div>
const lineTotal = Math.round(Number(ln.item.UNIT_PRICE) * ln.qty); {ln.item.REQUIRES_DELIVERY === "Y" && (
return ( <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 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>
<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 shrink-0">
<button onClick={() => removeLine(ln.item.OBJID)} className="text-slate-300 hover:text-rose-500"> <X size={14} />
<X size={14} /> </button>
</div>
<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>
<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> </button>
</div> </div>
<div className="flex items-center justify-between"> <div className="text-sm font-bold tabular-nums">{fmt(lineTotal)}</div>
<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>
<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>
</div>
<div className="text-sm font-bold tabular-nums">{fmt(lineTotal)}</div>
</div>
</div> </div>
); </div>
})} );
</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>
<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)}`} />
</div>
</>
)} )}
<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>
)} )}
</div> </div>
@@ -323,9 +473,14 @@ export default function ItemsBrowse() {
</div> </div>
<div className="flex items-start justify-between gap-2 mb-1"> <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="font-bold text-sm text-slate-900 leading-tight">{it.ITEM_NAME}</div>
{it.IS_TAX_FREE === "Y" && ( <div className="flex flex-col gap-0.5 items-end shrink-0">
<span className="px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 text-[10px] font-bold"></span> {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>
<div className="text-xs text-slate-500 mb-2">{it.MAKER_NAME || "-"}</div> <div className="text-xs text-slate-500 mb-2">{it.MAKER_NAME || "-"}</div>
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
@@ -334,6 +489,9 @@ export default function ItemsBrowse() {
{fmt(it.STOCK_QTY)} {it.UNIT} {fmt(it.STOCK_QTY)} {it.UNIT}
</div> </div>
</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 <button
disabled={Number(it.STOCK_QTY) === 0} disabled={Number(it.STOCK_QTY) === 0}
onClick={() => addToCart(it)} onClick={() => addToCart(it)}
@@ -366,6 +524,9 @@ export default function ItemsBrowse() {
{it.IS_TAX_FREE === "Y" && ( {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> <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>
<div className="text-xs text-slate-500 truncate">{it.MAKER_NAME || "-"}</div> <div className="text-xs text-slate-500 truncate">{it.MAKER_NAME || "-"}</div>
</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"}`}> <div className={`text-xs font-semibold ${Number(it.STOCK_QTY) > 0 ? "text-emerald-700" : "text-rose-500"}`}>
{fmt(it.STOCK_QTY)} {it.UNIT} {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">1 {fmt(it.MAX_ORDER_QTY)}</div>
)}
</div> </div>
<button <button
disabled={Number(it.STOCK_QTY) === 0} disabled={Number(it.STOCK_QTY) === 0}
@@ -465,8 +629,18 @@ export default function ItemsBrowse() {
<div className="text-slate-300 text-xs"> </div> <div className="text-slate-300 text-xs"> </div>
)} )}
</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">
{it.ITEM_NAME} <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>
<div className="text-sm text-slate-500 mt-1 truncate">{it.MAKER_NAME || "-"}</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"> <div className="font-extrabold text-slate-900 tabular-nums text-lg mt-2">
@@ -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"}`}> <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 || ""}` : "재고 없음"} {Number(it.STOCK_QTY) > 0 ? `재고 ${fmt(it.STOCK_QTY)}${it.UNIT || ""}` : "재고 없음"}
</div> </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 <button
disabled={Number(it.STOCK_QTY) === 0} disabled={Number(it.STOCK_QTY) === 0}
onClick={() => addToCart(it)} onClick={() => addToCart(it)}
@@ -502,11 +679,19 @@ export default function ItemsBrowse() {
)} )}
</div> </div>
<div className="flex-1 min-w-0"> <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">
{it.ITEM_NAME} <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>
<div className="text-xs text-slate-500 truncate mt-0.5">{it.MAKER_NAME || "-"}</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"> <div className="font-extrabold text-slate-900 tabular-nums text-lg">
{fmt(it.UNIT_PRICE)} {fmt(it.UNIT_PRICE)}
</div> </div>
@@ -520,6 +705,9 @@ export default function ItemsBrowse() {
? `재고 ${fmt(it.STOCK_QTY)}${it.UNIT || ""}` ? `재고 ${fmt(it.STOCK_QTY)}${it.UNIT || ""}`
: "재고 없음"} : "재고 없음"}
</div> </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>
</div> </div>
<button <button
@@ -546,7 +734,7 @@ export default function ItemsBrowse() {
<ShoppingCart size={22} className="text-emerald-700" /> <ShoppingCart size={22} className="text-emerald-700" />
<span className="text-base"></span> <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"> <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> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -558,71 +746,146 @@ export default function ItemsBrowse() {
</button> </button>
{cartOpen && ( {cartOpen && (
<div className="border-t border-emerald-100 px-3 py-3 max-h-[50vh] overflow-y-auto bg-slate-50/60"> <div className="border-t border-emerald-100 px-3 py-3 max-h-[60vh] overflow-y-auto bg-slate-50/60 space-y-3">
{cart.length === 0 ? ( {/* 택배/용차 추가 버튼 + 전체 비우기 */}
<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-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"> <div className="text-slate-500 text-base text-center py-6">
<span className="font-bold text-emerald-700"></span> . <span className="font-bold text-emerald-700"></span> .
</div> </div>
) : ( ) : cart.length > 0 && (
<> <div className="space-y-2">
<div className="flex justify-end mb-2"> {cart.map((ln) => {
<button const lineTotal = Math.round(Number(ln.item.UNIT_PRICE) * ln.qty);
onClick={() => setCart([])} return (
className="text-sm text-slate-500 active:text-rose-500 px-2 py-1" <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 min-w-0">
</button> <div>{ln.item.ITEM_NAME}</div>
</div> {ln.item.REQUIRES_DELIVERY === "Y" && (
<div className="space-y-2"> <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>
{cart.map((ln) => { )}
const lineTotal = Math.round(Number(ln.item.UNIT_PRICE) * ln.qty); </div>
return ( <button
<div key={ln.item.OBJID} className="bg-white border border-slate-200 rounded-xl p-3"> onClick={() => removeLine(ln.item.OBJID)}
<div className="flex items-start justify-between gap-2 mb-3"> className="w-9 h-9 -m-1 flex items-center justify-center text-slate-400 active:text-rose-500 shrink-0"
<div className="text-base font-semibold leading-snug flex-1"> aria-label="삭제"
{ln.item.ITEM_NAME} >
</div> <X size={20} />
</button>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<button <button
onClick={() => removeLine(ln.item.OBJID)} onClick={() => updateQty(ln.item.OBJID, -1)}
className="w-9 h-9 -m-1 flex items-center justify-center text-slate-400 active:text-rose-500" className="w-11 h-11 rounded-lg bg-slate-100 active:bg-slate-200 flex items-center justify-center"
aria-label="삭제" aria-label="수량 감소"
> >
<X size={20} /> <Minus size={20} strokeWidth={2.5} />
</button>
<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"
aria-label="수량 증가"
>
<Plus size={20} strokeWidth={2.5} />
</button> </button>
</div> </div>
<div className="flex items-center justify-between"> <div className="text-base font-bold tabular-nums">{fmt(lineTotal)}</div>
<div className="flex items-center gap-2">
<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"
aria-label="수량 감소"
>
<Minus size={20} strokeWidth={2.5} />
</button>
<span className="w-12 text-center text-xl font-extrabold tabular-nums">
{ln.qty}
</span>
<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"
aria-label="수량 증가"
>
<Plus size={20} strokeWidth={2.5} />
</button>
</div>
<div className="text-base font-bold tabular-nums">{fmt(lineTotal)}</div>
</div>
</div> </div>
); </div>
})} );
</div> })}
</> </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 <button
onClick={submitOrder} onClick={submitOrder}
disabled={cart.length === 0} 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}) <ShoppingCart size={20} /> ({totals.count})
</button> </button>
@@ -636,8 +899,12 @@ export default function ItemsBrowse() {
); );
} }
function Row({ label, value, color }: { label: string; value: string; color?: "violet" | "rose" }) { 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" : "text-slate-700"; 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 ( return (
<div className="flex justify-between"> <div className="flex justify-between">
<span className={cls}>{label}</span> <span className={cls}>{label}</span>
+36 -10
View File
@@ -2,7 +2,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Download, FileText } from "lucide-react"; import { Download } from "lucide-react";
import { downloadXlsx } from "@/lib/xlsx-export";
interface Order { interface Order {
OBJID: string; OBJID: string;
@@ -44,19 +45,44 @@ export default function MyOrdersPage() {
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-2">
<div> <div>
<h1 className="text-2xl font-bold"> </h1> <h1 className="text-xl sm:text-2xl font-bold"> </h1>
<p className="text-sm text-slate-500 mt-1"> {orders.length}</p> <p className="text-xs sm:text-sm text-slate-500 mt-1"> {orders.length}</p>
</div>
<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>
<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">
</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"> <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> <option value=""> </option>
{Object.entries(STATUS_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</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> <button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold"></button>
</div> </div>
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden"> <div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm min-w-[700px]">
<thead className="bg-slate-50 text-slate-600"> <thead className="bg-slate-50 text-slate-600">
<tr> <tr>
<th className="text-left px-4 py-3 font-semibold"></th> <th className="text-left px-4 py-3 font-semibold"></th>
+1
View File
@@ -78,6 +78,7 @@ export async function POST(req: NextRequest) {
I.attributes AS "ATTRIBUTES", I.attributes AS "ATTRIBUTES",
I.max_order_qty AS "MAX_ORDER_QTY", I.max_order_qty AS "MAX_ORDER_QTY",
COALESCE(I.is_hidden, 'N') AS "IS_HIDDEN", COALESCE(I.is_hidden, 'N') AS "IS_HIDDEN",
COALESCE(I.requires_delivery, 'N') AS "REQUIRES_DELIVERY",
COALESCE(( COALESCE((
SELECT SUM(S.qty) FROM momo_stocks S SELECT SUM(S.qty) FROM momo_stocks S
JOIN momo_warehouses W ON S.wh_objid = W.objid JOIN momo_warehouses W ON S.wh_objid = W.objid
+8 -6
View File
@@ -24,9 +24,11 @@ export async function POST(req: NextRequest) {
status, status,
maxOrderQty, maxOrderQty,
isHidden, isHidden,
requiresDelivery,
} = body; } = body;
const maxQty = maxOrderQty == null || maxOrderQty === "" ? null : Number(maxOrderQty); const maxQty = maxOrderQty == null || maxOrderQty === "" ? null : Number(maxOrderQty);
const hidden = isHidden === "Y" ? "Y" : "N"; const hidden = isHidden === "Y" ? "Y" : "N";
const reqDelivery = requiresDelivery === "Y" ? "Y" : "N";
if (!itemName) { if (!itemName) {
return NextResponse.json({ success: false, message: "품목명은 필수입니다." }, { status: 400 }); return NextResponse.json({ success: false, message: "품목명은 필수입니다." }, { status: 400 });
@@ -45,15 +47,15 @@ export async function POST(req: NextRequest) {
`INSERT INTO momo_items ( `INSERT INTO momo_items (
objid, item_code, item_name, item_detail, maker_objid, objid, item_code, item_name, item_detail, maker_objid,
unit, unit_price, cost_price, is_tax_free, image_url, attributes, status, 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 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, [newId, itemCode, cleanName, itemDetail ?? null, makerObjid ?? null,
unit ?? "EA", Number(unitPrice ?? 0), Number(costPrice ?? 0), unit ?? "EA", Number(unitPrice ?? 0), Number(costPrice ?? 0),
taxFree, imageUrl ?? null, taxFree, imageUrl ?? null,
attributes ? JSON.stringify(attributes) : null, attributes ? JSON.stringify(attributes) : null,
status ?? "ACTIVE", status ?? "ACTIVE",
maxQty, hidden, maxQty, hidden, reqDelivery,
userId] userId]
); );
return NextResponse.json({ success: true, objId: newId, itemCode }); 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, item_name=$2, item_detail=$3, maker_objid=$4, unit=$5,
unit_price=$6, cost_price=$7, is_tax_free=$8, image_url=$9, unit_price=$6, cost_price=$7, is_tax_free=$8, image_url=$9,
attributes=$10::jsonb, status=$11, attributes=$10::jsonb, status=$11,
max_order_qty=$12, is_hidden=$13, max_order_qty=$12, is_hidden=$13, requires_delivery=$14,
update_date=NOW(), update_id=$14 update_date=NOW(), update_id=$15
WHERE objid=$1`, WHERE objid=$1`,
[objid, cleanName, itemDetail ?? null, makerObjid ?? null, unit ?? "EA", [objid, cleanName, itemDetail ?? null, makerObjid ?? null, unit ?? "EA",
Number(unitPrice ?? 0), Number(costPrice ?? 0), Number(unitPrice ?? 0), Number(costPrice ?? 0),
taxFree, imageUrl ?? null, taxFree, imageUrl ?? null,
attributes ? JSON.stringify(attributes) : null, attributes ? JSON.stringify(attributes) : null,
status ?? "ACTIVE", status ?? "ACTIVE",
maxQty, hidden, maxQty, hidden, reqDelivery,
userId] userId]
); );
return NextResponse.json({ success: true, objId: objid }); return NextResponse.json({ success: true, objId: objid });
+4
View File
@@ -21,6 +21,8 @@ export async function POST(req: NextRequest) {
O.total_supply AS "TOTAL_SUPPLY", O.total_vat AS "TOTAL_VAT", O.total_supply AS "TOTAL_SUPPLY", O.total_vat AS "TOTAL_VAT",
O.total_amount AS "TOTAL_AMOUNT", O.total_amount AS "TOTAL_AMOUNT",
O.total_taxfree AS "TOTAL_TAXFREE", O.total_taxable AS "TOTAL_TAXABLE", 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", O.invoice_no AS "INVOICE_NO",
TO_CHAR(O.invoice_date,'YYYY-MM-DD') AS "INVOICE_DATE", TO_CHAR(O.invoice_date,'YYYY-MM-DD') AS "INVOICE_DATE",
O.paid_amount AS "PAID_AMOUNT", O.paid_amount AS "PAID_AMOUNT",
@@ -47,6 +49,8 @@ export async function POST(req: NextRequest) {
OI.supply_amount AS "SUPPLY_AMOUNT", OI.supply_amount AS "SUPPLY_AMOUNT",
OI.vat_amount AS "VAT_AMOUNT", OI.vat_amount AS "VAT_AMOUNT",
OI.total_amount AS "TOTAL_AMOUNT", OI.total_amount AS "TOTAL_AMOUNT",
COALESCE(OI.kind, 'ITEM') AS "KIND",
OI.extra_label AS "EXTRA_LABEL",
I.unit AS "UNIT", I.unit AS "UNIT",
I.image_url AS "IMAGE_URL", I.image_url AS "IMAGE_URL",
COALESCE( COALESCE(
+77 -13
View File
@@ -1,30 +1,36 @@
// 출고요청서 작성 (대리점) — status=REQUESTED 로 저장 // 출고요청서 작성 (대리점) — status=REQUESTED 로 저장
// v0.4: 택배비/용차비 라인 + 택배 전용 품목 자동 검증 지원
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { pool, queryOne } from "@/lib/db"; import { pool, queryOne } from "@/lib/db";
import { createObjectId } from "@/lib/utils"; import { createObjectId } from "@/lib/utils";
import { requireMomoUser } from "@/lib/momo-guard"; import { requireMomoUser } from "@/lib/momo-guard";
import { calcLine, sumTotals } from "@/lib/momo-pricing"; import { calcLine, sumTotals } from "@/lib/momo-pricing";
interface InputLine { interface InputItemLine {
itemObjid: string; itemObjid: string;
qty: number; qty: number;
} }
interface InputExtraLine {
kind: "DELIVERY" | "CHARTER";
amount: number;
label?: string;
}
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const r = await requireMomoUser(); const r = await requireMomoUser();
if (r instanceof NextResponse) return r; 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; const customerObjid = r.user.objid || r.user.userId;
if (!customerObjid) { if (!customerObjid) {
return NextResponse.json({ success: false, message: "사용자 식별자를 확인할 수 없습니다." }, { status: 400 }); return NextResponse.json({ success: false, message: "사용자 식별자를 확인할 수 없습니다." }, { status: 400 });
} }
let lines: InputLine[]; let lines: InputItemLine[];
let extras: InputExtraLine[];
let memo: string | undefined; let memo: string | undefined;
try { 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; lines = body.lines;
extras = Array.isArray(body.extras) ? body.extras : [];
memo = body.memo; memo = body.memo;
} catch { } catch {
return NextResponse.json({ success: false, message: "요청 본문을 해석할 수 없습니다." }, { status: 400 }); 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 }); 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 { try {
// 발주자(회원) 권한 — unlimited_qty='Y' 면 max_order_qty 무시
const customerRow = await pool.query( const customerRow = await pool.query(
`SELECT COALESCE(unlimited_qty, 'N') AS unlimited_qty FROM user_info WHERE user_id = $1`, `SELECT COALESCE(unlimited_qty, 'N') AS unlimited_qty FROM user_info WHERE user_id = $1`,
[customerObjid] [customerObjid]
@@ -51,7 +64,9 @@ export async function POST(req: NextRequest) {
const items = await pool.query( const items = await pool.query(
`SELECT `SELECT
I.objid, I.item_name, I.unit_price, I.is_tax_free, 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(( COALESCE((
SELECT SUM(S.qty) FROM momo_stocks S SELECT SUM(S.qty) FROM momo_stocks S
JOIN momo_warehouses W ON S.wh_objid = W.objid 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 }); return NextResponse.json({ success: false, message: `존재하지 않는 품목입니다: ${missing.itemObjid}` }, { status: 400 });
} }
// 수량 검증: 재고 한도 + (unlimited_qty 가 아니면) max_order_qty 한도 // 수량/숨김/택배 검증
let needsDelivery = false;
for (const ln of lines) { for (const ln of lines) {
const it = itemMap.get(ln.itemObjid)!; const it = itemMap.get(ln.itemObjid)!;
const stock = Number(it.stock_qty ?? 0); const stock = Number(it.stock_qty ?? 0);
@@ -87,10 +103,22 @@ export async function POST(req: NextRequest) {
}, { status: 400 }); }, { 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 orderObjid = createObjectId();
const orderNo = await genOrderNo(); const orderNo = await genOrderNo();
// 품목 라인
const enriched = lines.map((ln, idx) => { const enriched = lines.map((ln, idx) => {
const it = itemMap.get(ln.itemObjid)!; const it = itemMap.get(ln.itemObjid)!;
const isFree = it.is_tax_free === "Y"; const isFree = it.is_tax_free === "Y";
@@ -105,7 +133,30 @@ export async function POST(req: NextRequest) {
...calc, ...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(); const client = await pool.connect();
try { try {
@@ -114,22 +165,35 @@ export async function POST(req: NextRequest) {
`INSERT INTO momo_orders ( `INSERT INTO momo_orders (
objid, order_no, customer_objid, order_date, status, objid, order_no, customer_objid, order_date, status,
total_supply, total_vat, total_amount, total_taxfree, total_taxable, total_supply, total_vat, total_amount, total_taxfree, total_taxable,
total_delivery, total_charter,
memo, regdate, regid 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, [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] customerObjid]
); );
for (const ln of enriched) { for (const ln of enriched) {
await client.query( await client.query(
`INSERT INTO momo_order_items ( `INSERT INTO momo_order_items (
objid, order_objid, item_objid, item_name_snap, unit_price, qty, objid, order_objid, item_objid, item_name_snap, unit_price, qty,
is_tax_free, supply_amount, vat_amount, total_amount, seq is_tax_free, supply_amount, vat_amount, total_amount, seq, kind
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'ITEM')`,
[createObjectId(), orderObjid, ln.itemObjid, ln.itemName, ln.unitPrice, ln.qty, [createObjectId(), orderObjid, ln.itemObjid, ln.itemName, ln.unitPrice, ln.qty,
ln.isTaxFree ? "Y" : "N", ln.supplyAmount, ln.vatAmount, ln.totalAmount, ln.seq] 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"); await client.query("COMMIT");
return NextResponse.json({ success: true, objId: orderObjid, orderNo }); return NextResponse.json({ success: true, objId: orderObjid, orderNo });
} catch (err) { } catch (err) {
+34
View File
@@ -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`);
}