Some checks failed
Build and Push Images / build-and-push (push) Failing after 1m30s
- POP 전용 39개 파일 추가 (홈/입고/출고/생산) - 백엔드 INSERT에 id gen_random_uuid 추가 (5개 파일) - POP 전용 API 7개 추가 (창고/위치/입고/동기화) - PC 코드 구조/순서/로직 변경 없음 (AppLayout, UserDropdown 미수정)
277 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|