diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-04-08 03:08:19 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-04-08 03:08:19 +0000 |
| commit | 9ceed79cf32c896f8a998399bf1b296506b2cd4a (patch) | |
| tree | f84750fa6cac954d5e31221fc47a54c655fc06a9 /components | |
| parent | 230ce796836c25df26c130dbcd616ef97d12b2ec (diff) | |
로그인 및 미들웨어 처리. 구조 변경
Diffstat (limited to 'components')
| -rw-r--r-- | components/layout/GroupedMenuRender.tsx | 80 | ||||
| -rw-r--r-- | components/layout/Header.tsx | 215 | ||||
| -rw-r--r-- | components/layout/MobileMenu.tsx | 8 | ||||
| -rw-r--r-- | components/login/login-form-shi.tsx | 318 | ||||
| -rw-r--r-- | components/login/login-form.tsx | 201 | ||||
| -rw-r--r-- | components/pq/client-pq-input-wrapper.tsx | 90 | ||||
| -rw-r--r-- | components/pq/pq-review-detail.tsx | 9 |
7 files changed, 773 insertions, 148 deletions
diff --git a/components/layout/GroupedMenuRender.tsx b/components/layout/GroupedMenuRender.tsx new file mode 100644 index 00000000..e2a5a225 --- /dev/null +++ b/components/layout/GroupedMenuRender.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import Link from 'next/link'; +import { NavigationMenuLink } from "@/components/ui/navigation-menu"; +import { cn } from "@/lib/utils"; +import * as LucideIcons from "lucide-react"; +import { MenuItem } from '@/config/menuConfig'; + +type GroupedMenuItems = { + [key: string]: MenuItem[]; +}; + +interface GroupedMenuRendererProps { + items: MenuItem[]; + lng: string; +} + +const GroupedMenuRenderer = ({ items, lng }: GroupedMenuRendererProps) => { + // 그룹별로 아이템 분류 + const groupItems = (items: MenuItem[]): GroupedMenuItems => { + return items.reduce((groups, item) => { + const group = item.group || 'default'; + if (!groups[group]) { + groups[group] = []; + } + groups[group].push(item); + return groups; + }, {} as GroupedMenuItems); + }; + + const groupedItems = groupItems(items); + const groups = Object.keys(groupedItems); + + return ( + <div className="p-4 w-[600px]"> + {groups.map((groupName, index) => ( + <div key={groupName} className={cn("mb-4", index < groups.length - 1 && "pb-2 border-b border-border/30")}> + {groupName !== 'default' && ( + <h3 className="text-sm font-semibold mb-2 text-primary">{groupName}</h3> + )} + <div className="grid grid-cols-2 gap-3"> + {groupedItems[groupName].map((item) => ( + <MenuListItem key={item.title} item={item} lng={lng} /> + ))} + </div> + </div> + ))} + </div> + ); +}; + +const MenuListItem = ({ item, lng }: { item: MenuItem; lng: string }) => { + + + + + return ( + <NavigationMenuLink asChild> + <Link + href={`/${lng}${item.href}`} + className={cn( + "flex items-start space-x-2 rounded-md p-3 leading-none no-underline outline-none transition-colors", + "hover:bg-accent hover:text-accent-foreground", + "focus:bg-accent focus:text-accent-foreground", + item.disabled && "pointer-events-none opacity-60" + )} + > + <div className="space-y-1"> + <div className="text-sm font-medium leading-none">{item.title}</div> + {item.description && ( + <p className="line-clamp-2 text-xs leading-snug text-muted-foreground"> + {item.description} + </p> + )} + </div> + </Link> + </NavigationMenuLink> + ); +}; + +export default GroupedMenuRenderer;
\ No newline at end of file diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx index 1b6c45bb..498668eb 100644 --- a/components/layout/Header.tsx +++ b/components/layout/Header.tsx @@ -26,19 +26,20 @@ import { SearchIcon, BellIcon, Menu } from "lucide-react"; import { useParams, usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; import Image from "next/image"; -import { mainNav, additionalNav, MenuSection, mainNavVendor, additionalNavVendor } from "@/config/menuConfig"; // 메뉴 구성 임포트 +import { mainNav, additionalNav, MenuSection, MenuItem, mainNavVendor, additionalNavVendor } from "@/config/menuConfig"; // 메뉴 구성 임포트 import { MobileMenu } from "./MobileMenu"; import { CommandMenu } from "./command-menu"; import { useSession, signOut } from "next-auth/react"; - +import GroupedMenuRenderer from "./GroupedMenuRender"; export function Header() { const params = useParams(); - const lng = params.lng as string; + const lng = params?.lng as string; const pathname = usePathname(); const { data: session } = useSession(); - const userName = session?.user?.name || ""; // 없을 수도 있으니 안전하게 처리 + const userName = session?.user?.name || ""; + const domain = session?.user?.domain || ""; const initials = userName .split(" ") .map((word) => word[0]?.toUpperCase()) @@ -51,7 +52,7 @@ export function Header() { setIsMobileMenuOpen(!isMobileMenuOpen); }; - const isPartnerRoute = pathname.includes("/partners"); + const isPartnerRoute = pathname?.includes("/partners"); const main = isPartnerRoute ? mainNavVendor : mainNav; const additional = isPartnerRoute ? additionalNavVendor : additionalNav; @@ -60,10 +61,10 @@ export function Header() { return ( <> - <header className="border-grid sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> + <header className="border-grid sticky top-0 z-40 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <div className="container-wrapper"> <div className="container flex h-14 items-center"> - + {/* 햄버거 메뉴 버튼 (모바일) */} <Button onClick={toggleMobileMenu} variant="ghost" @@ -86,105 +87,115 @@ export function Header() { <span className="sr-only">Toggle Menu</span> </Button> - - <div className="mr-4 hidden md:flex"> - - {/* 로고 영역 */} - <div className="mr-4 flex items-center gap-2 lg:mr-6"> - <Link href={`/${lng}/evcp`} className="flex items-center gap-2"> - <Image - className="dark:invert" - src="/images/vercel.svg" - alt="EVCP Logo" - width={20} - height={20} - /> - <span className="hidden font-bold lg:inline-block">eVCP</span> - </Link> - </div> - {/* 데스크탑 네비게이션 메뉴 */} - <NavigationMenu className="flex items-center gap-4 text-sm xl:gap-6"> - <NavigationMenuList> - {main.map((section: MenuSection) => ( - <NavigationMenuItem key={section.title}> - <NavigationMenuTrigger>{section.title}</NavigationMenuTrigger> - <NavigationMenuContent> - <ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px] "> - {section.items.map((item) => ( - <ListItem - key={item.title} - title={item.title} - href={`/${lng}${item.href}`} - > - {item.description} - </ListItem> - ))} - </ul> - </NavigationMenuContent> - </NavigationMenuItem> - ))} - - - {/* 추가 네비게이션 항목 */} - {additional.map((item) => ( - <NavigationMenuItem key={item.title}> - <Link href={`/${lng}${item.href}`} legacyBehavior passHref> - <NavigationMenuLink className={navigationMenuTriggerStyle()}> - {item.title} - </NavigationMenuLink> - </Link> - </NavigationMenuItem> - ))} - </NavigationMenuList> + {/* 로고 영역 - 항상 표시 */} + <div className="mr-4 flex-shrink-0 flex items-center gap-2 lg:mr-6"> + <Link href={`/${lng}/evcp`} className="flex items-center gap-2"> + <Image + className="dark:invert" + src="/images/vercel.svg" + alt="EVCP Logo" + width={20} + height={20} + /> + <span className="hidden font-bold lg:inline-block">eVCP</span> + </Link> + </div> + + {/* 네비게이션 메뉴 - 내부 스크롤 적용, 드롭다운은 제약 없이 표시 */} + <div className="hidden md:block flex-1 min-w-0"> + {/* NavigationMenu는 z-index를 높게 설정하여 드롭다운이 제대로 표시되도록 함 */} + <NavigationMenu className="relative z-50"> + {/* 스크롤 가능한 메뉴 리스트 컨테이너 */} + <div className="w-full overflow-x-auto pb-1"> + <NavigationMenuList className="flex-nowrap w-max"> + {main.map((section: MenuSection) => ( + <NavigationMenuItem key={section.title}> + <NavigationMenuTrigger className="px-2 xl:px-3 text-sm whitespace-nowrap"> + {section.title} + </NavigationMenuTrigger> + + {/* 그룹핑이 필요한 메뉴의 경우 GroupedMenuRenderer 사용 */} + {section.useGrouping ? ( + <NavigationMenuContent> + <GroupedMenuRenderer items={section.items} lng={lng} /> + </NavigationMenuContent> + ) : ( + <NavigationMenuContent> + <ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px] "> + {section.items.map((item) => ( + <ListItem + key={item.title} + title={item.title} + href={`/${lng}${item.href}`} + > + {item.description} + </ListItem> + ))} + </ul> + </NavigationMenuContent> + )} + </NavigationMenuItem> + ))} + + {/* 추가 네비게이션 항목 */} + {additional.map((item) => ( + <NavigationMenuItem key={item.title}> + <Link href={`/${lng}${item.href}`} legacyBehavior passHref> + <NavigationMenuLink + className={cn( + navigationMenuTriggerStyle(), + "px-2 xl:px-3 text-sm whitespace-nowrap" + )} + > + {item.title} + </NavigationMenuLink> + </Link> + </NavigationMenuItem> + ))} + </NavigationMenuList> + </div> </NavigationMenu> - - </div> - - {/* 우측 영역 */} - <div className="flex flex-1 items-center justify-between gap-2 md:justify-end"> - - <CommandMenu /> - - - <div className="flex items-center space-x-4"> - {/* 알림 버튼 */} - <Button variant="ghost" className="relative p-2" aria-label="Notifications"> - <BellIcon className="h-5 w-5" /> - {/* 알림 뱃지 예시 */} - <span className="absolute -top-1 -right-1 inline-flex h-2 w-2 rounded-full bg-red-500"></span> - </Button> - - {/* 사용자 메뉴 (DropdownMenu) */} - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Avatar className="cursor-pointer"> - <AvatarImage src={`/profiles/${session?.user?.image}`||"/user-avatar.jpg"} alt="User Avatar" /> - <AvatarFallback> - {initials || "?"} - </AvatarFallback> - </Avatar> - </DropdownMenuTrigger> - <DropdownMenuContent className="w-48" align="end"> - <DropdownMenuLabel>My Account</DropdownMenuLabel> - <DropdownMenuSeparator /> - {/* <DropdownMenuItem asChild> - <Link href={`${basePath}/profile`}>Profile</Link> - </DropdownMenuItem> */} - <DropdownMenuItem asChild> - <Link href={`${basePath}/settings`}>Settings</Link> - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownMenuItem onSelect={() => signOut({ callbackUrl: `/${lng}/login` })}> - Logout - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - - {/* 모바일 햄버거 메뉴 버튼 */} - + {/* 우측 영역 - 고정 너비와 우선순위로 항상 표시되도록 함 */} + <div className="ml-auto flex flex-shrink-0 items-center space-x-2"> + {/* 데스크탑에서는 CommandMenu, 모바일에서는 검색 아이콘만 */} + <div className="hidden md:block md:w-auto"> + <CommandMenu /> </div> + <Button variant="ghost" size="icon" className="md:hidden" aria-label="Search"> + <SearchIcon className="h-5 w-5" /> + </Button> + + {/* 알림 버튼 */} + <Button variant="ghost" size="icon" className="relative" aria-label="Notifications"> + <BellIcon className="h-5 w-5" /> + {/* 알림 뱃지 예시 */} + <span className="absolute -top-1 -right-1 inline-flex h-2 w-2 rounded-full bg-red-500"></span> + </Button> + + {/* 사용자 메뉴 (DropdownMenu) */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Avatar className="cursor-pointer h-8 w-8"> + <AvatarImage src={`/profiles/${session?.user?.image}`||"/user-avatar.jpg"} alt="User Avatar" /> + <AvatarFallback> + {initials || "?"} + </AvatarFallback> + </Avatar> + </DropdownMenuTrigger> + <DropdownMenuContent className="w-48" align="end"> + <DropdownMenuLabel>My Account</DropdownMenuLabel> + <DropdownMenuSeparator /> + <DropdownMenuItem asChild> + <Link href={`${basePath}/settings`}>Settings</Link> + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem onSelect={() => signOut({ callbackUrl: `/${lng}/${domain}` })}> + Logout + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> </div> </div> </div> diff --git a/components/layout/MobileMenu.tsx b/components/layout/MobileMenu.tsx index d2e6b927..2e70aeba 100644 --- a/components/layout/MobileMenu.tsx +++ b/components/layout/MobileMenu.tsx @@ -4,10 +4,10 @@ import * as React from "react"; import Link from "next/link"; -import { useRouter,usePathname } from "next/navigation"; +import { useRouter, usePathname } from "next/navigation"; import { MenuSection, mainNav, additionalNav, MenuItem, mainNavVendor, additionalNavVendor } from "@/config/menuConfig"; import { cn } from "@/lib/utils"; -import { Drawer, DrawerContent,DrawerTitle,DrawerTrigger } from "@/components/ui/drawer"; +import { Drawer, DrawerContent, DrawerTitle, DrawerTrigger } from "@/components/ui/drawer"; import { Button } from "@/components/ui/button"; interface MobileMenuProps { @@ -24,14 +24,14 @@ export function MobileMenu({ lng, onClose }: MobileMenuProps) { onClose(); }; const pathname = usePathname(); - const isPartnerRoute = pathname.includes("/partners"); + const isPartnerRoute = pathname?.includes("/partners"); const main = isPartnerRoute ? mainNavVendor : mainNav; const additional = isPartnerRoute ? additionalNavVendor : additionalNav; return ( <Drawer open={true} onOpenChange={onClose}> - <DrawerTrigger asChild> + <DrawerTrigger asChild> </DrawerTrigger> <DrawerTitle /> <DrawerContent className="max-h-[60vh] p-0"> diff --git a/components/login/login-form-shi.tsx b/components/login/login-form-shi.tsx new file mode 100644 index 00000000..fb985592 --- /dev/null +++ b/components/login/login-form-shi.tsx @@ -0,0 +1,318 @@ +'use client'; + +import { useState, useEffect } from "react"; +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { SendIcon, Loader2, GlobeIcon, ChevronDownIcon, Ship } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem } from "@/components/ui/dropdown-menu" +import { useTranslation } from '@/i18n/client' +import { useRouter, useParams, usePathname, useSearchParams } from 'next/navigation'; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/ui/input-otp" +import { signIn } from 'next-auth/react'; +import { sendOtpAction } from "@/lib/users/send-otp"; +import { verifyTokenAction } from "@/lib/users/verifyToken"; +import { buttonVariants } from "@/components/ui/button" +import Link from "next/link" +import Image from 'next/image'; // 추가: Image 컴포넌트 import + +export function LoginFormSHI({ + className, + ...props +}: React.ComponentProps<"div">) { + + const params = useParams() || {}; + const pathname = usePathname() || ''; + const router = useRouter(); + const searchParams = useSearchParams(); + const token = searchParams?.get('token') || null; + + + const lng = params.lng as string; + const { t, i18n } = useTranslation(lng, 'login'); + + const { toast } = useToast(); + + const handleChangeLanguage = (lang: string) => { + const segments = pathname.split('/'); + segments[1] = lang; + router.push(segments.join('/')); + }; + + const currentLanguageText = i18n.language === 'ko' ? t('languages.korean') : t('languages.english'); + + const [email, setEmail] = useState(''); + const [otpSent, setOtpSent] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [otp, setOtp] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + try { + const result = await sendOtpAction(email, lng); + + if (result.success) { + setOtpSent(true); + toast({ + title: t('otpSentTitle'), + description: t('otpSentMessage'), + }); + } else { + // Handle specific error types + let errorMessage = t('defaultErrorMessage'); + + // You can handle different error types differently + if (result.error === 'userNotFound') { + errorMessage = t('userNotFoundMessage'); + } + + toast({ + title: t('errorTitle'), + description: result.message || errorMessage, + variant: 'destructive', + }); + } + } catch (error) { + // This will catch network errors or other unexpected issues + console.error(error); + toast({ + title: t('errorTitle'), + description: t('networkErrorMessage'), + variant: 'destructive', + }); + } finally { + setIsLoading(false); + } + }; + + async function handleOtpSubmit(e: React.FormEvent) { + e.preventDefault(); + setIsLoading(true); + + try { + // next-auth의 Credentials Provider로 로그인 시도 + const result = await signIn('credentials', { + email, + code: otp, + redirect: false, // 커스텀 처리 위해 redirect: false + }); + + if (result?.ok) { + // 토스트 메시지 표시 + toast({ + title: t('loginSuccess'), + description: t('youAreLoggedIn'), + }); + + router.push(`/${lng}/evcp/report`); + + } else { + toast({ + title: t('errorTitle'), + description: t('defaultErrorMessage'), + variant: 'destructive', + }); + } + } catch (error) { + console.error('Login error:', error); + toast({ + title: t('errorTitle'), + description: t('defaultErrorMessage'), + variant: 'destructive', + }); + } finally { + setIsLoading(false); + } + } + + useEffect(() => { + const verifyToken = async () => { + if (!token) return; + setIsLoading(true); + + try { + const data = await verifyTokenAction(token); + + if (data.valid) { + setOtpSent(true); + setEmail(data.email ?? ''); + } else { + toast({ + title: t('errorTitle'), + description: t('invalidToken'), + variant: 'destructive', + }); + } + } catch (error) { + toast({ + title: t('errorTitle'), + description: t('defaultErrorMessage'), + variant: 'destructive', + }); + } finally { + setIsLoading(false); + } + }; + verifyToken(); + }, [token, toast, t]); + + return ( + <div className="container relative flex h-screen flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0"> + {/* Left Content */} + <div className="flex flex-col w-full h-screen lg:p-2"> + {/* Top bar with Logo + eVCP (left) and "Request Vendor Repository" (right) */} + <div className="flex items-center justify-between"> + <div className="flex items-center space-x-2"> + {/* <img + src="/images/logo.png" + alt="logo" + className="h-8 w-auto" + /> */} + <Ship className="w-4 h-4" /> + <span className="text-md font-bold">eVCP</span> + </div> + </div> + + {/* Content section that occupies remaining space, centered vertically */} + <div className="flex-1 flex items-center justify-center"> + {/* Your form container */} + <div className="mx-auto w-full flex flex-col space-y-6 sm:w-[350px]"> + + {/* Here's your existing login/OTP forms: */} + {!otpSent ? ( + <form onSubmit={handleSubmit} className="p-6 md:p-8"> + <div className="flex flex-col gap-6"> + <div className="flex flex-col items-center text-center"> + <h1 className="text-2xl font-bold">{t('loginMessage')}</h1> + </div> + <div className="grid gap-2"> + <Input + id="email" + type="email" + placeholder="test@samsung.com" + required + className="h-10" + value={email} + onChange={(e) => setEmail(e.target.value)} + /> + </div> + <Button type="submit" className="w-full" variant="samsung" disabled={isLoading}> + {isLoading ? t('sending') : t('ContinueWithEmail')} + </Button> + <div className="text-center text-sm mx-auto"> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="flex items-center gap-2"> + <GlobeIcon className="h-4 w-4" /> + <span>{currentLanguageText}</span> + <ChevronDownIcon className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuRadioGroup + value={i18n.language} + onValueChange={(value) => handleChangeLanguage(value)} + > + <DropdownMenuRadioItem value="en"> + {t('languages.english')} + </DropdownMenuRadioItem> + <DropdownMenuRadioItem value="ko"> + {t('languages.korean')} + </DropdownMenuRadioItem> + </DropdownMenuRadioGroup> + </DropdownMenuContent> + </DropdownMenu> + </div> + </div> + </form> + ) : ( + <form onSubmit={handleOtpSubmit} className="flex flex-col gap-4 p-6 md:p-8"> + <div className="flex flex-col gap-6"> + <div className="flex flex-col items-center text-center"> + <h1 className="text-2xl font-bold">{t('loginMessage')}</h1> + </div> + <div className="grid gap-2 justify-center"> + <InputOTP + maxLength={6} + value={otp} + onChange={(value) => setOtp(value)} + > + <InputOTPGroup> + <InputOTPSlot index={0} /> + <InputOTPSlot index={1} /> + <InputOTPSlot index={2} /> + <InputOTPSlot index={3} /> + <InputOTPSlot index={4} /> + <InputOTPSlot index={5} /> + </InputOTPGroup> + </InputOTP> + </div> + <Button type="submit" className="w-full" disabled={isLoading}> + {isLoading ? t('verifying') : t('verifyOtp')} + </Button> + <div className="mx-auto"> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="flex items-center gap-2"> + <GlobeIcon className="h-4 w-4" /> + <span>{currentLanguageText}</span> + <ChevronDownIcon className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuRadioGroup + value={i18n.language} + onValueChange={(value) => handleChangeLanguage(value)} + > + <DropdownMenuRadioItem value="en"> + {t('languages.english')} + </DropdownMenuRadioItem> + <DropdownMenuRadioItem value="ko"> + {t('languages.korean')} + </DropdownMenuRadioItem> + </DropdownMenuRadioGroup> + </DropdownMenuContent> + </DropdownMenu> + </div> + </div> + </form> + )} + + <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary"> + {t('termsMessage')} <a href="#">{t('termsOfService')}</a> {t('and')} + <a href="#">{t('privacyPolicy')}</a>. + </div> + </div> + </div> + </div> + + {/* Right BG 이미지 영역 - Image 컴포넌트로 수정 */} + <div className="relative hidden h-full flex-col p-10 text-white dark:border-r md:flex"> + {/* Image 컴포넌트로 대체 */} + <div className="absolute inset-0"> + <Image + src="/images/02.jpg" + alt="Background image" + fill + priority + sizes="(max-width: 1024px) 100vw, 50vw" + className="object-cover" + /> + </div> + <div className="relative z-10 mt-auto"> + <blockquote className="space-y-2"> + <p className="text-sm">“{t("blockquote")}”</p> + {/* <footer className="text-sm">SHI</footer> */} + </blockquote> + </div> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/components/login/login-form.tsx b/components/login/login-form.tsx index 41d232f8..92fa6e2c 100644 --- a/components/login/login-form.tsx +++ b/components/login/login-form.tsx @@ -32,7 +32,8 @@ export function LoginForm({ const router = useRouter(); const searchParams = useSearchParams(); const token = searchParams?.get('token') || null; - + const [showCredentialsForm, setShowCredentialsForm] = useState(false); + const lng = params.lng as string; const { t, i18n } = useTranslation(lng, 'login'); @@ -51,6 +52,8 @@ export function LoginForm({ const [otpSent, setOtpSent] = useState(false); const [isLoading, setIsLoading] = useState(false); const [otp, setOtp] = useState(''); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -58,17 +61,33 @@ export function LoginForm({ try { const result = await sendOtpAction(email, lng); - if (result?.success) { + if (result.success) { setOtpSent(true); toast({ title: t('otpSentTitle'), description: t('otpSentMessage'), }); + } else { + // Handle specific error types + let errorMessage = t('defaultErrorMessage'); + + // You can handle different error types differently + if (result.error === 'userNotFound') { + errorMessage = t('userNotFoundMessage'); + } + + toast({ + title: t('errorTitle'), + description: result.message || errorMessage, + variant: 'destructive', + }); } } catch (error) { + // This will catch network errors or other unexpected issues + console.error(error); toast({ title: t('errorTitle'), - description: t('defaultErrorMessage'), + description: t('networkErrorMessage'), variant: 'destructive', }); } finally { @@ -76,11 +95,10 @@ export function LoginForm({ } }; - async function handleOtpSubmit(e: React.FormEvent) { e.preventDefault(); setIsLoading(true); - + try { // next-auth의 Credentials Provider로 로그인 시도 const result = await signIn('credentials', { @@ -88,30 +106,16 @@ export function LoginForm({ code: otp, redirect: false, // 커스텀 처리 위해 redirect: false }); - + if (result?.ok) { // 토스트 메시지 표시 toast({ title: t('loginSuccess'), description: t('youAreLoggedIn'), }); - - // NextAuth에서 유저 정보 API 호출 (최신 상태 보장) - const response = await fetch('/api/auth/session'); - const session = await response.json(); - - // domain 값에 따라 동적으로 리다이렉션 - const userDomain = session?.user?.domain; - console.log(session) - - if (userDomain === 'evcp') { - router.push(`/${lng}/evcp/report`); - } else if (userDomain === 'partners') { - router.push(`/${lng}/partners/dashboard`); - } else { - // 기본 리다이렉션 경로 - router.push(`/${lng}/dashboard`); - } + + router.push(`/${lng}/partners/dashboard`); + } else { toast({ title: t('errorTitle'), @@ -131,6 +135,53 @@ export function LoginForm({ } } + // 새로운 로그인 처리 함수 추가 + const handleCredentialsLogin = async () => { + if (!username || !password) { + toast({ + title: t('errorTitle'), + description: t('credentialsRequired'), + variant: 'destructive', + }); + return; + } + + setIsLoading(true); + + try { + // next-auth의 다른 credentials provider로 로그인 시도 + const result = await signIn('credentials-password', { + username, + password, + redirect: false, + }); + + if (result?.ok) { + toast({ + title: t('loginSuccess'), + description: t('youAreLoggedIn'), + }); + + router.push(`/${lng}/partners/dashboard`); + } else { + toast({ + title: t('errorTitle'), + description: t('invalidCredentials'), + variant: 'destructive', + }); + } + } catch (error) { + console.error('Login error:', error); + toast({ + title: t('errorTitle'), + description: t('defaultErrorMessage'), + variant: 'destructive', + }); + } finally { + setIsLoading(false); + } + }; + useEffect(() => { const verifyToken = async () => { if (!token) return; @@ -197,20 +248,92 @@ export function LoginForm({ <div className="flex flex-col items-center text-center"> <h1 className="text-2xl font-bold">{t('loginMessage')}</h1> </div> - <div className="grid gap-2"> - <Input - id="email" - type="email" - placeholder={t('email')} - required - className="h-10" - value={email} - onChange={(e) => setEmail(e.target.value)} - /> - </div> - <Button type="submit" className="w-full" variant="samsung" disabled={isLoading}> - {isLoading ? t('sending') : t('ContinueWithEmail')} - </Button> + + {/* S-chips 로그인 폼이 표시되지 않을 때만 이메일 입력 필드 표시 */} + {!showCredentialsForm && ( + <> + <div className="grid gap-2"> + <Input + id="email" + type="email" + placeholder={t('email')} + required + className="h-10" + value={email} + onChange={(e) => setEmail(e.target.value)} + /> + </div> + <Button type="submit" className="w-full" variant="samsung" disabled={isLoading}> + {isLoading ? t('sending') : t('ContinueWithEmail')} + </Button> + + {/* 구분선과 "Or continue with" 섹션 추가 */} + <div className="relative"> + <div className="absolute inset-0 flex items-center"> + <span className="w-full border-t"></span> + </div> + <div className="relative flex justify-center text-xs uppercase"> + <span className="bg-background px-2 text-muted-foreground"> + {t('orContinueWith')} + </span> + </div> + </div> + + {/* S-chips 로그인 버튼 */} + <Button + type="button" + className="w-full" + // variant="" + onClick={() => setShowCredentialsForm(true)} + > + S-chips로 로그인하기 + </Button> + </> + )} + + {/* ID/비밀번호 로그인 폼 - 버튼 클릭 시에만 표시 */} + {showCredentialsForm && ( + <> + <div className="grid gap-4"> + <Input + id="username" + type="text" + placeholder="S-chips ID" + className="h-10" + value={username} + onChange={(e) => setUsername(e.target.value)} + /> + <Input + id="password" + type="password" + placeholder="비밀번호" + className="h-10" + value={password} + onChange={(e) => setPassword(e.target.value)} + /> + <Button + type="button" + className="w-full" + variant="samsung" + onClick={handleCredentialsLogin} + disabled={isLoading} + > + {isLoading ? "로그인 중..." : "로그인"} + </Button> + + {/* 뒤로 가기 버튼 */} + <Button + type="button" + variant="ghost" + className="w-full text-sm" + onClick={() => setShowCredentialsForm(false)} + > + 이메일로 로그인하기 + </Button> + </div> + </> + )} + <div className="text-center text-sm mx-auto"> <DropdownMenu> <DropdownMenuTrigger asChild> @@ -302,8 +425,8 @@ export function LoginForm({ <div className="relative hidden h-full flex-col p-10 text-white dark:border-r md:flex"> {/* Image 컴포넌트로 대체 */} <div className="absolute inset-0"> - <Image - src="/images/02.jpg" + <Image + src="/images/02.jpg" alt="Background image" fill priority diff --git a/components/pq/client-pq-input-wrapper.tsx b/components/pq/client-pq-input-wrapper.tsx new file mode 100644 index 00000000..89f0fa78 --- /dev/null +++ b/components/pq/client-pq-input-wrapper.tsx @@ -0,0 +1,90 @@ +"use client" + +import * as React from "react" +import { Shell } from "@/components/shell" +import { Skeleton } from "@/components/ui/skeleton" +import { PQInputTabs } from "@/components/pq/pq-input-tabs" +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { PQGroupData, ProjectPQ } from "@/lib/pq/service" +import { useRouter, useSearchParams } from "next/navigation" + +interface ClientPQWrapperProps { + allPQData: PQGroupData[] + projectPQs: ProjectPQ[] + vendorId: number + rawSearchParams: { + projectId?: string + } +} + +export function ClientPQWrapper({ + allPQData, + projectPQs, + vendorId, + rawSearchParams +}: ClientPQWrapperProps) { + const searchParams = useSearchParams() + const projectIdParam = searchParams?.get('projectId') + + // 클라이언트 측에서 projectId 파싱 + const projectId = projectIdParam ? parseInt(projectIdParam, 10) : undefined + + // 현재 프로젝트 정보 찾기 + const currentProject = projectId + ? projectPQs.find(p => p.projectId === projectId) + : null + + // 필요한 경우 여기서 PQ 데이터를 필터링할 수 있음 + // 예: 모든 데이터를 가져왔는데 현재 projectId에 따라 필터링이 필요한 경우 + // const filteredPQData = projectId ? allPQData.filter(...) : allPQData; + + return ( + <Shell className="gap-2"> + {/* 헤더 - 프로젝트 정보 포함 */} + <div className="space-y-2"> + <h2 className="text-2xl font-bold tracking-tight"> + Pre-Qualification Check Sheet + {currentProject && ( + <span className="ml-2 text-muted-foreground"> + - {currentProject.projectCode} + </span> + )} + </h2> + <p className="text-muted-foreground"> + PQ에 적절한 응답을 제출하시기 바랍니다. + </p> + </div> + + {/* 일반/프로젝트 PQ 선택 탭 */} + {projectPQs.length > 0 && ( + <div className="border-b"> + <Tabs defaultValue={projectId ? `project-${projectId}` : "general"}> + <TabsList> + <TabsTrigger value="general" asChild> + <a href="/partners/pq">일반 PQ</a> + </TabsTrigger> + + {projectPQs.map(project => ( + <TabsTrigger key={project.projectId} value={`project-${project.projectId}`} asChild> + <a href={`/partners/pq?projectId=${project.projectId}`}> + {project.projectCode} + </a> + </TabsTrigger> + ))} + </TabsList> + </Tabs> + </div> + )} + + {/* PQ 입력 탭 */} + <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> + <PQInputTabs + data={allPQData} + vendorId={vendorId} + projectId={projectId} + projectData={currentProject} + /> + </React.Suspense> + </Shell> + ) +}
\ No newline at end of file diff --git a/components/pq/pq-review-detail.tsx b/components/pq/pq-review-detail.tsx index 18af02ed..e1bc5510 100644 --- a/components/pq/pq-review-detail.tsx +++ b/components/pq/pq-review-detail.tsx @@ -56,7 +56,9 @@ interface VendorPQAdminReviewProps { projectId?: number projectName?: string projectStatus?: string - loadData: () => Promise<PQGroupData[]> + // loadData: () => Promise<PQGroupData[]> + loadData: (vendorId: number, projectId?: number) => Promise<PQGroupData[]> + pqType: 'general' | 'project' } @@ -81,7 +83,8 @@ export default function VendorPQAdminReview({ const fetchData = async () => { setIsDataLoading(true) try { - const freshData = await loadData() + const freshData = await loadData(vendor.id, projectId) + setPqData(freshData) } catch (error) { console.error("Error loading PQ data:", error) @@ -98,7 +101,7 @@ export default function VendorPQAdminReview({ } else { setPqData(data) } - }, [data, loadData, toast]) + }, [data, loadData, vendor.id, projectId, toast]) // 다이얼로그 상태들 const [showRequestDialog, setShowRequestDialog] = React.useState(false) |
