o2o-negosium-original/negodata/front/src/features/partners/components/SupplierItemsManager.tsx
Mina Choi 7f1c0be2b3 [feat] negodata: 협력사 채팅 계정(supplier_users) 관리 — 발급·비번 재설정·활성상태
- 백엔드: supplier_users/supplier_user_tokens ORM 매핑, /v1/supplier/{id}/account 4종(조회·발급·재설정·상태), 회사 스코프 게이팅, 재설정·비활성 시 토큰 삭제로 기존 로그인 즉시 무효화
- 비밀번호: 발급=서버 자동생성(1회 반환), 재설정=커스텀 지정 가능(미지정 시 자동생성)
- 프론트: 협력사 상세 SupplierAccountManager 섹션 + 목록 '채팅 계정' 컬럼, orval 재생성(SupplierType 은 enumLabels 로컬 상수로 이동)
- 테스트: test_supplier_account.py 9건, 전체 스위트 green
2026-07-10 10:14:00 +09:00

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>
);
}