diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-07 06:53:06 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-07 06:53:06 +0000 |
| commit | 121a382b2d3c547f8facce73f57dbdbcf847d879 (patch) | |
| tree | 46cb96da0302ad15ba8c71829a4e3a62c1bb5db1 | |
| parent | 8daa2aeee017c642d2fd171094cf5d442966eb12 (diff) | |
(최겸) 구매 정보시스템 user vendorid 변경 기능 개발(신규)
| -rw-r--r-- | app/[lng]/evcp/(evcp)/(system)/change-vendor/change-vendor-client.tsx | 424 | ||||
| -rw-r--r-- | config/menuConfig.ts | 5 | ||||
| -rw-r--r-- | lib/change-vendor/service.ts | 258 |
3 files changed, 687 insertions, 0 deletions
diff --git a/app/[lng]/evcp/(evcp)/(system)/change-vendor/change-vendor-client.tsx b/app/[lng]/evcp/(evcp)/(system)/change-vendor/change-vendor-client.tsx new file mode 100644 index 00000000..47776bba --- /dev/null +++ b/app/[lng]/evcp/(evcp)/(system)/change-vendor/change-vendor-client.tsx @@ -0,0 +1,424 @@ +'use client'; + +import * as React from 'react'; +import { useState, useTransition } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Loader2, Search, User, Building2 } from 'lucide-react'; +import { searchUsers, getUserVendorInfo, getVendorList, changeVendor } from '@/lib/change-vendor/service'; +import { toast } from 'sonner'; + +interface User { + id: number; + name: string; + email: string; + companyId: number | null; +} + +interface UserVendorInfo { + userId: number; + userName: string; + userEmail: string; + currentVendorId: number | null; + currentVendorName: string | null; + currentVendorCode: string | null; +} + +interface Vendor { + id: number; + vendorName: string; + vendorCode: string | null; + status: string; +} + +export function ChangeVendorClient() { + const [isPending, startTransition] = useTransition(); + const [isSearchingUsers, setIsSearchingUsers] = React.useState(false); + const [isSearchingVendors, setIsSearchingVendors] = React.useState(false); + const [isLoadingVendorInfo, setIsLoadingVendorInfo] = React.useState(false); + + const [userSearchQuery, setUserSearchQuery] = useState(''); + const [users, setUsers] = useState<User[]>([]); + const [selectedUserId, setSelectedUserId] = useState<number | null>(null); + const [userVendorInfo, setUserVendorInfo] = useState<UserVendorInfo | null>(null); + + const [vendorSearchQuery, setVendorSearchQuery] = useState(''); + const [vendors, setVendors] = useState<Vendor[]>([]); + const [selectedVendorId, setSelectedVendorId] = useState<string>(''); + + const [error, setError] = useState<string | null>(null); + + // 유저 검색 + const handleSearchUsers = async () => { + if (isSearchingUsers) return; + + setIsSearchingUsers(true); + setError(null); + setSelectedUserId(null); + setUserVendorInfo(null); + setSelectedVendorId(''); + + try { + const result = await searchUsers(userSearchQuery); + + if (result.error) { + setError(result.error); + return; + } + + setUsers(result.data || []); + + if (result.data && result.data.length === 0) { + toast.info('검색 결과가 없습니다.'); + } + } catch (err) { + setError('유저 검색 중 오류가 발생했습니다.'); + console.error(err); + } finally { + setIsSearchingUsers(false); + } + }; + + // 유저 검색어 입력 시 엔터키 처리 + const handleUserSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.key === 'Enter') { + handleSearchUsers(); + } + }; + + // 유저 선택 시 해당 유저의 벤더 정보 조회 + const handleUserSelect = async (userId: number) => { + setSelectedUserId(userId); + setUserVendorInfo(null); + setSelectedVendorId(''); + setIsLoadingVendorInfo(true); + setError(null); + + try { + const result = await getUserVendorInfo(userId); + + if (result.error) { + setError(result.error); + return; + } + + setUserVendorInfo(result.data); + + // 벤더 목록도 함께 로드 + await loadVendors(); + } catch (err) { + setError('유저 정보를 불러오는 중 오류가 발생했습니다.'); + console.error(err); + } finally { + setIsLoadingVendorInfo(false); + } + }; + + // 벤더 목록 로드 + const loadVendors = async () => { + setIsSearchingVendors(true); + try { + const result = await getVendorList(vendorSearchQuery); + + if (result.error) { + setError(result.error); + return; + } + + setVendors(result.data || []); + } catch (err) { + setError('벤더 목록을 불러오는 중 오류가 발생했습니다.'); + console.error(err); + } finally { + setIsSearchingVendors(false); + } + }; + + // 벤더 검색 + const handleSearchVendors = async () => { + if (isSearchingVendors) return; + + setIsSearchingVendors(true); + setError(null); + setSelectedVendorId(''); + + try { + const result = await getVendorList(vendorSearchQuery); + + if (result.error) { + setError(result.error); + return; + } + + setVendors(result.data || []); + + if (result.data && result.data.length === 0) { + toast.info('검색 결과가 없습니다.'); + } + } catch (err) { + setError('벤더 검색 중 오류가 발생했습니다.'); + console.error(err); + } finally { + setIsSearchingVendors(false); + } + }; + + // 벤더 검색어 입력 시 엔터키 처리 + const handleVendorSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.key === 'Enter') { + handleSearchVendors(); + } + }; + + // 벤더 변경 처리 + const handleChangeVendor = () => { + if (!selectedUserId) { + toast.error('유저를 선택해주세요.'); + return; + } + + if (!selectedVendorId) { + toast.error('변경할 벤더를 선택해주세요.'); + return; + } + + const vendorId = Number(selectedVendorId); + + if (userVendorInfo?.currentVendorId === vendorId) { + toast.error('현재 선택된 벤더와 동일합니다.'); + return; + } + + startTransition(async () => { + try { + const result = await changeVendor(selectedUserId, vendorId); + + if (result.error || !result.success) { + toast.error(result.error || '벤더 변경에 실패했습니다.'); + return; + } + + toast.success(`"${result.data?.userName}"님의 벤더가 "${result.data?.vendorName}"로 변경되었습니다.`); + + // 유저 정보 새로고침 + await handleUserSelect(selectedUserId); + } catch (err) { + toast.error('벤더 변경 중 오류가 발생했습니다.'); + console.error(err); + } + }); + }; + + return ( + <div className="space-y-6"> + {/* 1단계: 유저 검색 및 선택 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <User className="h-5 w-5" /> + 유저 검색 및 선택 + </CardTitle> + <CardDescription> + 벤더를 변경할 유저를 검색하고 선택하세요. + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + {/* 유저 검색 */} + <div className="flex gap-2"> + <div className="flex-1"> + <Input + placeholder="유저명 또는 이메일로 검색..." + value={userSearchQuery} + onChange={(e) => setUserSearchQuery(e.target.value)} + onKeyDown={handleUserSearchKeyDown} + disabled={isSearchingUsers} + /> + </div> + <Button + type="button" + variant="outline" + onClick={handleSearchUsers} + disabled={isSearchingUsers} + > + {isSearchingUsers ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : ( + <Search className="h-4 w-4" /> + )} + <span className="ml-2">검색</span> + </Button> + </div> + + {/* 유저 선택 */} + {users.length > 0 && ( + <div className="grid gap-2"> + <Label>유저 선택</Label> + <Select + value={selectedUserId?.toString() || ''} + onValueChange={(value) => handleUserSelect(Number(value))} + disabled={isLoadingVendorInfo} + > + <SelectTrigger> + <SelectValue placeholder="유저를 선택하세요" /> + </SelectTrigger> + <SelectContent> + {users.map((user) => ( + <SelectItem key={user.id} value={user.id.toString()}> + <div className="flex flex-col"> + <span>{user.name}</span> + <span className="text-xs text-muted-foreground">{user.email}</span> + </div> + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + )} + + {isLoadingVendorInfo && ( + <div className="flex items-center justify-center py-4"> + <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> + </div> + )} + + {/* 선택한 유저의 현재 벤더 정보 */} + {userVendorInfo && ( + <div className="rounded-lg border p-4 bg-muted/50"> + <div className="grid gap-2"> + <div className="flex items-center gap-2"> + <User className="h-4 w-4 text-muted-foreground" /> + <Label className="text-sm font-medium">선택한 유저</Label> + </div> + <div className="text-sm"> + <div className="font-semibold">{userVendorInfo.userName}</div> + <div className="text-muted-foreground">{userVendorInfo.userEmail}</div> + </div> + <div className="mt-2 pt-2 border-t"> + <div className="flex items-center gap-2 mb-1"> + <Building2 className="h-4 w-4 text-muted-foreground" /> + <Label className="text-sm font-medium">현재 벤더</Label> + </div> + <div className="text-sm font-semibold"> + {userVendorInfo.currentVendorName || '벤더 정보 없음'} + {userVendorInfo.currentVendorCode && ( + <span className="text-muted-foreground ml-2"> + ({userVendorInfo.currentVendorCode}) + </span> + )} + </div> + </div> + </div> + </div> + )} + </CardContent> + </Card> + + {/* 2단계: 벤더 검색 및 선택 (유저 선택 후에만 표시) */} + {selectedUserId && userVendorInfo && ( + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Building2 className="h-5 w-5" /> + 벤더 변경 + </CardTitle> + <CardDescription> + 변경할 벤더를 검색하고 선택하세요. + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + {/* 벤더 검색 */} + <div className="flex gap-2"> + <div className="flex-1"> + <Input + placeholder="벤더명 또는 벤더코드로 검색..." + value={vendorSearchQuery} + onChange={(e) => setVendorSearchQuery(e.target.value)} + onKeyDown={handleVendorSearchKeyDown} + disabled={isSearchingVendors} + /> + </div> + <Button + type="button" + variant="outline" + onClick={handleSearchVendors} + disabled={isSearchingVendors} + > + {isSearchingVendors ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : ( + <Search className="h-4 w-4" /> + )} + <span className="ml-2">검색</span> + </Button> + </div> + + {/* 벤더 선택 */} + {vendors.length > 0 && ( + <div className="grid gap-2"> + <Label htmlFor="vendor-select">변경할 벤더 선택</Label> + <Select + value={selectedVendorId} + onValueChange={setSelectedVendorId} + disabled={isPending} + > + <SelectTrigger id="vendor-select"> + <SelectValue placeholder="벤더를 선택하세요" /> + </SelectTrigger> + <SelectContent> + {vendors.map((vendor) => ( + <SelectItem key={vendor.id} value={vendor.id.toString()}> + <div className="flex flex-col"> + <span>{vendor.vendorName}</span> + {vendor.vendorCode && ( + <span className="text-xs text-muted-foreground"> + {vendor.vendorCode} + </span> + )} + </div> + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + )} + + {error && ( + <Alert variant="destructive"> + <AlertDescription>{error}</AlertDescription> + </Alert> + )} + + {/* 저장 버튼 */} + <Button + onClick={handleChangeVendor} + disabled={isPending || !selectedVendorId} + className="w-full" + > + {isPending ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 변경 중... + </> + ) : ( + <> + <Building2 className="mr-2 h-4 w-4" /> + 벤더 변경 + </> + )} + </Button> + </CardContent> + </Card> + )} + </div> + ); +} diff --git a/config/menuConfig.ts b/config/menuConfig.ts index 948ef20e..e24a283b 100644 --- a/config/menuConfig.ts +++ b/config/menuConfig.ts @@ -511,6 +511,11 @@ export const mainNav: MenuSection[] = [ href: '/evcp/page-visits', groupKey: 'groups.access_history', }, + { + titleKey: 'menu.information_system.change_vendor', + href: '/evcp/change-vendor', + groupKey: 'groups.menu', + }, ], }, ]; diff --git a/lib/change-vendor/service.ts b/lib/change-vendor/service.ts new file mode 100644 index 00000000..09c93a26 --- /dev/null +++ b/lib/change-vendor/service.ts @@ -0,0 +1,258 @@ +'use server'; + +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import db from '@/db/db'; +import { users, vendors } from '@/db/schema'; +import { and, eq, ilike, or, asc } from 'drizzle-orm'; +import { revalidatePath } from 'next/cache'; +import { getErrorMessage } from '@/lib/handle-error'; +import { unstable_noStore } from 'next/cache'; + +/** + * 유저 목록 검색 (limit 100) + */ +export async function searchUsers(search?: string) { + unstable_noStore(); + + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return { data: null, error: '로그인이 필요합니다.' }; + } + + let whereCondition; + + // 검색어가 있으면 이름 또는 이메일로 검색 + if (search && search.trim()) { + const searchPattern = `%${search.trim()}%`; + whereCondition = or( + ilike(users.name, searchPattern), + ilike(users.email, searchPattern) + ); + } + + const usersList = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + companyId: users.companyId, + }) + .from(users) + .where(whereCondition) + .orderBy(asc(users.name)) + .limit(100); + + return { + data: usersList, + error: null, + }; + } catch (error) { + return { data: null, error: getErrorMessage(error) }; + } +} + +/** + * 특정 유저의 벤더 정보 조회 + */ +export async function getUserVendorInfo(userId: number) { + unstable_noStore(); + + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return { data: null, error: '로그인이 필요합니다.' }; + } + + // 사용자 정보 조회 + const user = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + companyId: users.companyId, + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!user[0]) { + return { data: null, error: '사용자를 찾을 수 없습니다.' }; + } + + // 현재 벤더 정보 조회 (companyId가 있는 경우) + let vendor = null; + if (user[0].companyId) { + const vendorResult = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + }) + .from(vendors) + .where(eq(vendors.id, user[0].companyId)) + .limit(1); + + vendor = vendorResult[0] || null; + } + + return { + data: { + userId: user[0].id, + userName: user[0].name, + userEmail: user[0].email, + currentVendorId: user[0].companyId, + currentVendorName: vendor?.vendorName || null, + currentVendorCode: vendor?.vendorCode || null, + }, + error: null, + }; + } catch (error) { + return { data: null, error: getErrorMessage(error) }; + } +} + +/** + * 벤더 목록 조회 (활성 상태만, 검색 기능 포함, limit 100) + */ +export async function getVendorList(search?: string) { + unstable_noStore(); + + try { + const statusCondition = eq(vendors.status, 'ACTIVE'); + + // 검색어가 있으면 벤더명 또는 벤더코드로 검색 + if (search && search.trim()) { + const searchPattern = `%${search.trim()}%`; + const searchConditions = [ + ilike(vendors.vendorName, searchPattern), + ilike(vendors.vendorCode, searchPattern), + ]; + + const whereCondition = and( + statusCondition, + or(...searchConditions) + ); + + const vendorsList = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + status: vendors.status, + }) + .from(vendors) + .where(whereCondition) + .orderBy(asc(vendors.vendorName)) + .limit(100); + + return { + data: vendorsList, + error: null, + }; + } + + // 검색어가 없으면 활성 상태만 조회 + const whereCondition = statusCondition; + + const vendorsList = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + status: vendors.status, + }) + .from(vendors) + .where(whereCondition) + .orderBy(asc(vendors.vendorName)) + .limit(100); + + return { + data: vendorsList, + error: null, + }; + } catch (error) { + return { data: null, error: getErrorMessage(error) }; + } +} + +/** + * 벤더 변경 서버 액션 (특정 유저의 벤더 변경) + */ +export async function changeVendor(userId: number, newVendorId: number) { + unstable_noStore(); + + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return { success: false, error: '로그인이 필요합니다.' }; + } + + // 대상 사용자 확인 + const targetUser = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!targetUser[0]) { + return { success: false, error: '사용자를 찾을 수 없습니다.' }; + } + + // 새 벤더 정보 확인 + const newVendor = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + status: vendors.status, + }) + .from(vendors) + .where(eq(vendors.id, newVendorId)) + .limit(1); + + if (!newVendor[0]) { + return { success: false, error: '벤더를 찾을 수 없습니다.' }; + } + + if (newVendor[0].status !== 'ACTIVE') { + return { success: false, error: '활성 상태인 벤더만 선택할 수 있습니다.' }; + } + + // 사용자의 companyId 업데이트 + await db + .update(users) + .set({ + companyId: newVendorId, + updatedAt: new Date(), + }) + .where(eq(users.id, userId)); + + // 세션 재검증을 위해 경로 재검증 + revalidatePath('/', 'layout'); + + return { + success: true, + data: { + userId: targetUser[0].id, + userName: targetUser[0].name, + userEmail: targetUser[0].email, + vendorId: newVendor[0].id, + vendorName: newVendor[0].vendorName, + vendorCode: newVendor[0].vendorCode, + }, + error: null, + }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } +} + |
