Files
vexplor_dev/frontend/components/pop/hardcoded/inbound/SupplierModal.tsx
SeongHyun Kim 9b7b88ff7c
Some checks failed
Build and Push Images / build-and-push (push) Failing after 1m30s
feat: POP 하드코딩 화면 추가 (PC 코드 무변경 재병합)
- POP 전용 39개 파일 추가 (홈/입고/출고/생산)
- 백엔드 INSERT에 id gen_random_uuid 추가 (5개 파일)
- POP 전용 API 7개 추가 (창고/위치/입고/동기화)
- PC 코드 구조/순서/로직 변경 없음 (AppLayout, UserDropdown 미수정)
2026-04-02 17:39:42 +09:00

277 lines
11 KiB
TypeScript

"use client";
import React, { useState, useEffect, useMemo, useCallback } from "react";
import { apiClient } from "@/lib/api/client";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export interface Supplier {
id: string;
customer_name: string;
customer_code?: string;
business_number?: string;
phone?: string;
address?: string;
}
interface SupplierModalProps {
open: boolean;
onClose: () => void;
onSelect: (supplier: Supplier) => void;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const AVATAR_COLORS = [
"#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6",
"#06b6d4", "#ec4899", "#14b8a6", "#f97316", "#6366f1",
"#84cc16", "#e11d48", "#0ea5e9", "#a855f7", "#10b981",
];
function getAvatarColor(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
}
/** Get the Korean initial consonant (Chosung) for sorting */
export function getChosung(char: string): string {
const code = char.charCodeAt(0);
if (code < 0xAC00 || code > 0xD7A3) {
// Not Korean -- group by uppercase letter
const upper = char.toUpperCase();
if (/[A-Z]/.test(upper)) return upper;
return "#";
}
const chosungList = [
"ㄱ","ㄲ","ㄴ","ㄷ","ㄸ","ㄹ","ㅁ","ㅂ","ㅃ","ㅅ",
"ㅆ","ㅇ","ㅈ","ㅉ","ㅊ","ㅋ","ㅌ","ㅍ","ㅎ",
];
const idx = Math.floor((code - 0xAC00) / 588);
return chosungList[idx] ?? "#";
}
/** Check if a query (possibly chosung-only) matches a Korean string */
export function matchChosung(text: string, query: string): boolean {
if (!query) return true;
// Normal substring match first
if (text.toLowerCase().includes(query.toLowerCase())) return true;
// Check if query is all chosung characters
const chosungChars = "ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ";
const isChosungQuery = [...query].every((c) => chosungChars.includes(c));
if (!isChosungQuery) return false;
// Extract chosung from text (strip prefixes like (주))
const cleaned = text.replace(/^\(주\)|\(유\)|\(합\)/g, "").trim();
const textChosung = [...cleaned].map((c) => getChosung(c)).join("");
return textChosung.includes(query);
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function SupplierModal({ open, onClose, onSelect }: SupplierModalProps) {
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
const [search, setSearch] = useState("");
const [sortMode, setSortMode] = useState<"korean" | "abc">("korean");
const [loading, setLoading] = useState(false);
/* Fetch suppliers (supplier_mng = 공급사/거래처) */
const fetchSuppliers = useCallback(async () => {
setLoading(true);
try {
// 구매입고 거래처 = supplier_mng (공급사)
const res = await apiClient.get("/data/supplier_mng", {
params: { pageSize: 500 },
});
const data = res.data?.data ?? res.data?.rows ?? [];
const list: Supplier[] = (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({
id: String(r.id ?? ""),
customer_name: String(r.supplier_name ?? r.customer_name ?? r.name ?? ""),
customer_code: String(r.supplier_code ?? r.customer_code ?? r.code ?? ""),
business_number: String(r.business_number ?? ""),
phone: String(r.contact_phone ?? r.phone ?? ""),
address: String(r.address ?? ""),
}));
setSuppliers(list);
} catch {
// fallback: customer_info 시도
try {
const res2 = await apiClient.get("/data/customer_info", {
params: { pageSize: 500 },
});
const data2 = res2.data?.data ?? res2.data?.rows ?? [];
const list2: Supplier[] = (Array.isArray(data2) ? data2 : []).map((r: Record<string, unknown>) => ({
id: String(r.id ?? ""),
customer_name: String(r.customer_name ?? r.name ?? ""),
customer_code: String(r.customer_code ?? r.code ?? ""),
business_number: String(r.business_number ?? ""),
phone: String(r.phone ?? ""),
address: String(r.address ?? ""),
}));
setSuppliers(list2);
} catch {
setSuppliers([]);
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) {
fetchSuppliers();
setSearch("");
}
}, [open, fetchSuppliers]);
/* Filtered + grouped */
const grouped = useMemo(() => {
const filtered = suppliers.filter((s) =>
s.customer_name.toLowerCase().includes(search.toLowerCase()) ||
(s.customer_code ?? "").toLowerCase().includes(search.toLowerCase())
);
// Sort
const sorted = [...filtered].sort((a, b) => {
if (sortMode === "abc") return a.customer_name.localeCompare(b.customer_name, "en");
return a.customer_name.localeCompare(b.customer_name, "ko");
});
// Group by chosung
const groups: { letter: string; items: Supplier[] }[] = [];
const map = new Map<string, Supplier[]>();
for (const s of sorted) {
const first = s.customer_name.replace(/^\(주\)|\(유\)|\(합\)/g, "").trim().charAt(0);
const letter = getChosung(first);
if (!map.has(letter)) map.set(letter, []);
map.get(letter)!.push(s);
}
for (const [letter, items] of map) {
groups.push({ letter, items });
}
return groups;
}, [suppliers, search, sortMode]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Overlay */}
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
{/* Modal */}
<div className="relative bg-white rounded-2xl w-full max-w-lg max-h-[85vh] flex flex-col shadow-2xl overflow-hidden z-10">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<div className="flex items-center gap-3">
<h3 className="text-lg font-bold text-gray-900"> </h3>
{/* Sort tabs */}
<div className="flex gap-1.5">
<button
onClick={() => setSortMode("korean")}
className={`px-3 py-1 rounded-full text-xs font-medium border transition-all ${
sortMode === "korean"
? "bg-gray-900 text-white border-gray-900"
: "bg-white text-gray-500 border-gray-200 hover:bg-gray-50"
}`}
>
</button>
<button
onClick={() => setSortMode("abc")}
className={`px-3 py-1 rounded-full text-xs font-medium border transition-all ${
sortMode === "abc"
? "bg-gray-900 text-white border-gray-900"
: "bg-white text-gray-500 border-gray-200 hover:bg-gray-50"
}`}
>
ABC
</button>
</div>
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-gray-500 hover:bg-gray-200 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Search */}
<div className="px-5 py-3">
<div className="relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="거래처명 또는 코드 검색..."
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition-all"
/>
</div>
</div>
{/* Supplier list */}
<div className="flex-1 overflow-y-auto px-5 pb-5">
{loading ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
...
</div>
) : grouped.length === 0 ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
{search ? "검색 결과가 없습니다" : "거래처가 없습니다"}
</div>
) : (
grouped.map((group) => (
<div key={group.letter} className="mb-4">
{/* Group header */}
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-bold text-blue-500 min-w-[20px]">{group.letter}</span>
<div className="flex-1 h-px bg-gray-100" />
</div>
{/* Grid */}
<div className="grid grid-cols-4 sm:grid-cols-5 gap-1">
{group.items.map((supplier) => {
const displayName = supplier.customer_name.replace(/^\(주\)|\(유\)|\(합\)/g, "").trim();
const initial = displayName.charAt(0);
const color = getAvatarColor(supplier.customer_name);
return (
<button
key={supplier.id}
onClick={() => { onSelect(supplier); onClose(); }}
className="flex flex-col items-center gap-1.5 p-2.5 rounded-xl hover:bg-gray-50 active:scale-95 transition-all cursor-pointer border-none bg-transparent"
>
<div
className="w-12 h-12 sm:w-14 sm:h-14 rounded-2xl flex items-center justify-center text-white text-base sm:text-lg font-bold shadow-sm"
style={{ background: color }}
>
{initial}
</div>
<span className="text-[10px] sm:text-[11px] font-medium text-gray-700 text-center leading-tight max-w-[64px] truncate">
{supplier.customer_name}
</span>
</button>
);
})}
</div>
</div>
))
)}
</div>
</div>
</div>
);
}