diff --git a/fems-app/package.json b/fems-app/package.json index 182e1ea..8d7e55b 100644 --- a/fems-app/package.json +++ b/fems-app/package.json @@ -10,14 +10,25 @@ "lint": "next lint" }, "dependencies": { + "@hookform/resolvers": "^3.9.1", + "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-toast": "^1.2.2", + "@tanstack/react-query": "^5.59.16", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "lucide-react": "^0.454.0", "next": "14.2.16", + "next-themes": "^0.3.0", + "providers": "^0.5.0", "react": "^18", "react-dom": "^18", + "react-hook-form": "^7.53.1", + "recharts": "^2.13.3", "tailwind-merge": "^2.5.4", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zod": "^3.23.8" }, "devDependencies": { "@types/node": "^20", diff --git a/fems-app/src/app/(admin)/dashboard/overview/page.tsx b/fems-app/src/app/(admin)/dashboard/overview/page.tsx new file mode 100644 index 0000000..aef7f15 --- /dev/null +++ b/fems-app/src/app/(admin)/dashboard/overview/page.tsx @@ -0,0 +1,48 @@ +// src/app/(admin)/dashboard/overview/page.tsx +import { Card } from "@/components/ui/card"; +import { UsageChart } from "@/components/charts/UsageChart"; +import { CostChart } from "@/components/charts/CostChart"; +import { Alert } from "@/components/ui/alert"; + +export default function DashboardOverviewPage() { + return ( +
+

전체 현황

+ +
+ +

+ 금월 전력 사용량 +

+
+

2,453,890

+

kWh

+
+

전월 대비 5% 감소

+
+ {/* ... 다른 카드들 */} +
+ +
+ +

에너지 사용 추이

+ +
+ +

에너지원별 비용

+ +
+
+ +
+

최근 알림

+ + {/* ... 다른 알림들 */} +
+
+ ); +} diff --git a/fems-app/src/app/(admin)/layout.tsx b/fems-app/src/app/(admin)/layout.tsx new file mode 100644 index 0000000..47db0d8 --- /dev/null +++ b/fems-app/src/app/(admin)/layout.tsx @@ -0,0 +1,19 @@ +// src/app/(admin)/layout.tsx +import { SideNav } from '@/components/layout/SideNav'; +import { TopNav } from '@/components/layout/TopNav'; + +export default function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ +
+ +
{children}
+
+
+ ); +} \ No newline at end of file diff --git a/fems-app/src/app/(auth)/layout.tsx b/fems-app/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..0f02b89 --- /dev/null +++ b/fems-app/src/app/(auth)/layout.tsx @@ -0,0 +1,12 @@ +// src/app/(auth)/layout.tsx +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/fems-app/src/app/(auth)/login/page.tsx b/fems-app/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..ab78e7c --- /dev/null +++ b/fems-app/src/app/(auth)/login/page.tsx @@ -0,0 +1,78 @@ +// src/app/(auth)/login/page.tsx +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm } from 'react-hook-form' +import * as z from 'zod' +import { Button } from '@/components/ui/button' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { useAuth } from '@/hooks/useAuth' + +const formSchema = z.object({ + username: z.string().min(4, '아이디는 4자 이상이어야 합니다'), + password: z.string().min(6, '비밀번호는 6자 이상이어야 합니다'), +}) + +export default function LoginPage() { + const { login } = useAuth() + const form = useForm>({ + resolver: zodResolver(formSchema), + }) + + async function onSubmit(values: z.infer) { + try { + await login(values) + } catch (error) { + console.error(error) + } + } + + return ( +
+
+

로그인

+
+ + ( + + 아이디 + + + + + + )} + /> + ( + + 비밀번호 + + + + + + )} + /> + + + +
+
+ ) +} \ No newline at end of file diff --git a/fems-app/src/app/(dashboard)/layout.tsx b/fems-app/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..eacd0ba --- /dev/null +++ b/fems-app/src/app/(dashboard)/layout.tsx @@ -0,0 +1,19 @@ +// src/app/(dashboard)/layout.tsx +import { SideNav } from '@/components/layout/SideNav' +import { TopNav } from '@/components/layout/TopNav' + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( +
+ +
+ +
{children}
+
+
+ ) +} \ No newline at end of file diff --git a/fems-app/src/app/layout.tsx b/fems-app/src/app/layout.tsx index a36cde0..b44d46d 100644 --- a/fems-app/src/app/layout.tsx +++ b/fems-app/src/app/layout.tsx @@ -1,35 +1,21 @@ -import type { Metadata } from "next"; -import localFont from "next/font/local"; -import "./globals.css"; +// src/app/layout.tsx +import { Inter } from 'next/font/google' +import './globals.css' +import { Providers } from '@/providers' -const geistSans = localFont({ - src: "./fonts/GeistVF.woff", - variable: "--font-geist-sans", - weight: "100 900", -}); -const geistMono = localFont({ - src: "./fonts/GeistMonoVF.woff", - variable: "--font-geist-mono", - weight: "100 900", -}); - -export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", -}; +const inter = Inter({ subsets: ['latin'] }) export default function RootLayout({ children, -}: Readonly<{ - children: React.ReactNode; -}>) { +}: { + children: React.ReactNode +}) { return ( - - - {children} + + + {children} - ); + ) } + diff --git a/fems-app/src/app/page.tsx b/fems-app/src/app/page.tsx index 6fe62d1..3c1b8f2 100644 --- a/fems-app/src/app/page.tsx +++ b/fems-app/src/app/page.tsx @@ -1,101 +1,33 @@ -import Image from "next/image"; +// src/app/page.tsx (랜딩 페이지) +import Link from "next/link"; +import { Button } from "@/components/ui/button"; export default function Home() { return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - src/app/page.tsx - - . -
  2. -
  3. Save and see your changes instantly.
  4. -
