feat(charter): 거래처별 기본 용차비 자동 셋팅 + 택배/용차 거래처 수정 차단
Deploy momo-erp / deploy (push) Successful in 1m56s
Deploy momo-erp / deploy (push) Successful in 1m56s
DB:
- user_info.default_charter_use (CHAR 'Y'/'N'), default_charter_price (INTEGER)
- /api/admin/users/detail: ALTER TABLE IF NOT EXISTS 로 자동 증설 + SELECT 노출
- /api/admin/users/save: 두 필드 UPDATE
- /api/auth/me: 로그인 사용자의 defaultCharterUse/Price 응답에 포함
UI:
- admin-panel/user-form: [기본 용차비 사용] 체크박스 + [금액] 입력 (사용 체크 시만 활성)
- /m/orders/new: 카트에 품목이 들어오는 순간 default_charter_use='Y' 거래처는 용차 라인 자동 추가
- /m/orders/new: 거래처는 카트 안 택배/용차 라인 수정/삭제 불가 (read-only 표시)
[+ 택배 추가] [+ 용차 추가] 버튼도 admin 만 노출
- /m/orders DetailModal: canEditExtra=false 로 거래처 택배/용차 수정/삭제 차단
(출고관리 /m/admin/orders 에서만 수정 가능)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,9 @@ function ItemsBrowse() {
|
||||
// 현재 사용자의 발주 한도 우회 권한 (관리자 또는 unlimited_qty='Y' 거래처)
|
||||
const [unlimitedQty, setUnlimitedQty] = useState(false);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
// 기본 용차비 자동 추가 설정
|
||||
const [defaultCharterUse, setDefaultCharterUse] = useState(false);
|
||||
const [defaultCharterPrice, setDefaultCharterPrice] = useState(0);
|
||||
|
||||
// 수기 발주 모드일 때 거래처 이름 표시용 조회
|
||||
useEffect(() => {
|
||||
@@ -75,6 +78,8 @@ function ItemsBrowse() {
|
||||
const adm = d.user.role === "ADMIN" || d.user.isAdmin === true || d.user.userType === "A";
|
||||
setIsAdmin(adm);
|
||||
setUnlimitedQty(adm || !!d.user.unlimitedQty);
|
||||
setDefaultCharterUse(!!d.user.defaultCharterUse);
|
||||
setDefaultCharterPrice(Number(d.user.defaultCharterPrice) || 0);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
@@ -118,6 +123,18 @@ function ItemsBrowse() {
|
||||
}
|
||||
}, [cartNeedsDelivery, hasDeliveryLine]);
|
||||
|
||||
// 기본 용차비 사용 거래처 — 카트에 품목이 처음 들어왔는데 용차 라인이 없으면 자동 추가
|
||||
const cartHasItems = cart.length > 0;
|
||||
const hasCharterLine = extras.some((e) => e.kind === "CHARTER");
|
||||
useEffect(() => {
|
||||
if (defaultCharterUse && cartHasItems && !hasCharterLine) {
|
||||
setExtras((prev) => [
|
||||
...prev,
|
||||
{ id: newKey(), kind: "CHARTER", unitPrice: defaultCharterPrice, qty: 1, label: "용차비" },
|
||||
]);
|
||||
}
|
||||
}, [defaultCharterUse, defaultCharterPrice, cartHasItems, hasCharterLine]);
|
||||
|
||||
const addToCart = (item: Item) => addManyToCart(item, 1);
|
||||
|
||||
const addManyToCart = (item: Item, qty: number) => {
|
||||
@@ -340,23 +357,27 @@ function ItemsBrowse() {
|
||||
|
||||
{cartOpen && (
|
||||
<div className="border-t border-emerald-100 px-3 sm:px-4 py-3 max-h-[55vh] overflow-y-auto bg-slate-50/50 space-y-3">
|
||||
{/* 택배/용차 추가 버튼 */}
|
||||
{/* 택배/용차 추가 버튼 — admin 만 노출 (거래처는 자동 추가만 받음) */}
|
||||
<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>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<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
|
||||
@@ -368,7 +389,7 @@ function ItemsBrowse() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 택배/용차 라인 */}
|
||||
{/* 택배/용차 라인 — admin 은 수정 가능, 거래처는 read-only 표시 */}
|
||||
{extras.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{extras.map((ex) => {
|
||||
@@ -381,42 +402,56 @@ function ItemsBrowse() {
|
||||
<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-[120px] h-8 px-2 rounded border border-slate-200 text-xs bg-white"
|
||||
/>
|
||||
{isAdmin ? (
|
||||
<input
|
||||
value={ex.label}
|
||||
onChange={(e) => updateExtra(ex.id, "label", e.target.value)}
|
||||
placeholder="담당자/메모"
|
||||
className="flex-1 min-w-[120px] h-8 px-2 rounded border border-slate-200 text-xs bg-white"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex-1 min-w-[120px] text-xs text-slate-700 font-semibold">{ex.label}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={ex.unitPrice || ""}
|
||||
onChange={(e) => updateExtra(ex.id, "unitPrice", Number(e.target.value))}
|
||||
placeholder="단가"
|
||||
className="w-20 sm:w-24 h-8 px-2 rounded border border-slate-200 text-xs text-right tabular-nums bg-white"
|
||||
/>
|
||||
<span className="text-slate-400 text-xs">×</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={ex.qty || ""}
|
||||
onChange={(e) => updateExtra(ex.id, "qty", Number(e.target.value))}
|
||||
placeholder="수량"
|
||||
className="w-14 sm:w-16 h-8 px-2 rounded border border-slate-200 text-xs text-right tabular-nums bg-white"
|
||||
/>
|
||||
<span className="text-slate-400 text-xs">=</span>
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={ex.unitPrice || ""}
|
||||
onChange={(e) => updateExtra(ex.id, "unitPrice", Number(e.target.value))}
|
||||
placeholder="단가"
|
||||
className="w-20 sm:w-24 h-8 px-2 rounded border border-slate-200 text-xs text-right tabular-nums bg-white"
|
||||
/>
|
||||
<span className="text-slate-400 text-xs">×</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={ex.qty || ""}
|
||||
onChange={(e) => updateExtra(ex.id, "qty", Number(e.target.value))}
|
||||
placeholder="수량"
|
||||
className="w-14 sm:w-16 h-8 px-2 rounded border border-slate-200 text-xs text-right tabular-nums bg-white"
|
||||
/>
|
||||
<span className="text-slate-400 text-xs">=</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-slate-600 tabular-nums">
|
||||
₩{fmt(Number(ex.unitPrice))} × {fmt(Number(ex.qty))} =
|
||||
</span>
|
||||
)}
|
||||
<span className="w-20 sm:w-24 h-8 px-2 inline-flex items-center justify-end text-xs font-bold tabular-nums bg-white border border-slate-100 rounded">
|
||||
₩{fmt(lineTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeExtra(ex.id)}
|
||||
className="text-slate-300 hover:text-rose-500 shrink-0"
|
||||
title="삭제"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => removeExtra(ex.id)}
|
||||
className="text-slate-300 hover:text-rose-500 shrink-0"
|
||||
title="삭제"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -396,7 +396,8 @@ function DetailModal({ order, items, supplier, onClose, onCancel, onReload }: {
|
||||
const kindBadge = it.KIND === "DELIVERY" ? "택배" : it.KIND === "CHARTER" ? "용차" : null;
|
||||
const kindBg = it.KIND === "DELIVERY" ? "bg-orange-50" : it.KIND === "CHARTER" ? "bg-sky-50" : "";
|
||||
const canEditItem = editable && !isExtra;
|
||||
const canEditExtra = editable && isExtra;
|
||||
// 거래처는 택배/용차 라인 수정/삭제 불가 — 출고관리(/m/admin/orders)에서만 가능
|
||||
const canEditExtra = false;
|
||||
return (
|
||||
<tr key={it.OBJID || idx} className={kindBg}>
|
||||
<td className="border border-slate-300 px-1.5 py-1 text-center">{idx + 1}</td>
|
||||
|
||||
@@ -146,6 +146,33 @@ function UserForm() {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 grid grid-cols-2 gap-x-3 gap-y-1.5 pt-2 border-t">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-0.5">기본 용차비 사용</label>
|
||||
<label className="inline-flex items-center gap-1.5 px-2 h-8 border rounded text-[11px] cursor-pointer hover:bg-gray-50 bg-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.default_charter_use === "Y"}
|
||||
onChange={(e) => set("default_charter_use", e.target.checked ? "Y" : "N")}
|
||||
className="w-3.5 h-3.5"
|
||||
/>
|
||||
품목 담을 때 용차비 자동 추가
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-0.5">기본 용차비 금액 <span className="text-gray-400 font-normal">(원)</span></label>
|
||||
<Input
|
||||
className="h-8 text-right tabular-nums"
|
||||
type="number"
|
||||
min={0}
|
||||
step={100}
|
||||
value={form.default_charter_price ?? ""}
|
||||
onChange={(e) => set("default_charter_price", e.target.value)}
|
||||
disabled={form.default_charter_use !== "Y"}
|
||||
placeholder="예: 50000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,9 +2,26 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { pool } from "@/lib/db";
|
||||
import { getSession } from "@/lib/session";
|
||||
|
||||
// user_info 에 기본 용차비 컬럼이 없으면 1회 자동 증설
|
||||
let charterColsEnsured = false;
|
||||
async function ensureCharterCols() {
|
||||
if (charterColsEnsured) return;
|
||||
try {
|
||||
await pool.query(`
|
||||
ALTER TABLE user_info
|
||||
ADD COLUMN IF NOT EXISTS default_charter_use CHAR(1) DEFAULT 'N',
|
||||
ADD COLUMN IF NOT EXISTS default_charter_price INTEGER DEFAULT 0;
|
||||
`);
|
||||
charterColsEnsured = true;
|
||||
} catch (err) {
|
||||
console.error("[users/ensureCharterCols]", err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const user = await getSession();
|
||||
if (!user) return NextResponse.json({ success: false }, { status: 401 });
|
||||
await ensureCharterCols();
|
||||
const body = await request.json();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
@@ -18,6 +35,8 @@ export async function POST(request: NextRequest) {
|
||||
COALESCE(view_hidden, 'N') AS "view_hidden",
|
||||
default_wh_objid::text AS "default_wh_objid",
|
||||
COALESCE(statement_branch, 'HQ') AS "statement_branch",
|
||||
COALESCE(default_charter_use, 'N') AS "default_charter_use",
|
||||
COALESCE(default_charter_price, 0) AS "default_charter_price",
|
||||
TO_CHAR(regdate, 'YYYY-MM-DD') AS "regdate"
|
||||
FROM user_info WHERE user_id = $1`,
|
||||
[body.userId || ""]
|
||||
@@ -28,3 +47,4 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ success: false, message: "조회 중 오류가 발생했습니다." });
|
||||
} finally { client.release(); }
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,14 @@ export async function POST(request: NextRequest) {
|
||||
// 빈 문자열 / undefined → NULL 처리
|
||||
const defaultWh = body.default_wh_objid && String(body.default_wh_objid).trim() !== "" ? body.default_wh_objid : null;
|
||||
const stmtBranch = body.statement_branch === "KIMPO" ? "KIMPO" : "HQ";
|
||||
const charterUse = body.default_charter_use === "Y" ? "Y" : "N";
|
||||
const charterPrice = Number.isFinite(Number(body.default_charter_price)) ? Math.max(0, Math.floor(Number(body.default_charter_price))) : 0;
|
||||
// 컬럼이 없는 환경 대응 — 1회 IF NOT EXISTS ALTER
|
||||
await client.query(`
|
||||
ALTER TABLE user_info
|
||||
ADD COLUMN IF NOT EXISTS default_charter_use CHAR(1) DEFAULT 'N',
|
||||
ADD COLUMN IF NOT EXISTS default_charter_price INTEGER DEFAULT 0;
|
||||
`);
|
||||
await client.query(
|
||||
`UPDATE user_info SET
|
||||
user_name=$1, sabun=$2, dept_code=$3, dept_name=$4,
|
||||
@@ -41,14 +49,17 @@ export async function POST(request: NextRequest) {
|
||||
biz_no=COALESCE($13, biz_no),
|
||||
unlimited_qty=$14, view_hidden=$15,
|
||||
default_wh_objid=$16,
|
||||
statement_branch=$17
|
||||
statement_branch=$17,
|
||||
default_charter_use=$18,
|
||||
default_charter_price=$19
|
||||
WHERE user_id=$10`,
|
||||
[body.user_name || "", body.sabun || "", body.dept_code || "",
|
||||
body.dept_name || "", body.position_name || "", body.email || "",
|
||||
body.cell_phone || "", body.user_type || "", body.tel || "",
|
||||
body.user_id || "",
|
||||
body.address ?? null, body.ceo_name ?? null, body.biz_no ?? null,
|
||||
unlimited, viewHidden, defaultWh, stmtBranch]
|
||||
unlimited, viewHidden, defaultWh, stmtBranch,
|
||||
charterUse, charterPrice]
|
||||
);
|
||||
// 비밀번호가 입력된 경우만 변경 (빈 문자열이면 기존 유지)
|
||||
if (typeof body.password === "string" && body.password.length > 0) {
|
||||
|
||||
@@ -7,15 +7,34 @@ export async function GET() {
|
||||
if (!user) {
|
||||
return NextResponse.json({ success: false }, { status: 401 });
|
||||
}
|
||||
// 특수 권한 (발주 한도 무시 / 숨김 품목 보기) 도 같이 내려줌
|
||||
let perms = { unlimitedQty: false, viewHidden: false };
|
||||
// 특수 권한 (발주한도 무시 / 숨김품목 보기) + 기본 용차비 설정 같이 내려줌.
|
||||
// default_charter_* 컬럼이 운영DB 에 아직 없을 수 있어 try-catch 로 안전 처리.
|
||||
let perms = { unlimitedQty: false, viewHidden: false, defaultCharterUse: false, defaultCharterPrice: 0 };
|
||||
try {
|
||||
const row = await queryOne<{ U: string; V: string }>(
|
||||
`SELECT COALESCE(unlimited_qty, 'N') AS "U", COALESCE(view_hidden, 'N') AS "V"
|
||||
const row = await queryOne<{ U: string; V: string; CU: string; CP: string }>(
|
||||
`SELECT COALESCE(unlimited_qty, 'N') AS "U",
|
||||
COALESCE(view_hidden, 'N') AS "V",
|
||||
COALESCE(default_charter_use, 'N') AS "CU",
|
||||
COALESCE(default_charter_price, 0)::text AS "CP"
|
||||
FROM user_info WHERE user_id = $1`,
|
||||
[user.userId]
|
||||
);
|
||||
if (row) perms = { unlimitedQty: row.U === "Y", viewHidden: row.V === "Y" };
|
||||
} catch { /* ignore */ }
|
||||
if (row) perms = {
|
||||
unlimitedQty: row.U === "Y",
|
||||
viewHidden: row.V === "Y",
|
||||
defaultCharterUse: row.CU === "Y",
|
||||
defaultCharterPrice: Number(row.CP) || 0,
|
||||
};
|
||||
} catch {
|
||||
// 컬럼이 없는 경우 폴백 — 기존 권한만 다시 조회
|
||||
try {
|
||||
const row = await queryOne<{ U: string; V: string }>(
|
||||
`SELECT COALESCE(unlimited_qty, 'N') AS "U", COALESCE(view_hidden, 'N') AS "V"
|
||||
FROM user_info WHERE user_id = $1`,
|
||||
[user.userId]
|
||||
);
|
||||
if (row) perms = { ...perms, unlimitedQty: row.U === "Y", viewHidden: row.V === "Y" };
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return NextResponse.json({ success: true, user: { ...user, ...perms } });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user