summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-11-07 06:53:06 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-11-07 06:53:06 +0000
commit121a382b2d3c547f8facce73f57dbdbcf847d879 (patch)
tree46cb96da0302ad15ba8c71829a4e3a62c1bb5db1
parent8daa2aeee017c642d2fd171094cf5d442966eb12 (diff)
(최겸) 구매 정보시스템 user vendorid 변경 기능 개발(신규)
-rw-r--r--app/[lng]/evcp/(evcp)/(system)/change-vendor/change-vendor-client.tsx424
-rw-r--r--config/menuConfig.ts5
-rw-r--r--lib/change-vendor/service.ts258
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) };
+ }
+}
+