feat: 매입 발주서 납품조건 + 공급업체 샘플 + 관리자/사용자 모드 토글 + 모달 sticky
Deploy momo-erp / deploy (push) Successful in 53s
Deploy momo-erp / deploy (push) Successful in 53s
[DB 019]
- momo_procurements 에 delivery_place / delivery_period / payment_terms / freight_terms 컬럼 추가
- 기존 supply_mng (공급업체) 데이터 모두 삭제 + 샘플 10개 신규 등록
· (주)아바텍, 대성식품, (주)고기파는농부, 광이진천 농장, 단과일,
봉담수산, 명일동유기농, 울산단과일, 농부의아침, 초록마을 도매
- 시퀀스 가정 없이 MAX(objid)+1 로 안전하게 부여
[발주서 양식 — 표준 거래명세표 양식 반영]
- ProcurementForm: "2. 납품조건" 섹션 추가
· 1)~3) 표준 조항 (납기 지연 공제 / 검수 부적합 반출 / 수량 규격 변경)
· 4) 납품장소 5) 납품기간 6) 대금지불 7) 운임부담 — 표 형식 입력칸
· 8)~9) 표준 조항 (3일 이의 제기 효력 / 명시되지 않은 사항)
· 하단 "상기와 같이 발주함." + 발주일 + 발주자
- update-header API: 4개 필드 동적 업데이트
- /api/m/procurements/excel/[id]: 엑셀 출력에도 납품조건 9개 항목 + 4필드 표
- /api/m/procurements/send: 메일 본문 HTML 에도 납품조건 표 + 표준 조항
[관리자/사용자 모드 토글]
- 헤더 매뉴얼 옆에 [👥 사용자 / 🛡 관리자] 토글 버튼 (admin 권한자만 노출)
- menu-store: viewMode("user"|"admin") + setViewMode 추가
- 사이드바: viewMode 에 따라 대메뉴 필터링
· 사용자 모드: '거래처 주문' 그룹만
· 관리자 모드: 출고/정산 + 매입/입고 + 마스터 관리 + 통계
- admin 권한자 자동으로 로그인 시 관리자 모드 진입
[ItemPicker 모달 모바일 친화]
- 모바일에서 화면 하단 도킹(items-end) → 풀스크린 시트 처럼
- 헤더는 sticky top-0 으로 고정 → 긴 목록에서도 검색바 항상 보임
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
-- 019_proc_terms.sql
|
||||
-- v0.8 (2026-05-08)
|
||||
-- 1) 매입 발주서 납품조건 4필드 추가
|
||||
-- 2) 기존 공급업체 데이터 삭제 + 샘플 10개 신규 등록
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
-- 1. momo_procurements 납품조건 컬럼
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
ALTER TABLE momo_procurements
|
||||
ADD COLUMN IF NOT EXISTS delivery_place TEXT,
|
||||
ADD COLUMN IF NOT EXISTS delivery_period TEXT,
|
||||
ADD COLUMN IF NOT EXISTS payment_terms TEXT,
|
||||
ADD COLUMN IF NOT EXISTS freight_terms TEXT;
|
||||
|
||||
COMMENT ON COLUMN momo_procurements.delivery_place IS '납품장소';
|
||||
COMMENT ON COLUMN momo_procurements.delivery_period IS '납품기간';
|
||||
COMMENT ON COLUMN momo_procurements.payment_terms IS '대금지불 조건';
|
||||
COMMENT ON COLUMN momo_procurements.freight_terms IS '운임부담';
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
-- 2. 공급업체(supply_mng) 초기화 + 샘플 10개
|
||||
-- supply_mng.objid 는 numeric/bigint — 시퀀스가 있을 수도/없을 수도 있어
|
||||
-- DO 블록 안에서 MAX(objid)+1 로 안전하게 부여한다.
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
|
||||
-- 담당자 테이블 정리 (테이블이 있으면)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'supply_charger') THEN
|
||||
DELETE FROM supply_charger
|
||||
WHERE supply_objid::text IN (SELECT objid::text FROM supply_mng);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 기존 공급업체 모두 삭제
|
||||
DELETE FROM supply_mng;
|
||||
|
||||
-- 샘플 10개 — MAX(objid)+1 ~ +10 으로 부여
|
||||
DO $$
|
||||
DECLARE
|
||||
base_id BIGINT;
|
||||
samples TEXT[][] := ARRAY[
|
||||
ARRAY['VND-001', '(주)아바텍', '김영수', '02-1234-5678', '101-81-12345', 'avatec@example.com', '서울시 강남구 테헤란로 123'],
|
||||
ARRAY['VND-002', '대성식품', '이상민', '031-987-6543','129-86-54321', 'daesung@example.com', '경기도 의왕시 벌모루길 46'],
|
||||
ARRAY['VND-003', '(주)고기파는농부', '박정훈', '02-555-1212', '215-87-66721', 'meatfarmer@example.com', '서울시 송파구 문정동 88-2'],
|
||||
ARRAY['VND-004', '광이진천 농장', '최수진', '043-532-1010','317-91-12340', 'gwang2@example.com', '충북 진천군 진천읍 광혜원로 12'],
|
||||
ARRAY['VND-005', '단과일', '강동현', '063-211-3344','404-86-77890', 'danfruit@example.com', '전북 전주시 완산구 단풍로 5'],
|
||||
ARRAY['VND-006', '봉담수산', '윤소라', '031-220-7788','129-86-22301', 'bongdam@example.com', '경기도 화성시 봉담읍 와우안길 33'],
|
||||
ARRAY['VND-007', '명일동유기농', '이지호', '02-441-2233', '220-81-33445', 'myungil@example.com', '서울시 강동구 명일로 100'],
|
||||
ARRAY['VND-008', '울산단과일', '오민재', '052-733-9988','610-81-44567', 'ulsanfruit@example.com', '울산시 남구 삼산로 150'],
|
||||
ARRAY['VND-009', '농부의아침', '한세영', '031-333-4444','215-87-55667', 'morning@example.com', '경기도 양주시 백석읍 호명로 22'],
|
||||
ARRAY['VND-010', '초록마을 도매', '정혜민', '02-1577-7234','110-86-99887', 'choroc@example.com', '서울시 마포구 양화로 45']
|
||||
];
|
||||
i INT;
|
||||
BEGIN
|
||||
SELECT COALESCE(MAX(objid::bigint), 0) INTO base_id FROM supply_mng;
|
||||
FOR i IN 1..10 LOOP
|
||||
INSERT INTO supply_mng
|
||||
(objid, supply_code, supply_name, charge_user_name, supply_tel_no, reg_no, email, supply_address, status, reg_id, reg_date)
|
||||
VALUES
|
||||
(base_id + i, samples[i][1], samples[i][2], samples[i][3], samples[i][4], samples[i][5], samples[i][6], samples[i][7], 'active', 'admin', NOW());
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -13,6 +13,10 @@ interface ProcDetail {
|
||||
OBJID: string; PROC_NO: string; PROC_DATE: string; STATUS: string;
|
||||
TOTAL_AMOUNT: number; MEMO?: string;
|
||||
VENDOR_OBJID: string | null; VENDOR_NAME: string | null;
|
||||
DELIVERY_PLACE?: string;
|
||||
DELIVERY_PERIOD?: string;
|
||||
PAYMENT_TERMS?: string;
|
||||
FREIGHT_TERMS?: string;
|
||||
}
|
||||
interface ProcLine {
|
||||
OBJID: string; ITEM_OBJID: string; ITEM_CODE: string; ITEM_NAME: string;
|
||||
@@ -234,6 +238,7 @@ export default function ProcurementsPage() {
|
||||
vendors={vendors}
|
||||
onSetVendor={(v) => updateHeader({ vendorObjid: v || null })}
|
||||
onSetMemo={(m) => updateHeader({ memo: m })}
|
||||
onSetTerm={(field, val) => updateHeader({ [field]: val })}
|
||||
onAddPicker={() => setPickerOpen(true)}
|
||||
onUpdateLine={updateLine}
|
||||
onDeleteLine={deleteLine}
|
||||
@@ -271,11 +276,12 @@ export default function ProcurementsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onAddPicker, onUpdateLine, onDeleteLine }: {
|
||||
function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, onAddPicker, onUpdateLine, onDeleteLine }: {
|
||||
detail: { proc: ProcDetail; items: ProcLine[] };
|
||||
vendors: Vendor[];
|
||||
onSetVendor: (id: string) => void;
|
||||
onSetMemo: (m: string) => void;
|
||||
onSetTerm: (field: "deliveryPlace" | "deliveryPeriod" | "paymentTerms" | "freightTerms", val: string) => void;
|
||||
onAddPicker: () => void;
|
||||
onUpdateLine: (line: { objid?: string; itemObjid?: string; qty: number; costPrice: number }) => void;
|
||||
onDeleteLine: (objid: string) => void;
|
||||
@@ -407,20 +413,87 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onAddPicker,
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p className="mt-4 font-semibold text-[12px]">2. 비고</p>
|
||||
<p className="mt-4 font-semibold text-[12px]">2. 납품조건</p>
|
||||
<ol className="text-[11px] mt-1 space-y-1 leading-relaxed list-decimal pl-5">
|
||||
<li>상기 품목의 납기 지연 시, 지연일수 매 1일에 대하여 미납 금액의 3/1000을 납품대금 지불 시 우선 공제한다.</li>
|
||||
<li>납품된 물품은 당사의 지정인에게 검수를 받아야 하며, 부적합품은 즉시 납품자의 비용으로 반출하여야 한다.</li>
|
||||
<li>상기 수량 및 규격은 당사의 사정에 의하여 변경될 수 있으며, 납품자는 이에 대하여 이의를 제기할 수 없다.</li>
|
||||
</ol>
|
||||
|
||||
<table className="text-[11px] mt-2 border border-slate-400 w-full" style={{borderCollapse:'collapse'}}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th className="border border-slate-400 bg-slate-100 px-2 py-1 text-center font-semibold w-[80px]">4) 납품장소</th>
|
||||
<td className="border border-slate-400 px-2 py-0.5">
|
||||
{editable ? (
|
||||
<input defaultValue={detail.proc.DELIVERY_PLACE ?? ""}
|
||||
onBlur={(e) => onSetTerm("deliveryPlace", e.target.value)}
|
||||
className="w-full h-7 px-2 text-[11px] outline-none border-0 bg-transparent" />
|
||||
) : (
|
||||
<span>{detail.proc.DELIVERY_PLACE || "-"}</span>
|
||||
)}
|
||||
</td>
|
||||
<th className="border border-slate-400 bg-slate-100 px-2 py-1 text-center font-semibold w-[80px]">6) 대금지불</th>
|
||||
<td className="border border-slate-400 px-2 py-0.5">
|
||||
{editable ? (
|
||||
<input defaultValue={detail.proc.PAYMENT_TERMS ?? ""}
|
||||
onBlur={(e) => onSetTerm("paymentTerms", e.target.value)}
|
||||
className="w-full h-7 px-2 text-[11px] outline-none border-0 bg-transparent" />
|
||||
) : (
|
||||
<span>{detail.proc.PAYMENT_TERMS || "-"}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border border-slate-400 bg-slate-100 px-2 py-1 text-center font-semibold">5) 납품기간</th>
|
||||
<td className="border border-slate-400 px-2 py-0.5">
|
||||
{editable ? (
|
||||
<input defaultValue={detail.proc.DELIVERY_PERIOD ?? ""}
|
||||
onBlur={(e) => onSetTerm("deliveryPeriod", e.target.value)}
|
||||
className="w-full h-7 px-2 text-[11px] outline-none border-0 bg-transparent" />
|
||||
) : (
|
||||
<span>{detail.proc.DELIVERY_PERIOD || "-"}</span>
|
||||
)}
|
||||
</td>
|
||||
<th className="border border-slate-400 bg-slate-100 px-2 py-1 text-center font-semibold">7) 운임부담</th>
|
||||
<td className="border border-slate-400 px-2 py-0.5">
|
||||
{editable ? (
|
||||
<input defaultValue={detail.proc.FREIGHT_TERMS ?? ""}
|
||||
onBlur={(e) => onSetTerm("freightTerms", e.target.value)}
|
||||
className="w-full h-7 px-2 text-[11px] outline-none border-0 bg-transparent" />
|
||||
) : (
|
||||
<span>{detail.proc.FREIGHT_TERMS || "-"}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<ol className="text-[11px] mt-2 space-y-1 leading-relaxed list-decimal pl-5" start={8}>
|
||||
<li>본 발주서는 납품자가 FAX 또는 기타 방법으로 접수 후 3일 이내에 이의를 제기치 않으면 제반 법적 효력을 발휘한다.</li>
|
||||
<li>상기 조항에 명시되지 아니한 사항은 상호 협의하고, 협의가 불가능할 경우에는 당사의 해석에 따른다.</li>
|
||||
</ol>
|
||||
|
||||
<p className="mt-4 font-semibold text-[12px]">3. 비고</p>
|
||||
{editable ? (
|
||||
<textarea
|
||||
defaultValue={detail.proc.MEMO ?? ""}
|
||||
onBlur={(e) => onSetMemo(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="납품장소, 납품기간, 대금지불 조건 등을 적어주세요"
|
||||
rows={2}
|
||||
placeholder="추가로 전달할 사항이 있으면 입력"
|
||||
className="w-full mt-1 px-3 py-2 rounded border border-slate-300 text-[11px] resize-y"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1 px-3 py-2 rounded border border-slate-200 bg-slate-50 text-[11px] whitespace-pre-wrap min-h-[60px]">
|
||||
<div className="mt-1 px-3 py-2 rounded border border-slate-200 bg-slate-50 text-[11px] whitespace-pre-wrap min-h-[40px]">
|
||||
{detail.proc.MEMO || <span className="text-slate-400">없음</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-center text-[11px] text-slate-600">
|
||||
<p>상기와 같이 발주함.</p>
|
||||
<p className="mt-2">{detail.proc.PROC_DATE.replace(/-/g, ".") + "."}</p>
|
||||
<p className="mt-3 font-semibold">발주자 : {process.env.NEXT_PUBLIC_MOMO_NAME ?? "(주)모모유통"}</p>
|
||||
</div>
|
||||
</div>{/* /formRef capture area */}
|
||||
</div>
|
||||
);
|
||||
@@ -517,9 +590,9 @@ function ItemPicker({ vendorObjid, existingItemIds, onClose, onAddItems }: {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-3" onClick={onClose}>
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-3xl w-full max-h-[90vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="px-4 py-3 border-b flex items-center justify-between">
|
||||
<div className="fixed inset-0 bg-slate-900/60 z-50 flex items-end sm:items-center justify-center p-0 sm:p-3" onClick={onClose}>
|
||||
<div className="bg-white rounded-t-xl sm:rounded-xl shadow-xl max-w-3xl w-full max-h-[90vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="px-4 py-3 border-b flex items-center justify-between sticky top-0 bg-white z-10">
|
||||
<h3 className="font-bold">품목 추가 ({filtered.length}개)</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-700"><X size={18} /></button>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,10 @@ export async function POST(req: NextRequest) {
|
||||
`SELECT P.objid AS "OBJID", P.proc_no AS "PROC_NO",
|
||||
TO_CHAR(P.proc_date,'YYYY-MM-DD') AS "PROC_DATE",
|
||||
P.status AS "STATUS", P.total_amount AS "TOTAL_AMOUNT", P.memo AS "MEMO",
|
||||
P.delivery_place AS "DELIVERY_PLACE",
|
||||
P.delivery_period AS "DELIVERY_PERIOD",
|
||||
P.payment_terms AS "PAYMENT_TERMS",
|
||||
P.freight_terms AS "FREIGHT_TERMS",
|
||||
V.objid AS "VENDOR_OBJID", V.supply_name AS "VENDOR_NAME"
|
||||
FROM momo_procurements P
|
||||
LEFT JOIN supply_mng V ON P.vendor_objid = V.objid::text
|
||||
|
||||
@@ -12,6 +12,7 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string
|
||||
const proc = await queryOne<Record<string, unknown>>(
|
||||
`SELECT P.proc_no, TO_CHAR(P.proc_date,'YYYY-MM-DD') AS proc_date,
|
||||
P.status, P.total_amount, P.memo,
|
||||
P.delivery_place, P.delivery_period, P.payment_terms, P.freight_terms,
|
||||
V.supply_name AS vendor_name,
|
||||
V.charge_user_name AS vendor_contact,
|
||||
V.supply_tel_no AS vendor_phone,
|
||||
@@ -60,7 +61,16 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string
|
||||
aoa.push(["", "", "", "", "", "총액", Number(proc.total_amount)]);
|
||||
aoa.push(["", "", "", "", "", "(V.A.T 별도)", ""]);
|
||||
aoa.push([]);
|
||||
aoa.push(["2. 비고"]);
|
||||
aoa.push(["2. 납품조건"]);
|
||||
aoa.push(["1) 상기 품목의 납기 지연 시, 지연일수 매 1일에 대하여 미납 금액의 3/1000을 납품대금 지불 시 우선 공제한다."]);
|
||||
aoa.push(["2) 납품된 물품은 당사의 지정인에게 검수를 받아야 하며, 부적합품은 즉시 납품자의 비용으로 반출하여야 한다."]);
|
||||
aoa.push(["3) 상기 수량 및 규격은 당사의 사정에 의하여 변경될 수 있으며, 납품자는 이에 대하여 이의를 제기할 수 없다."]);
|
||||
aoa.push(["4) 납품장소", String(proc.delivery_place ?? ""), "", "6) 대금지불", String(proc.payment_terms ?? "")]);
|
||||
aoa.push(["5) 납품기간", String(proc.delivery_period ?? ""), "", "7) 운임부담", String(proc.freight_terms ?? "")]);
|
||||
aoa.push(["8) 본 발주서는 납품자가 FAX 또는 기타 방법으로 접수 후 3일 이내에 이의를 제기치 않으면 제반 법적 효력을 발휘한다."]);
|
||||
aoa.push(["9) 상기 조항에 명시되지 아니한 사항은 상호 협의하고, 협의가 불가능할 경우에는 당사의 해석에 따른다."]);
|
||||
aoa.push([]);
|
||||
aoa.push(["3. 비고"]);
|
||||
aoa.push([String(proc.memo ?? "")]);
|
||||
aoa.push([]);
|
||||
aoa.push([]);
|
||||
|
||||
@@ -14,6 +14,7 @@ export async function POST(req: NextRequest) {
|
||||
const proc = await queryOne<Record<string, unknown>>(
|
||||
`SELECT P.objid, P.proc_no, TO_CHAR(P.proc_date,'YYYY-MM-DD') AS proc_date,
|
||||
P.status, P.total_amount, P.memo,
|
||||
P.delivery_place, P.delivery_period, P.payment_terms, P.freight_terms,
|
||||
V.objid AS vendor_objid, V.supply_name AS vendor_name,
|
||||
V.email AS vendor_email, V.charge_user_name AS vendor_contact,
|
||||
V.supply_tel_no AS vendor_phone
|
||||
@@ -92,7 +93,35 @@ export async function POST(req: NextRequest) {
|
||||
<tbody>${itemRows}</tbody>
|
||||
</table>
|
||||
<div style="text-align:right;margin-top:14px;font-size:14px"><b>총액 ₩${fmt(proc.total_amount)}</b> (V.A.T 별도)</div>
|
||||
${proc.memo ? `<div style="margin-top:14px;padding:10px;background:#f8fafc;border-left:4px solid #94a3b8;font-size:12px">${String(proc.memo).replace(/\n/g,"<br>")}</div>` : ""}
|
||||
|
||||
<div style="margin-top:18px;font-size:12px">
|
||||
<p style="font-weight:700;margin:6px 0">2. 납품조건</p>
|
||||
<ol style="padding-left:20px;margin:0">
|
||||
<li>상기 품목의 납기 지연 시, 지연일수 매 1일에 대하여 미납 금액의 3/1000을 납품대금 지불 시 우선 공제한다.</li>
|
||||
<li>납품된 물품은 당사의 지정인에게 검수를 받아야 하며, 부적합품은 즉시 납품자의 비용으로 반출하여야 한다.</li>
|
||||
<li>상기 수량 및 규격은 당사의 사정에 의하여 변경될 수 있으며, 납품자는 이에 대하여 이의를 제기할 수 없다.</li>
|
||||
</ol>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:12px;margin:8px 0">
|
||||
<tr>
|
||||
<th style="border:1px solid #cbd5e1;background:#f1f5f9;padding:5px;width:90px;text-align:center">4) 납품장소</th>
|
||||
<td style="border:1px solid #cbd5e1;padding:5px">${proc.delivery_place ?? "-"}</td>
|
||||
<th style="border:1px solid #cbd5e1;background:#f1f5f9;padding:5px;width:90px;text-align:center">6) 대금지불</th>
|
||||
<td style="border:1px solid #cbd5e1;padding:5px">${proc.payment_terms ?? "-"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style="border:1px solid #cbd5e1;background:#f1f5f9;padding:5px;text-align:center">5) 납품기간</th>
|
||||
<td style="border:1px solid #cbd5e1;padding:5px">${proc.delivery_period ?? "-"}</td>
|
||||
<th style="border:1px solid #cbd5e1;background:#f1f5f9;padding:5px;text-align:center">7) 운임부담</th>
|
||||
<td style="border:1px solid #cbd5e1;padding:5px">${proc.freight_terms ?? "-"}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<ol start="8" style="padding-left:20px;margin:0">
|
||||
<li>본 발주서는 납품자가 FAX 또는 기타 방법으로 접수 후 3일 이내에 이의를 제기치 않으면 제반 법적 효력을 발휘한다.</li>
|
||||
<li>상기 조항에 명시되지 아니한 사항은 상호 협의하고, 협의가 불가능할 경우에는 당사의 해석에 따른다.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
${proc.memo ? `<div style="margin-top:14px;padding:10px;background:#f8fafc;border-left:4px solid #94a3b8;font-size:12px"><b>비고:</b> ${String(proc.memo).replace(/\n/g,"<br>")}</div>` : ""}
|
||||
<p style="margin-top:24px;text-align:center">상기와 같이 발주합니다.</p>
|
||||
<p style="margin-top:8px;text-align:center;font-weight:bold">${process.env.MOMO_COMPANY_NAME ?? "모모유통"}</p>
|
||||
<p style="margin-top:0;text-align:center;font-size:12px;color:#64748b">전화: ${process.env.MOMO_PHONE ?? ""}</p>
|
||||
|
||||
@@ -8,7 +8,10 @@ export async function POST(req: NextRequest) {
|
||||
if (g instanceof NextResponse) return g;
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { objid, vendorObjid, memo } = body as { objid?: string; vendorObjid?: string | null; memo?: string };
|
||||
const { objid, vendorObjid, memo, deliveryPlace, deliveryPeriod, paymentTerms, freightTerms } = body as {
|
||||
objid?: string; vendorObjid?: string | null; memo?: string;
|
||||
deliveryPlace?: string; deliveryPeriod?: string; paymentTerms?: string; freightTerms?: string;
|
||||
};
|
||||
if (!objid) return NextResponse.json({ success: false, message: "objid 누락" }, { status: 400 });
|
||||
|
||||
const cur = await pool.query(`SELECT status FROM momo_procurements WHERE objid = $1`, [objid]);
|
||||
@@ -21,8 +24,12 @@ export async function POST(req: NextRequest) {
|
||||
const sets: string[] = [];
|
||||
const params: unknown[] = [objid];
|
||||
let i = 2;
|
||||
if (vendorObjid !== undefined) { sets.push(`vendor_objid = $${i++}`); params.push(vendorObjid); }
|
||||
if (memo !== undefined) { sets.push(`memo = $${i++}`); params.push(memo); }
|
||||
if (vendorObjid !== undefined) { sets.push(`vendor_objid = $${i++}`); params.push(vendorObjid); }
|
||||
if (memo !== undefined) { sets.push(`memo = $${i++}`); params.push(memo); }
|
||||
if (deliveryPlace !== undefined) { sets.push(`delivery_place = $${i++}`); params.push(deliveryPlace); }
|
||||
if (deliveryPeriod !== undefined) { sets.push(`delivery_period = $${i++}`); params.push(deliveryPeriod); }
|
||||
if (paymentTerms !== undefined) { sets.push(`payment_terms = $${i++}`); params.push(paymentTerms); }
|
||||
if (freightTerms !== undefined) { sets.push(`freight_terms = $${i++}`); params.push(freightTerms); }
|
||||
if (sets.length === 0) return NextResponse.json({ success: true });
|
||||
|
||||
await pool.query(`UPDATE momo_procurements SET ${sets.join(", ")} WHERE objid = $1`, params);
|
||||
|
||||
@@ -3,16 +3,23 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAuthStore } from "@/store/auth-store";
|
||||
import { useMenuStore } from "@/store/menu-store";
|
||||
import { LogOut, User, BookOpen, Menu as MenuIcon } from "lucide-react";
|
||||
import { LogOut, User, BookOpen, Menu as MenuIcon, Shield, Users } from "lucide-react";
|
||||
|
||||
export function Header() {
|
||||
const { user, logout } = useAuthStore();
|
||||
const { topMenus, activeTopMenu, fetchTopMenus, fetchSideMenus, setMobileOpen } = useMenuStore();
|
||||
const { topMenus, activeTopMenu, fetchTopMenus, fetchSideMenus, setMobileOpen, viewMode, setViewMode } = useMenuStore();
|
||||
const isAdminUser = !!user && (user.isAdmin || user.role === "ADMIN" || user.userType === "A");
|
||||
|
||||
useEffect(() => {
|
||||
fetchTopMenus();
|
||||
}, [fetchTopMenus]);
|
||||
|
||||
// 관리자가 로그인 직후 자동으로 관리자 모드 진입 (한 번만)
|
||||
useEffect(() => {
|
||||
if (isAdminUser && viewMode === "user") setViewMode("admin");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAdminUser]);
|
||||
|
||||
// 초기 로드: "사용자" 메뉴의 사이드바를 불러오기 (관리자 제외)
|
||||
useEffect(() => {
|
||||
if (topMenus.length > 0 && !activeTopMenu) {
|
||||
@@ -49,6 +56,22 @@ export function Header() {
|
||||
<span className="hidden sm:inline">매뉴얼</span>
|
||||
</a>
|
||||
|
||||
{/* 관리자 모드 토글 (admin 권한자만) */}
|
||||
{isAdminUser && (
|
||||
<button
|
||||
onClick={() => setViewMode(viewMode === "admin" ? "user" : "admin")}
|
||||
className={`inline-flex items-center gap-1 h-8 px-2.5 rounded-md text-xs font-bold transition ${
|
||||
viewMode === "admin"
|
||||
? "bg-orange-100 text-orange-700 hover:bg-orange-200"
|
||||
: "bg-blue-100 text-blue-700 hover:bg-blue-200"
|
||||
}`}
|
||||
title={viewMode === "admin" ? "사용자 메뉴로 전환" : "관리자 메뉴로 전환"}
|
||||
>
|
||||
{viewMode === "admin" ? <Shield size={13} /> : <Users size={13} />}
|
||||
<span className="hidden sm:inline">{viewMode === "admin" ? "관리자" : "사용자"}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 우측: 사용자명(프로필 링크) + 로그아웃 */}
|
||||
<a
|
||||
href="/profile"
|
||||
|
||||
@@ -33,9 +33,14 @@ function getMenuIcon(menuName: string): React.ElementType {
|
||||
export function Sidebar() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
sideMenus, activeSubMenu, isCollapsed,
|
||||
sideMenus: allSideMenus, activeSubMenu, isCollapsed, viewMode,
|
||||
setActiveSubMenu, toggleCollapsed, setMobileOpen,
|
||||
} = useMenuStore();
|
||||
// 사용자 모드: '거래처 주문' 그룹만. 관리자 모드: 그 외 그룹.
|
||||
const sideMenus = allSideMenus.filter((m) => {
|
||||
const isUserGroup = m.menuNameKor?.includes("거래처");
|
||||
return viewMode === "user" ? isUserGroup : !isUserGroup;
|
||||
});
|
||||
const [openCategories, setOpenCategories] = useState<Set<string>>(new Set());
|
||||
// 축소 모드 호버 팝업
|
||||
const [hoveredCategory, setHoveredCategory] = useState<string | null>(null);
|
||||
|
||||
@@ -9,12 +9,15 @@ interface MenuState {
|
||||
isCollapsed: boolean;
|
||||
/** 모바일에서 사이드바 오버레이로 펼침 여부 */
|
||||
mobileOpen: boolean;
|
||||
/** 관리자 모드 / 사용자 모드 — 사이드바에 보일 메뉴 그룹 결정 */
|
||||
viewMode: "user" | "admin";
|
||||
setTopMenus: (menus: { OBJID: string; MENU_NAME_KOR: string }[]) => void;
|
||||
setSideMenus: (menus: MenuItem[]) => void;
|
||||
setActiveTopMenu: (id: string) => void;
|
||||
setActiveSubMenu: (id: string) => void;
|
||||
toggleCollapsed: () => void;
|
||||
setMobileOpen: (v: boolean) => void;
|
||||
setViewMode: (v: "user" | "admin") => void;
|
||||
fetchTopMenus: () => Promise<void>;
|
||||
fetchSideMenus: (menuObjId: string) => Promise<void>;
|
||||
}
|
||||
@@ -26,6 +29,7 @@ export const useMenuStore = create<MenuState>((set) => ({
|
||||
activeSubMenu: "",
|
||||
isCollapsed: false,
|
||||
mobileOpen: false,
|
||||
viewMode: "user",
|
||||
|
||||
setTopMenus: (topMenus) => set({ topMenus }),
|
||||
setSideMenus: (sideMenus) => set({ sideMenus }),
|
||||
@@ -33,6 +37,7 @@ export const useMenuStore = create<MenuState>((set) => ({
|
||||
setActiveSubMenu: (activeSubMenu) => set({ activeSubMenu }),
|
||||
toggleCollapsed: () => set((s) => ({ isCollapsed: !s.isCollapsed })),
|
||||
setMobileOpen: (v) => set({ mobileOpen: v }),
|
||||
setViewMode: (v) => set({ viewMode: v }),
|
||||
|
||||
fetchTopMenus: async () => {
|
||||
const res = await fetch("/api/menu/top");
|
||||
|
||||
Reference in New Issue
Block a user