- -
- - Vercel logomark - Deploy now - - - Read our docs - +
+ {/* Hero Section */} +
+
+
+

+ Factory Energy Management System +

+

+ 스마트한 에너지 관리로 비용 절감과 효율성을 높이세요 +

+
+ + + + +
-
- +
+ + {/* Features Section */} +
{/* ... Features 내용 */}
); } diff --git a/fems-app/src/components/charts/CostChart.tsx b/fems-app/src/components/charts/CostChart.tsx new file mode 100644 index 0000000..fc98c6d --- /dev/null +++ b/fems-app/src/components/charts/CostChart.tsx @@ -0,0 +1,56 @@ +// src/components/charts/CostChart.tsx +"use client"; + +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from "recharts"; + +const data = [ + { name: "전력", cost: 5000000 }, + { name: "가스", cost: 3000000 }, + { name: "용수", cost: 1000000 }, + { name: "스팀", cost: 2000000 }, +]; + +interface CostChartProps { + className?: string; +} + +export function CostChart({ className }: CostChartProps) { + return ( +
+ + + + + + + new Intl.NumberFormat("ko-KR", { + style: "currency", + currency: "KRW", + }).format(value as number) + } + /> + + + + +
+ ); +} diff --git a/fems-app/src/components/charts/UsageChart.tsx b/fems-app/src/components/charts/UsageChart.tsx new file mode 100644 index 0000000..e597715 --- /dev/null +++ b/fems-app/src/components/charts/UsageChart.tsx @@ -0,0 +1,55 @@ +// src/components/charts/UsageChart.tsx +'use client' + +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer +} from 'recharts'; + +// 샘플 데이터 +const data = [ + { name: '1월', 전력: 4000, 가스: 2400, 용수: 2400 }, + { name: '2월', 전력: 3000, 가스: 1398, 용수: 2210 }, + { name: '3월', 전력: 2000, 가스: 9800, 용수: 2290 }, + { name: '4월', 전력: 2780, 가스: 3908, 용수: 2000 }, + { name: '5월', 전력: 1890, 가스: 4800, 용수: 2181 }, + { name: '6월', 전력: 2390, 가스: 3800, 용수: 2500 }, +]; + +interface UsageChartProps { + className?: string; +} + +export function UsageChart({ className }: UsageChartProps) { + return ( +
+ + + + + + + + + + + + +
+ ); +} + diff --git a/fems-app/src/components/layout/SideNav.tsx b/fems-app/src/components/layout/SideNav.tsx new file mode 100644 index 0000000..35e94d5 --- /dev/null +++ b/fems-app/src/components/layout/SideNav.tsx @@ -0,0 +1,79 @@ +// src/components/layout/SideNav.tsx +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; +import { MenuIcon } from "lucide-react"; + +const menuItems = [ + { + title: "대시보드", + items: [ + { title: "전체 현황", href: "/dashboard/overview", icon: MenuIcon }, + { title: "KPI 지표", href: "/dashboard/kpi" }, + { title: "비용 현황", href: "/dashboard/costs" }, + ], + }, + { + title: "에너지 모니터링", + items: [ + { title: "전력", href: "/monitoring/electricity" }, + { title: "가스", href: "/monitoring/gas" }, + { title: "용수", href: "/monitoring/water" }, + { title: "스팀", href: "/monitoring/steam" }, + ], + }, + { + title: "설비 관리", + items: [ + { title: "설비 목록", href: "/equipment/inventory" }, + { title: "상태 모니터링", href: "/equipment/monitoring" }, + { title: "정비 관리", href: "/equipment/maintenance" }, + ], + }, + { + title: "시스템 관리", + items: [ + { title: "사용자 관리", href: "/system/users" }, + { title: "알림 관리", href: "/system/alerts" }, + { title: "설정", href: "/system/settings" }, + ], + }, +]; + +export function SideNav() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/fems-app/src/components/layout/TopNav.tsx b/fems-app/src/components/layout/TopNav.tsx new file mode 100644 index 0000000..c682087 --- /dev/null +++ b/fems-app/src/components/layout/TopNav.tsx @@ -0,0 +1,33 @@ +// src/components/layout/TopNav.tsx +"use client"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useAuth } from "@/hooks/useAuth"; + +export function TopNav() { + const { user, logout } = useAuth(); + + return ( +
+
+
{/* 추가 기능 버튼들 */}
+
+ + + + + + 로그아웃 + + +
+
+
+ ); +} diff --git a/fems-app/src/components/ui/alert.tsx b/fems-app/src/components/ui/alert.tsx new file mode 100644 index 0000000..41fa7e0 --- /dev/null +++ b/fems-app/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/fems-app/src/components/ui/button.tsx b/fems-app/src/components/ui/button.tsx new file mode 100644 index 0000000..36496a2 --- /dev/null +++ b/fems-app/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/fems-app/src/components/ui/card.tsx b/fems-app/src/components/ui/card.tsx new file mode 100644 index 0000000..afa13ec --- /dev/null +++ b/fems-app/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/fems-app/src/components/ui/dropdown-menu.tsx b/fems-app/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..0fc4c0e --- /dev/null +++ b/fems-app/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,200 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/fems-app/src/components/ui/form.tsx b/fems-app/src/components/ui/form.tsx new file mode 100644 index 0000000..ce264ae --- /dev/null +++ b/fems-app/src/components/ui/form.tsx @@ -0,0 +1,178 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +