- 백엔드: supplier_users/supplier_user_tokens ORM 매핑, /v1/supplier/{id}/account 4종(조회·발급·재설정·상태), 회사 스코프 게이팅, 재설정·비활성 시 토큰 삭제로 기존 로그인 즉시 무효화
- 비밀번호: 발급=서버 자동생성(1회 반환), 재설정=커스텀 지정 가능(미지정 시 자동생성)
- 프론트: 협력사 상세 SupplierAccountManager 섹션 + 목록 '채팅 계정' 컬럼, orval 재생성(SupplierType 은 enumLabels 로컬 상수로 이동)
- 테스트: test_supplier_account.py 9건, 전체 스위트 green
147 lines
6.4 KiB
TypeScript
147 lines
6.4 KiB
TypeScript
import { useState } from 'react';
|
|
import { Plus, Trash2 } from 'lucide-react';
|
|
import { useListItems } from '@/api/generated/item/item';
|
|
import { Typography } from '@/components/ui/typography';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { Combobox, type ComboOption } from '@/components/ui/combobox';
|
|
import { SUPPLIER_TYPE_OPTIONS, SupplierType, supplierTypeLabel } from '@/lib/enumLabels';
|
|
import { showToast } from '@/lib/notify';
|
|
import { useSupplierItems } from '../hooks/useSupplierItems';
|
|
|
|
// 협력사 상세의 취급상품 관리 섹션 — 상품 추가/삭제 + 공급유형(제조/유통/총판/없음) 수정.
|
|
// 각 조작은 즉시 서버 반영(협력사 기본정보 저장과 독립).
|
|
export function SupplierItemsManager({ supplierId }: { supplierId: string }) {
|
|
const { items, isLoading, addItem, changeType, removeItem } = useSupplierItems(supplierId);
|
|
const [q, setQ] = useState('');
|
|
const catalogQuery = useListItems({ search: q || undefined, size: 30 }); // 서버검색(상품 100개 이상도 검색으로 도달)
|
|
|
|
const [pickItemId, setPickItemId] = useState('');
|
|
const [pickLabel, setPickLabel] = useState('');
|
|
const [pickType, setPickType] = useState(String(SupplierType.NONE)); // 기본 없음(0)
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const mappedIds = new Set(items.map((m) => m.item_id));
|
|
const options: ComboOption[] = (catalogQuery.data?.items ?? [])
|
|
.filter((it) => !mappedIds.has(it.item_id))
|
|
.map((it) => ({ id: it.item_id, label: `${it.name}${it.code ? ` [${it.code}]` : ''}` }));
|
|
|
|
const handleAdd = async () => {
|
|
if (!pickItemId) {
|
|
showToast('추가할 상품을 선택하세요.', 'error');
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
try {
|
|
await addItem(pickItemId, Number(pickType));
|
|
setPickItemId('');
|
|
setPickLabel('');
|
|
setQ('');
|
|
showToast('취급상품이 추가되었습니다.', 'success');
|
|
} catch (err) {
|
|
showToast(err instanceof Error ? err.message : '취급상품 추가 실패', 'error');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const handleChangeType = async (supplierItemId: string, v: string) => {
|
|
try {
|
|
await changeType(supplierItemId, Number(v));
|
|
} catch {
|
|
showToast('공급유형 변경에 실패했습니다.', 'error');
|
|
}
|
|
};
|
|
|
|
const handleRemove = async (supplierItemId: string) => {
|
|
try {
|
|
await removeItem(supplierItemId);
|
|
showToast('취급상품이 삭제되었습니다.', 'info');
|
|
} catch {
|
|
showToast('취급상품 삭제에 실패했습니다.', 'error');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="pt-4 border-t border-border space-y-2">
|
|
<Typography as="label" variant="label">취급상품 ({items.length})</Typography>
|
|
|
|
{/* 추가 행 — 상품 + 공급유형 선택 후 추가 */}
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<Combobox
|
|
id="supplier-item-pick"
|
|
options={options}
|
|
loading={catalogQuery.isLoading}
|
|
onQueryChange={setQ}
|
|
value={pickItemId || undefined}
|
|
selectedLabel={pickLabel}
|
|
onSelect={(opt) => { setPickItemId(opt.id); setPickLabel(opt.label); }}
|
|
placeholder="취급상품으로 추가할 상품 검색..."
|
|
searchPlaceholder="상품명·코드로 검색..."
|
|
emptyText="일치하는 상품이 없습니다"
|
|
/>
|
|
</div>
|
|
<div className="w-24 shrink-0">
|
|
<Select value={pickType} onValueChange={(v) => setPickType(v ?? String(SupplierType.NONE))}>
|
|
<SelectTrigger id="supplier-item-pick-type" className="w-full">
|
|
<SelectValue>{(value) => supplierTypeLabel(Number(value))}</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{SUPPLIER_TYPE_OPTIONS.map((o) => (
|
|
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<Button type="button" size="sm" onClick={handleAdd} disabled={busy || !pickItemId}>
|
|
<Plus />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 현재 취급상품 목록 */}
|
|
<div className="border border-border rounded divide-y divide-border max-h-48 overflow-y-auto">
|
|
{isLoading ? (
|
|
<Typography as="p" variant="small" className="p-3 text-muted-foreground text-[11px]">불러오는 중…</Typography>
|
|
) : items.length === 0 ? (
|
|
<Typography as="p" variant="small" className="p-3 text-muted-foreground text-[11px]">등록된 취급상품이 없습니다.</Typography>
|
|
) : (
|
|
items.map((m) => (
|
|
<div key={m.supplier_item_id} className="flex items-center justify-between gap-2 p-2">
|
|
<div className="min-w-0">
|
|
<Typography as="span" variant="small" className="font-semibold block truncate">{m.item_name}</Typography>
|
|
{m.item_code && (
|
|
<Typography as="span" variant="small" className="text-muted-foreground text-[10px]">{m.item_code}</Typography>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
<div className="w-24">
|
|
<Select value={String(m.supply_type)} onValueChange={(v) => handleChangeType(m.supplier_item_id, v ?? String(SupplierType.NONE))}>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue>{(value) => supplierTypeLabel(Number(value))}</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{SUPPLIER_TYPE_OPTIONS.map((o) => (
|
|
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRemove(m.supplier_item_id)}
|
|
title="취급상품 삭제"
|
|
className="p-1 rounded text-muted-foreground hover:text-rose-600 hover:bg-rose-500/10 cursor-pointer"
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|