summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-rw-r--r--components/layout/GroupedMenuRender.tsx80
-rw-r--r--components/layout/Header.tsx215
-rw-r--r--components/layout/MobileMenu.tsx8
-rw-r--r--components/login/login-form-shi.tsx318
-rw-r--r--components/login/login-form.tsx201
-rw-r--r--components/pq/client-pq-input-wrapper.tsx90
-rw-r--r--components/pq/pq-review-detail.tsx9
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">&ldquo;{t("blockquote")}&rdquo;</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)