o2o-negosium-original/negodata/front/src/features/settings/SettingsView.tsx

549 lines
24 KiB
TypeScript

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<SettingsTab, string> = {
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<CompanySettings>({});
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<HTMLInputElement>(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<string> => {
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<CompanySettings['branding']>, 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 (
<div className="space-y-4 font-mono text-xs">
<Tabs value={tab} onValueChange={setTab}>
<div className="flex items-center justify-between gap-3 flex-wrap">
<TabsList>
<TabsTrigger value="branding" className="gap-1.5 px-3">
<Palette size={13} /> 브랜딩(CI)
</TabsTrigger>
<TabsTrigger value="labels" className="gap-1.5 px-3">
<Tags size={13} /> 용어(라벨)
</TabsTrigger>
<TabsTrigger value="fields" className="gap-1.5 px-3">
<ListPlus size={13} /> 커스텀 필드
</TabsTrigger>
</TabsList>
{/* 저장 바 — 변경이 있을 때만 활성화 */}
<div className="flex items-center gap-2">
{dirty && (
<Button variant="ghost" size="sm" onClick={() => setDraft(structuredClone(settings))}>
<RotateCcw size={13} /> 되돌리기
</Button>
)}
<input
ref={fileRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleImport(f);
e.target.value = ''; // 같은 파일 재선택 허용
}}
/>
<Button variant="ghost" size="sm" onClick={() => fileRef.current?.click()}>
<Upload size={13} /> JSON 불러오기
</Button>
<Button variant="ghost" size="sm" onClick={handleExport}>
<Download size={13} /> JSON 내보내기
</Button>
<Button size="sm" onClick={handleSave} disabled={!dirty || saving}>
{saving ? '저장 중...' : '변경사항 저장'}
</Button>
</div>
</div>
{/* ---- 브랜딩(CI) ---- */}
<TabsContent value="branding" className="space-y-4">
<SectionCard
title="서비스 브랜딩"
desc="사이드바·타이틀에 노출되는 서비스명과 로고입니다. 비워두면 기본 브랜드(NegoData)가 사용됩니다."
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field label="서비스명" hint="예: iMarketKorea 구매협상 콘솔">
<Input
value={draft.branding?.service_name ?? ''}
onChange={(e) => setBranding('service_name', e.target.value)}
placeholder="NegoData (기본값)"
/>
</Field>
</div>
{/* 로고 이미지 — 업로드(드롭/선택) 또는 URL 직접 입력. 비우면 색상 마크+서비스명 텍스트. */}
<div className="mt-4">
<Field label="로고 이미지" hint="비우면 색상 마크 + 서비스명 텍스트로 표시됩니다.">
<ImageDropzone
value={draft.branding?.logo_url ?? ''}
onChange={(url) => setBranding('logo_url', url)}
onClear={() => setBranding('logo_url', '')}
onUpload={handleUploadLogo}
label="로고 이미지 업로드"
/>
</Field>
</div>
{/* 라이브 미리보기 */}
<div className="mt-4 border border-border rounded-md p-3 bg-muted/30">
<Typography variant="muted" className="text-[10px] block mb-2">미리보기 — 사이드바 브랜드</Typography>
<div className="inline-flex items-center gap-2 font-extrabold tracking-tight text-foreground text-sm bg-background border border-border rounded px-3 py-2">
{draft.branding?.logo_url ? (
<img src={draft.branding.logo_url} alt="로고 미리보기" className="h-4 max-w-24 object-contain" />
) : (
<span
aria-hidden
className="size-4 rounded-[5px]"
style={{ backgroundColor: draft.branding?.primary_color || 'var(--primary)' }}
/>
)}
{draft.branding?.service_name || 'NegoData'}
</div>
</div>
</SectionCard>
</TabsContent>
{/* ---- 용어(라벨) ---- */}
<TabsContent value="labels" className="space-y-4">
<SectionCard
title="용어 커스터마이징"
desc="우리 회사에서 쓰는 용어로 화면 표기를 바꿉니다. 비워두면 기본 용어가 사용됩니다."
>
<div className="border border-border rounded overflow-hidden">
<Table className="w-full text-left font-mono text-[11px]">
<TableHeader className="bg-muted text-muted-foreground">
<TableRow>
<TableHead className="p-2 w-40">기본 용어</TableHead>
<TableHead className="p-2 w-56">우리 회사 용어</TableHead>
<TableHead className="p-2">적용 위치</TableHead>
</TableRow>
</TableHeader>
<TableBody className="divide-y divide-border bg-background">
{LABEL_CATALOG.map((entry, i) => {
const value = draft.labels?.[entry.key] ?? '';
const isGroupHead = i === 0 || LABEL_CATALOG[i - 1].group !== entry.group;
return (
<Fragment key={entry.key}>
{/* 그룹(상품·협력사·견적) 구분 행 — 용어가 40개 가까워 한 덩어리면 찾기 어렵다 */}
{isGroupHead && (
<TableRow className="bg-muted/40 hover:bg-muted/40">
<TableCell colSpan={3} className="p-2 font-bold text-muted-foreground">
{entry.group}
</TableCell>
</TableRow>
)}
<TableRow className="hover:bg-muted/30">
<TableCell className="p-2 font-bold text-foreground">{entry.base}</TableCell>
<TableCell className="p-2">
<Input
className="h-7 text-[11px]"
value={value}
onChange={(e) => setLabel(entry.key, e.target.value)}
placeholder={`${entry.base} (기본값)`}
/>
</TableCell>
<TableCell className="p-2 text-muted-foreground">
{entry.where}
{value.trim() && (
<Badge variant="secondary" className="ml-2 text-[9px] px-1 py-0">변경됨</Badge>
)}
</TableCell>
</TableRow>
</Fragment>
);
})}
</TableBody>
</Table>
</div>
</SectionCard>
</TabsContent>
{/* ---- 커스텀 필드 ---- */}
<TabsContent value="fields" className="space-y-4">
<SectionCard
title="상품 필드 숨김"
desc="체크한 항목은 상품 목록·등록 폼·엑셀 양식에서 감춰집니다. DB 컬럼과 기존 값은 그대로 남습니다."
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2">
{HIDEABLE_ITEM_FIELDS.map((f) => {
const checked = (draft.hidden_fields ?? []).includes(f.key);
return (
<label key={f.key} className="flex items-start gap-2 py-1 cursor-pointer">
<input
type="checkbox"
className="mt-0.5"
checked={checked}
onChange={(e) =>
setDraft((d) => {
const cur = new Set(d.hidden_fields ?? []);
if (e.target.checked) cur.add(f.key);
else cur.delete(f.key);
return { ...d, hidden_fields: [...cur] };
})
}
/>
<span className="min-w-0">
<Typography as="span" variant="small" className="block font-semibold">
{f.label}
</Typography>
<Typography as="span" variant="caption" className="block text-muted-foreground">
{f.where}
</Typography>
{f.calcNote && (
<Typography as="span" variant="caption" className="block text-amber-600">
⚠ {f.calcNote}
</Typography>
)}
</span>
</label>
);
})}
</div>
</SectionCard>
<CustomFieldsEditor
title="상품 커스텀 필드"
desc="상품 등록·수정 화면에 추가로 입력받을 항목입니다. (예: 발주배수, 하도급 여부)"
fields={draft.item_fields ?? []}
onChange={(fields) => setDraft((d) => ({ ...d, item_fields: fields }))}
/>
<CustomFieldsEditor
title="협력사 커스텀 필드"
desc="협력사 등록·수정 화면에 추가로 입력받을 항목입니다. (예: 분류카테고리, 유통레벨)"
fields={draft.supplier_fields ?? []}
onChange={(fields) => setDraft((d) => ({ ...d, supplier_fields: fields }))}
/>
<CustomFieldsEditor
title="협상완료 부가정보 필드"
desc="공급사가 협상 타결 후 입력할 항목입니다. (예: 표준납기, 최소주문수량, 발주배수, 배송유형)"
fields={draft.session_fields ?? []}
onChange={(fields) => setDraft((d) => ({ ...d, session_fields: fields }))}
/>
</TabsContent>
</Tabs>
</div>
);
}
// 불러온 JSON 을 현재 설정에 병합. 값이 비어있는 키(빈 문자열·빈 배열·null·undefined)는
// "업데이트 안 함"으로 보고 기존 값을 유지한다 — 부분 JSON 만 던져도 안전하게 갱신되도록.
function mergeSettings(base: CompanySettings, incoming: CompanySettings): CompanySettings {
const mergeMap = (b: Record<string, string> = {}, i: Record<string, string> = {}) => {
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<string, string> | undefined,
incoming.branding as Record<string, string> | 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 (
<div className="bg-card border border-border rounded-lg p-5">
<Typography variant="h3" className="mb-1">{title}</Typography>
<Typography variant="muted" className="text-[11px] block mb-4">{desc}</Typography>
{children}
</div>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="space-y-1">
<Typography as="label" variant="muted" className="text-[10px] font-semibold">{label}</Typography>
{children}
{hint && <Typography variant="muted" className="text-[10px] block">{hint}</Typography>}
</div>
);
}
// 커스텀필드 정의 편집 — 표시명을 입력하면 key 를 자동 제안하되 직접 수정도 가능.
function CustomFieldsEditor({
title,
desc,
fields,
onChange,
}: {
title: string;
desc: string;
fields: CustomFieldDef[];
onChange: (fields: CustomFieldDef[]) => void;
}) {
const update = (i: number, patch: Partial<CustomFieldDef>) =>
onChange(fields.map((f, idx) => (idx === i ? { ...f, ...patch } : f)));
return (
<SectionCard title={title} desc={desc}>
<div className="border border-border rounded overflow-hidden">
<Table className="w-full text-left font-mono text-[11px]">
<TableHeader className="bg-muted text-muted-foreground">
<TableRow>
<TableHead className="p-2 w-52">표시명</TableHead>
<TableHead className="p-2 w-52">키 (영문)</TableHead>
<TableHead className="p-2 w-36">유형</TableHead>
<TableHead className="p-2">보기 목록 (선택형, 쉼표로 구분)</TableHead>
<TableHead className="p-2 text-center w-12">삭제</TableHead>
</TableRow>
</TableHeader>
<TableBody className="divide-y divide-border bg-background">
{fields.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="p-6 text-center text-muted-foreground">
추가된 커스텀 필드가 없습니다.
</TableCell>
</TableRow>
)}
{fields.map((f, i) => (
<TableRow key={i} className="hover:bg-muted/30">
<TableCell className="p-2">
<Input
className="h-7 text-[11px]"
value={f.label}
onChange={(e) => update(i, { label: e.target.value })}
placeholder="예: 발주배수"
/>
</TableCell>
<TableCell className="p-2">
<Input
className="h-7 text-[11px]"
value={f.key}
onChange={(e) => update(i, { key: sanitizeKey(e.target.value) })}
placeholder="예: order_multiple"
/>
</TableCell>
<TableCell className="p-2">
<Select<CustomFieldType>
value={f.type}
onValueChange={(v) => v && update(i, { type: v })}
>
<SelectTrigger className="h-7 text-[11px] w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(CUSTOM_FIELD_TYPE_LABEL) as CustomFieldType[]).map((t) => (
<SelectItem key={t} value={t}>
{CUSTOM_FIELD_TYPE_LABEL[t]}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="p-2">
<OptionsEditor
options={f.options ?? []}
disabled={f.type !== 'select'}
onChange={(opts) => update(i, { options: opts })}
/>
</TableCell>
<TableCell className="p-2 text-center">
<button
type="button"
onClick={() => onChange(fields.filter((_, idx) => idx !== i))}
className="text-red-500 hover:text-red-700 p-1 rounded hover:bg-red-50 cursor-pointer"
title="필드 삭제"
>
<Trash2 size={13} />
</button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex justify-end pt-3">
<Button
variant="outline"
size="sm"
onClick={() => onChange([...fields, { key: '', label: '', type: 'text' }])}
>
<Plus size={13} /> 필드 추가
</Button>
</div>
</SectionCard>
);
}
// 선택형 필드의 보기 목록 — 칩(태그) 방식. 입력 후 Enter/쉼표로 하나씩 추가, X로 제거, 빈 칸에서 Backspace 로 마지막 제거.
function OptionsEditor({
options,
disabled,
onChange,
}: {
options: string[];
disabled?: boolean;
onChange: (opts: string[]) => void;
}) {
const [draft, setDraft] = useState('');
if (disabled) return <span className="text-[11px] text-muted-foreground">유형이 &lsquo;선택&rsquo;일 때 사용</span>;
const add = (raw: string) => {
const v = raw.trim();
setDraft('');
if (v && !options.includes(v)) onChange([...options, v]);
};
return (
<div className="flex min-h-7 flex-wrap items-center gap-1 rounded-md border border-input bg-background px-1.5 py-1">
{options.map((o, idx) => (
<span key={idx} className="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-[11px] font-sans">
{o}
<button
type="button"
onClick={() => onChange(options.filter((_, i) => i !== idx))}
className="text-muted-foreground hover:text-red-500"
title="옵션 삭제"
>
<X size={11} />
</button>
</span>
))}
<input
className="h-5 min-w-[70px] flex-1 bg-transparent text-[11px] font-sans outline-none"
value={draft}
onChange={(e) => (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 ? '추가…' : '예: 협력사배송 (엔터로 추가)'}
/>
</div>
);
}
// key 입력 정리 — 영문/숫자/언더스코어만 허용(소문자화).
function sanitizeKey(raw: string): string {
return raw.toLowerCase().replace(/[^a-z0-9_]/g, '');
}