import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router'; import { Palette, Tags, ListPlus, Plus, Trash2, RotateCcw, Download, Upload, X } from 'lucide-react'; import { showToast } from '@/lib/notify'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import ImageDropzone from '@/components/ImageDropzone'; import { uploadItemImage } from '@/api/generated/item/item'; import { Badge } from '@/components/ui/badge'; import { Typography } from '@/components/ui/typography'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/select'; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; import { LABEL_CATALOG, HIDEABLE_ITEM_FIELDS, CUSTOM_FIELD_TYPE_LABEL, type CompanySettings, type CustomFieldDef, type CustomFieldType, } from './catalog'; import { useCompanySettings } from './useCompanySettings'; export const SETTINGS_TABS = ['branding', 'labels', 'fields'] as const; export type SettingsTab = (typeof SETTINGS_TABS)[number]; export const SETTINGS_TAB_LABEL: Record = { branding: '브랜딩(CI)', labels: '용어(라벨)', fields: '커스텀 필드', }; export function SettingsView() { const { settings, isLoading, save } = useCompanySettings(); // 탭은 ?tab=<탭> 으로 URL 에 남긴다 — 링크 공유·새로고침·메뉴 빠른이동에서 같은 탭으로 열리게. const [searchParams, setSearchParams] = useSearchParams(); const tabParam = searchParams.get('tab'); const tab: SettingsTab = (SETTINGS_TABS as readonly string[]).includes(tabParam ?? '') ? (tabParam as SettingsTab) : 'branding'; const setTab = (next: string) => { const params = new URLSearchParams(searchParams); params.set('tab', next); setSearchParams(params, { replace: true }); }; // 저장 전 편집본(draft). 서버 반영은 저장 버튼에서만. const [draft, setDraft] = useState({}); const [saving, setSaving] = useState(false); useEffect(() => { if (!isLoading) setDraft(structuredClone(settings)); // settings 객체는 쿼리 캐시 무효화 때만 바뀐다(참조 비교로 충분). // eslint-disable-next-line react-hooks/exhaustive-deps }, [isLoading, JSON.stringify(settings)]); const dirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(settings), [draft, settings]); const handleSave = async () => { // 빈 문자열 라벨/브랜딩은 "기본값 사용"이므로 저장 전 제거해 문서를 깨끗하게 유지한다. const labels = Object.fromEntries(Object.entries(draft.labels ?? {}).filter(([, v]) => v.trim())); const branding = Object.fromEntries(Object.entries(draft.branding ?? {}).filter(([, v]) => (v ?? '').trim())); const itemFields = (draft.item_fields ?? []).filter((f) => f.key.trim() && f.label.trim()); const supplierFields = (draft.supplier_fields ?? []).filter((f) => f.key.trim() && f.label.trim()); const sessionFields = (draft.session_fields ?? []).filter((f) => f.key.trim() && f.label.trim()); const hiddenFields = [...new Set((draft.hidden_fields ?? []).filter((k) => k.trim()))]; const next: CompanySettings = { ...draft, hidden_fields: hiddenFields, labels, branding, item_fields: itemFields, supplier_fields: supplierFields, session_fields: sessionFields, }; setSaving(true); try { await save(next); showToast('회사 설정이 저장되었습니다.', 'success'); } catch (err) { showToast(err instanceof Error ? err.message : '설정 저장 실패', 'error'); } finally { setSaving(false); } }; const fileRef = useRef(null); // 현재 편집본을 JSON 파일로 내려받는다(회사 설정 전체 백업/이관용). const handleExport = () => { const blob = new Blob([JSON.stringify(draft, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'company-settings.json'; a.click(); URL.revokeObjectURL(url); }; // JSON 파일 → 편집본 병합. 빈 값(빈 문자열·빈 배열·null)은 "그대로 두기"로 보고 덮어쓰지 않는다. const handleImport = async (file: File) => { try { const parsed = JSON.parse(await file.text()) as CompanySettings; setDraft((d) => mergeSettings(d, parsed)); showToast('설정 JSON 을 불러왔습니다. 확인 후 저장하십시오.', 'success'); } catch (err) { showToast(err instanceof Error ? `JSON 파싱 실패 - ${err.message}` : 'JSON 파싱 실패', 'error'); } }; // 로고 파일 업로드 — 상품 이미지와 동일한 스토리지 엔드포인트(/v1/item/image, Azure Blob)를 재사용해 URL 을 받는다. const handleUploadLogo = async (file: File): Promise => { const res = await uploadItemImage({ file: file as unknown as string }); if (res.result?.success === false) throw new Error(res.result.desc || '로고 업로드에 실패했습니다.'); if (!res.image_url) throw new Error('업로드 응답에 URL 이 없습니다.'); return res.image_url; }; const setBranding = (key: keyof NonNullable, value: string) => setDraft((d) => ({ ...d, branding: { ...d.branding, [key]: value } })); const setLabel = (key: string, value: string) => setDraft((d) => ({ ...d, labels: { ...d.labels, [key]: value } })); return (
브랜딩(CI) 용어(라벨) 커스텀 필드 {/* 저장 바 — 변경이 있을 때만 활성화 */}
{dirty && ( )} { const f = e.target.files?.[0]; if (f) handleImport(f); e.target.value = ''; // 같은 파일 재선택 허용 }} />
{/* ---- 브랜딩(CI) ---- */}
setBranding('service_name', e.target.value)} placeholder="NegoData (기본값)" />
{/* 로고 이미지 — 업로드(드롭/선택) 또는 URL 직접 입력. 비우면 색상 마크+서비스명 텍스트. */}
setBranding('logo_url', url)} onClear={() => setBranding('logo_url', '')} onUpload={handleUploadLogo} label="로고 이미지 업로드" />
{/* 라이브 미리보기 */}
미리보기 — 사이드바 브랜드
{draft.branding?.logo_url ? ( 로고 미리보기 ) : ( )} {draft.branding?.service_name || 'NegoData'}
{/* ---- 용어(라벨) ---- */}
기본 용어 우리 회사 용어 적용 위치 {LABEL_CATALOG.map((entry, i) => { const value = draft.labels?.[entry.key] ?? ''; const isGroupHead = i === 0 || LABEL_CATALOG[i - 1].group !== entry.group; return ( {/* 그룹(상품·협력사·견적) 구분 행 — 용어가 40개 가까워 한 덩어리면 찾기 어렵다 */} {isGroupHead && ( {entry.group} )} {entry.base} setLabel(entry.key, e.target.value)} placeholder={`${entry.base} (기본값)`} /> {entry.where} {value.trim() && ( 변경됨 )} ); })}
{/* ---- 커스텀 필드 ---- */}
{HIDEABLE_ITEM_FIELDS.map((f) => { const checked = (draft.hidden_fields ?? []).includes(f.key); return ( ); })}
setDraft((d) => ({ ...d, item_fields: fields }))} /> setDraft((d) => ({ ...d, supplier_fields: fields }))} /> setDraft((d) => ({ ...d, session_fields: fields }))} />
); } // 불러온 JSON 을 현재 설정에 병합. 값이 비어있는 키(빈 문자열·빈 배열·null·undefined)는 // "업데이트 안 함"으로 보고 기존 값을 유지한다 — 부분 JSON 만 던져도 안전하게 갱신되도록. function mergeSettings(base: CompanySettings, incoming: CompanySettings): CompanySettings { const mergeMap = (b: Record = {}, i: Record = {}) => { const out = { ...b }; for (const [k, v] of Object.entries(i)) if (typeof v === 'string' && v.trim()) out[k] = v; return out; }; const pickFields = (b?: CustomFieldDef[], i?: CustomFieldDef[]) => Array.isArray(i) && i.length > 0 ? i : (b ?? []); return { ...base, labels: mergeMap(base.labels, incoming.labels), branding: mergeMap( base.branding as Record | undefined, incoming.branding as Record | undefined, ) as CompanySettings['branding'], hidden_fields: Array.isArray(incoming.hidden_fields) && incoming.hidden_fields.length > 0 ? incoming.hidden_fields : (base.hidden_fields ?? []), item_fields: pickFields(base.item_fields, incoming.item_fields), supplier_fields: pickFields(base.supplier_fields, incoming.supplier_fields), session_fields: pickFields(base.session_fields, incoming.session_fields), }; } function SectionCard({ title, desc, children }: { title: string; desc: string; children: React.ReactNode }) { return (
{title} {desc} {children}
); } function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { return (
{label} {children} {hint && {hint}}
); } // 커스텀필드 정의 편집 — 표시명을 입력하면 key 를 자동 제안하되 직접 수정도 가능. function CustomFieldsEditor({ title, desc, fields, onChange, }: { title: string; desc: string; fields: CustomFieldDef[]; onChange: (fields: CustomFieldDef[]) => void; }) { const update = (i: number, patch: Partial) => onChange(fields.map((f, idx) => (idx === i ? { ...f, ...patch } : f))); return (
표시명 키 (영문) 유형 보기 목록 (선택형, 쉼표로 구분) 삭제 {fields.length === 0 && ( 추가된 커스텀 필드가 없습니다. )} {fields.map((f, i) => ( update(i, { label: e.target.value })} placeholder="예: 발주배수" /> update(i, { key: sanitizeKey(e.target.value) })} placeholder="예: order_multiple" /> value={f.type} onValueChange={(v) => v && update(i, { type: v })} > {(Object.keys(CUSTOM_FIELD_TYPE_LABEL) as CustomFieldType[]).map((t) => ( {CUSTOM_FIELD_TYPE_LABEL[t]} ))} update(i, { options: opts })} /> ))}
); } // 선택형 필드의 보기 목록 — 칩(태그) 방식. 입력 후 Enter/쉼표로 하나씩 추가, X로 제거, 빈 칸에서 Backspace 로 마지막 제거. function OptionsEditor({ options, disabled, onChange, }: { options: string[]; disabled?: boolean; onChange: (opts: string[]) => void; }) { const [draft, setDraft] = useState(''); if (disabled) return 유형이 ‘선택’일 때 사용; const add = (raw: string) => { const v = raw.trim(); setDraft(''); if (v && !options.includes(v)) onChange([...options, v]); }; return (
{options.map((o, idx) => ( {o} ))} (e.target.value.includes(',') ? add(e.target.value.replace(/,/g, '')) : setDraft(e.target.value))} onKeyDown={(e) => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) { e.preventDefault(); add(draft); } else if (e.key === 'Backspace' && !draft && options.length) { onChange(options.slice(0, -1)); } }} onBlur={() => add(draft)} placeholder={options.length ? '추가…' : '예: 협력사배송 (엔터로 추가)'} />
); } // key 입력 정리 — 영문/숫자/언더스코어만 허용(소문자화). function sanitizeKey(raw: string): string { return raw.toLowerCase().replace(/[^a-z0-9_]/g, ''); }