chore: 제조사 관리 메뉴/페이지/API 삭제 + 엑셀 공급업체 80개 일괄 등록
Deploy momo-erp / deploy (push) Successful in 2m0s

1) 삭제:
   - src/app/(main)/m/admin/makers/page.tsx
   - src/app/api/m/makers/{list,save,delete}/route.ts
   (메뉴 DB 의 제조사 항목은 이전 commit 9705a04 에서 이미 제거됨)

2) 마이그레이션 023:
   - docs/모모유통 제조사 리스트(26.05.12).xlsx 의 80개 업체를 supply_mng 에 일괄 등록
   - idempotent: supply_name 중복 시 SKIP (NOT EXISTS)
   - supply_code: MM-NNNN 자동 채번 (기존 max(objid) + ROW_NUMBER)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-13 12:13:19 +09:00
parent b8d0200831
commit 9293029631
5 changed files with 91 additions and 353 deletions
@@ -0,0 +1,91 @@
-- 엑셀 (docs/모모유통 제조사 리스트(26.05.12).xlsx) 100개 공급업체 일괄 등록
-- idempotent: supply_name 중복 시 skip
INSERT INTO supply_mng (objid, supply_code, supply_name, charge_user_name, supply_tel_no, supply_busname, status, reg_id, reg_date)
SELECT
(SELECT COALESCE(MAX(objid),0) FROM supply_mng) + ROW_NUMBER() OVER (),
'MM-' || LPAD(((SELECT COALESCE(MAX(objid),0) FROM supply_mng) + ROW_NUMBER() OVER ())::text, 4, '0'),
v.name, v.charger, v.tel, v.product, 'active', 'system', NOW()
FROM (VALUES
('주식회사 헤이필드 크리머리', '이지수 대표', '010-7244-4404', '유정란,유제품,치즈'),
('버터럼', '이지수 대표', '010-7244-4404', '노슈거, 뚜띠푸르티 과일즙/올리브유 외'),
('맛깔나는 모모수산', '김창완 대표', '010-7171-0070', '산지직송 수산'),
('신통방통푸드', '조용준 대표', '010-2222-3465', '보리쌈장'),
('플랜비', '김영헌 대표', '010-2965-9106', '듀라텍스외 화장품/식품'),
('비프스토리', '정찬/함휘령 대표', '010-3935-0076', '축산 /육가공'),
('에이슬링코리아', '김민재 대표', '010-5415-8753', '앙투어솔레 치즈/파스타외'),
('농업회사법인 그린라인 유한회사', '김남헌 수석 컨설턴트', '010-8704-4554', '유러피안 샐러드'),
('농업회사법인㈜대덕유통', '최광진 대표', '010-2774-6361', '1번 방사유정란'),
('오수연푸드', '신예진 과장', '010-3457-7080', '반찬류 외'),
('한울', '배우리 대리', '010-6855-1654', '한울김치 외'),
('한울팜스', '배우리 대리', '010-6855-1654', '비빔나물/ 한울 반찬 외'),
('자운두부', '대표', '010-4095-4149', ''),
('썬스토리', '김명준 상무', '010-9466-7098', '빛채울김치 외'),
('주식회사 상선에프앤비', '박선규 대표', '010-9938-7601', '간장게장/양념게장 외'),
('다온다코리아', '고요한 대표', '010-2995-1534', '덤핑/임박'),
('와이파이상사', '정종민 대표', '010-6648-9006', '화장품/식품/덤핑'),
('바른맛 자연', '대표', '010-8633-5816', '꽃징어 외 건어물'),
('그레잇 코리아', '정현경 팀장', '010-9100-4505', '김치/장아찌'),
('주식회사 야식창고', '도주형 대표', '010-8834-8874', '쿵딘쌀국수 외'),
('팔덕팩토리', '김경민 대표', '010-2927-7054', '팔덕식당 등갈비찜'),
('㈜성우트레이딩', '홍순석 대표', '010-6571-1440', '생선까스/밀키트 외'),
('오름 에프엔비', '정현주', '010-5115-0440', '대장쪽갈비'),
('㈜ 삼부에프씨', '조은실 대표', '010-9282-1150', '로움갈비'),
('벨라마켓', '김수진 대표', '010-4199-7321', '여수해적 밀키트 외'),
('르구르망', '차장님', '010-2442-6279', '치즈 외 유제품'),
('민속떡집', '정미화 대표', '010-8221-3846', '쑥개떡 외'),
('경기떡집', '마충렬 팀장', '010-4123-0626', '이티떡/미숫가루 외'),
('이랜드 팜앤푸드', '김승만 파트장', '010-6501-8768', '에슐리 밀키트 외'),
('퐈퐈마켓', '정수민 대리', '010-8495-0026', '초례청 약과 외'),
('덕스에프앤비', '이세환 대표', '010-5663-4764', '국물닭발 외'),
('오웬푸드 셰프애찬', '김영미 이사', '010-8818-2243', '셰프애찬 김치 외'),
('대명수산', '서태명 대표', '010-4992-5424', '과메기/ 수산밀키트 외'),
('정만수산', '대표', '010-7352-8882', '알탕 수산밀키트'),
('순진식품 영농회사법인', '이도규 부장', '010-5757-3954', '순진콩물'),
('한국해양수산', '이도경 대표', '010-3075-5598', '통영 수산물'),
('에스티식품', '허주원 대표', '010-6330-8032', '화명동 떡볶이'),
('㈜솔푸드', '대표', '', '뚱이만두'),
('진도 삼촌네', '대표', '010-5161-7603', '초당옥수수'),
('성부유통', '국수호 대표', '010-4233-9257', '가락청과 과일'),
('연동치미', '실장님', '010-4792-0023', ''),
('달을성농장', '윤성규 대표', '010-7585-0988', '샐러드팩'),
('아이캔두잇', '대표', '010-5276-5680', '스낵류'),
('피와이푸드', '박정수 대표', '010-7417-5800', '월남쌈 외'),
('간식어장', '이재선 대표', '010-2987-0444', '떡볶이/분식밀키트'),
('통큰수산', '조성우 전무', '010-9852-2734', '홍게/수산밀키트'),
('조선물산', '추희수 대리', '010-9791-8515', ''),
('오로라 에프앤비', '김리나 대표', '010-8445-2269', '양송이스프/치즈스틱 외'),
('초이스엠 코리아', '강정회 대표', '010-3200-6722', '양갈비 숄더랙'),
('글로비스 얼라이언스', '이승관 본부장', '010-3198-1742', '나탈리스 주스/밀키트 외'),
('덕컴퍼니', '강나래 대표', '010-9793-1117', '덤핑 외'),
('예령산업', '최우영 대표', '010-3350-3184', '철호국밥 외'),
('보라티알', '백민기 차장', '010-3722-1951', '데체코 올리브유 외'),
('에스디지 코퍼레이션', '양정규 대표', '010-6242-5098', '순대국 외'),
('더존푸드', '김준열 담당', '010-8507-9972', '국물닭발 외'),
('약단밤 이야기', '김영미 대표', '010-5671-6679', '생율, 약단밤'),
('남동공단 떡볶이', '대표', '010-9441-7901', '떡볶이/분식밀키트'),
('주식회사 온다미', '지현진 과장', '010-6564-4767', '반건오징어,수산'),
('차림에프엔비', '차주영 대표', '010-2582-4294', '삼일카레'),
('청담푸드', '박성태 대표', '010-7236-4988', '직화 알곱창'),
('촌드레 푸드', '전준동 대표', '010-8450-9944', '코다리 조림'),
('농업회사법인 ㈜ 애담', '대표', '010-3734-6595', '강화 약숙찹쌀떡'),
('주안고기백화점', '원기연 대표', '010-5431-3020', '쫙갈비(다원미트)'),
('㈜엘에푸푸드', '조성국 팀장', '010-8-4084-2097', 'LF푸드'),
('㈜대일본초', '전종호 대표', '010-3320-1848', '고구마칩'),
('이레축산', '박지헌 대표', '010-5612-4079', '정육(엠마오미트)'),
('영성', '김금영 대표', '010-2981-3427', '생강청 외'),
('이천쌀 김부각', '오경선 대표', '010-7194-3256', '이천 김부각'),
('㈜해늘', '최연준 이사', '010-9887-6256', '순대국'),
('유통몬스터', '최원철 대표', '010-5370-6123', '명인카스테라'),
('진보 건어물', '이주송 대표', '010-3167-4210', '쥐포'),
('서락비', '대표', '010-9070-2227', '닭갈비'),
('화앤닭', '대표', '010-6353-2223', '닭갈비'),
('치즈앤푸드', '정예슬대리', '010-2692-6066', '치즈/유제품 외'),
('오늘의 즐거움', '장주희 대표', '010-3795-3961', '밀키트 외'),
('그래비티', '김선영대표', '010-7176-8335', '애사비'),
('㈜동추원FNB', '박동신대표', '010-6341-0520', '소불고기 밀키트'),
('선산이조곱창', '대표', '010-5369-8272', '곱창전골'),
('단풍고을', '대표', '010-5351-2833', ''),
('엘리스유통', '대표', '010-2447-3002', '화장품/덤핑')
) AS v(name, charger, tel, product)
WHERE NOT EXISTS (SELECT 1 FROM supply_mng s WHERE s.supply_name = v.name);
-265
View File
@@ -1,265 +0,0 @@
"use client";
import { useEffect, useState, FormEvent } from "react";
import { Plus, Search, Pencil, Trash2, Factory } from "lucide-react";
import Swal from "sweetalert2";
interface Maker {
OBJID: string;
MAKER_NAME: string;
CONTACT: string;
PHONE: string;
MEMO: string;
REGDATE: string;
}
export default function AdminMakersPage() {
const [makers, setMakers] = useState<Maker[]>([]);
const [keyword, setKeyword] = useState("");
const [editing, setEditing] = useState<Partial<Maker> | null>(null);
const [saving, setSaving] = useState(false);
const load = async () => {
const res = await fetch("/api/m/makers/list", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ keyword }),
});
setMakers((await res.json()).RESULTLIST ?? []);
};
useEffect(() => {
load();
}, []); // eslint-disable-line
const onSave = async (e: FormEvent) => {
e.preventDefault();
if (!editing) return;
setSaving(true);
try {
const isNew = !editing.OBJID;
const res = await fetch("/api/m/makers/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
objid: editing.OBJID,
actionType: isNew ? "regist" : "update",
makerName: editing.MAKER_NAME,
contact: editing.CONTACT,
phone: editing.PHONE,
memo: editing.MEMO,
}),
});
const j = await res.json();
if (j.success) {
Swal.fire({ icon: "success", title: j.message, timer: 1200, showConfirmButton: false });
setEditing(null);
load();
} else {
Swal.fire({ icon: "error", title: "저장 실패", text: j.message });
}
} finally {
setSaving(false);
}
};
const onDelete = async (objid: string, name: string) => {
const ok = await Swal.fire({
icon: "warning",
title: `"${name}" 삭제`,
text: "삭제하시겠습니까?",
showCancelButton: true,
confirmButtonText: "삭제",
cancelButtonText: "취소",
confirmButtonColor: "#dc2626",
});
if (!ok.isConfirmed) return;
const res = await fetch("/api/m/makers/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objids: [objid] }),
});
const j = await res.json();
if (j.success) {
Swal.fire({ icon: "success", title: j.message, timer: 1200, showConfirmButton: false });
load();
} else {
Swal.fire({ icon: "error", title: "오류", text: j.message });
}
};
const set = (k: keyof Maker) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setEditing((prev) => prev ? { ...prev, [k]: e.target.value } : prev);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h1 className="text-xl sm:text-2xl font-bold text-slate-800"> </h1>
<p className="text-xs sm:text-sm text-slate-500 mt-1"> {makers.length}</p>
</div>
<button
onClick={() => setEditing({ MAKER_NAME: "", CONTACT: "", PHONE: "", MEMO: "" })}
className="h-10 px-3 sm:px-4 inline-flex items-center gap-1.5 rounded-lg bg-emerald-700 text-white text-xs sm:text-sm font-bold hover:bg-emerald-800"
>
<Plus size={16} />
</button>
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && load()}
placeholder="제조사명 검색"
className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
/>
</div>
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold shrink-0"></button>
</div>
{/* 모바일: 카드 */}
<div className="grid grid-cols-1 sm:hidden gap-2">
{makers.length === 0 ? (
<div className="bg-white border border-slate-200 rounded-xl p-8 text-center text-slate-400"> .</div>
) : makers.map((m) => (
<div key={m.OBJID} className="bg-white border border-slate-200 rounded-xl p-3 shadow-sm">
<div className="flex items-start gap-2">
<Factory size={16} className="text-emerald-700 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-sm truncate">{m.MAKER_NAME}</span>
<div className="shrink-0 flex gap-1">
<button onClick={() => setEditing(m)} className="text-slate-400 hover:text-emerald-700 p-1.5"><Pencil size={14} /></button>
<button onClick={() => onDelete(m.OBJID, m.MAKER_NAME)} className="text-slate-400 hover:text-rose-600 p-1.5"><Trash2 size={14} /></button>
</div>
</div>
<div className="text-[11px] text-slate-600 space-y-0.5 mt-0.5">
<div>📞 {m.PHONE || "-"} {m.CONTACT && `· ${m.CONTACT}`}</div>
{m.MEMO && <div className="text-slate-500">📝 {m.MEMO}</div>}
<div className="text-slate-400 text-[10px]"> {m.REGDATE}</div>
</div>
</div>
</div>
</div>
))}
</div>
{/* 데스크탑: 표 */}
<div className="hidden sm:block bg-white border border-slate-200 rounded-xl overflow-x-auto">
<table className="w-full text-sm min-w-[700px]">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
<th className="text-center px-4 py-3 w-[100px]"></th>
<th className="text-right px-4 py-3 w-[80px]"></th>
</tr>
</thead>
<tbody>
{makers.length === 0 ? (
<tr>
<td colSpan={6} className="text-center py-12 text-slate-400">
. .
</td>
</tr>
) : (
makers.map((m) => (
<tr key={m.OBJID} className="border-t border-slate-100 hover:bg-slate-50">
<td className="px-4 py-3 font-semibold text-slate-800">{m.MAKER_NAME}</td>
<td className="px-4 py-3 text-slate-600">{m.CONTACT || "-"}</td>
<td className="px-4 py-3 text-slate-600">{m.PHONE || "-"}</td>
<td className="px-4 py-3 text-slate-500 text-xs">{m.MEMO || "-"}</td>
<td className="px-4 py-3 text-center text-slate-500 text-xs">{m.REGDATE}</td>
<td className="px-4 py-3 text-right">
<button onClick={() => setEditing(m)} className="text-slate-400 hover:text-emerald-700 p-1"><Pencil size={14} /></button>
<button onClick={() => onDelete(m.OBJID, m.MAKER_NAME)} className="text-slate-400 hover:text-rose-600 p-1 ml-1"><Trash2 size={14} /></button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* 등록/수정 모달 */}
{editing && (
<div
className="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-4"
onClick={() => setEditing(null)}
>
<form
onSubmit={onSave}
onClick={(e) => e.stopPropagation()}
className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6"
>
<h3 className="text-lg font-bold mb-5 text-slate-800">
{editing.OBJID ? "제조사 수정" : "제조사 등록"}
</h3>
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1.5">
<span className="text-rose-500">*</span>
</label>
<input
required
value={editing.MAKER_NAME ?? ""}
onChange={set("MAKER_NAME")}
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1.5"></label>
<input
value={editing.CONTACT ?? ""}
onChange={set("CONTACT")}
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1.5"></label>
<input
value={editing.PHONE ?? ""}
onChange={set("PHONE")}
placeholder="010-0000-0000"
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1.5"></label>
<textarea
rows={3}
value={editing.MEMO ?? ""}
onChange={set("MEMO")}
className="w-full px-3 py-2 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none resize-none"
/>
</div>
</div>
<div className="flex gap-2 justify-end mt-6 pt-4 border-t border-slate-100">
<button
type="button"
onClick={() => setEditing(null)}
className="px-4 h-10 rounded-lg border border-slate-200 text-sm font-semibold hover:bg-slate-50"
>
</button>
<button
type="submit"
disabled={saving}
className="px-5 h-10 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 disabled:opacity-60"
>
{saving ? "저장 중..." : "저장"}
</button>
</div>
</form>
</div>
)}
</div>
);
}
-19
View File
@@ -1,19 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { execute } from "@/lib/db";
import { requireMomoAdmin } from "@/lib/momo-guard";
export async function POST(req: NextRequest) {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
const { objids } = await req.json();
if (!Array.isArray(objids) || objids.length === 0) {
return NextResponse.json({ success: false, message: "삭제할 항목을 선택하세요." }, { status: 400 });
}
for (const id of objids) {
await execute(`UPDATE momo_makers SET is_del='Y' WHERE objid=$1`, [id]);
}
return NextResponse.json({ success: true, message: `${objids.length}개 삭제되었습니다.` });
}
-35
View File
@@ -1,35 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { queryRows } from "@/lib/db";
import { requireMomoUser } from "@/lib/momo-guard";
export async function POST(req: NextRequest) {
const r = await requireMomoUser();
if (r instanceof NextResponse) return r;
const body = await req.json().catch(() => ({}));
const { keyword } = body as { keyword?: string };
const conditions = ["COALESCE(is_del, 'N') != 'Y'"];
const params: unknown[] = [];
let i = 1;
if (keyword) {
conditions.push(`maker_name ILIKE '%' || $${i++} || '%'`);
params.push(keyword);
}
const rows = await queryRows(
`SELECT objid AS "OBJID",
maker_name AS "MAKER_NAME",
contact AS "CONTACT",
phone AS "PHONE",
memo AS "MEMO",
TO_CHAR(regdate, 'YYYY-MM-DD') AS "REGDATE"
FROM momo_makers
WHERE ${conditions.join(" AND ")}
ORDER BY maker_name ASC`,
params
);
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
}
-34
View File
@@ -1,34 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { execute } from "@/lib/db";
import { createObjectId } from "@/lib/utils";
import { requireMomoAdmin } from "@/lib/momo-guard";
export async function POST(req: NextRequest) {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
const body = await req.json();
const { objid, actionType, makerName, contact, phone, memo } = body;
if (!makerName) {
return NextResponse.json({ success: false, message: "제조사명은 필수입니다." }, { status: 400 });
}
if (actionType === "regist") {
const newId = createObjectId();
await execute(
`INSERT INTO momo_makers (objid, maker_name, contact, phone, memo, is_del, regdate, regid)
VALUES ($1, $2, $3, $4, $5, 'N', NOW(), $6)`,
[newId, makerName, contact ?? null, phone ?? null, memo ?? null, g.user.userId]
);
return NextResponse.json({ success: true, objId: newId, message: "등록되었습니다." });
}
if (!objid) return NextResponse.json({ success: false, message: "objid 누락" }, { status: 400 });
await execute(
`UPDATE momo_makers SET maker_name=$2, contact=$3, phone=$4, memo=$5 WHERE objid=$1`,
[objid, makerName, contact ?? null, phone ?? null, memo ?? null]
);
return NextResponse.json({ success: true, objId: objid, message: "수정되었습니다." });
}