commit f2bb8eb2bf287a1031901e77276c6ca7cb204855 Author: HasanMu Date: Tue Mar 25 15:54:54 2025 +0700 wip: refactor design diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d55013e --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +VITE_API_URL="https://dev-nam.winteraccess.id" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64177a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +.env +package-lock.json \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..40ede56 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default tseslint.config({ + extends: [ + // Remove ...tseslint.configs.recommended and replace with this + ...tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + ...tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + ...tseslint.configs.stylisticTypeChecked, + ], + languageOptions: { + // other options... + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, +}) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default tseslint.config({ + plugins: { + // Add the react-x and react-dom plugins + 'react-x': reactX, + 'react-dom': reactDom, + }, + rules: { + // other rules... + // Enable its recommended typescript rules + ...reactX.configs['recommended-typescript'].rules, + ...reactDom.configs.recommended.rules, + }, +}) +``` diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..092408a --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/index.html b/index.html new file mode 100644 index 0000000..a6349a8 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + NAM + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..a1528e2 --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "nam", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@tailwindcss/vite": "^4.0.14", + "@tanstack/react-query": "^5.69.0", + "@tanstack/react-table": "^8.21.2", + "axios": "^1.8.4", + "clsx": "^2.1.1", + "daisyui": "^5.0.6", + "dayjs": "^1.11.13", + "lucide-react": "^0.483.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router": "^7.4.0", + "react-svg": "^16.3.0", + "tailwind-merge": "^3.0.2", + "tailwindcss": "^4.0.14", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@eslint/js": "^9.21.0", + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "@vitejs/plugin-react": "^4.3.4", + "eslint": "^9.21.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^15.15.0", + "typescript": "~5.7.2", + "typescript-eslint": "^8.24.1", + "vite": "^6.2.0" + } +} diff --git a/public/vite.svg b/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..8e6c7a7 --- /dev/null +++ b/src/App.css @@ -0,0 +1,94 @@ +@import url('https://fonts.googleapis.com/css2?family=Public+Sans:ital,wght@0,100..900;1,100..900&display=swap'); +@import "tailwindcss"; +@plugin "daisyui" { + themes: all; +} + +@theme { + --font-sans: "Public Sans", sans-serif; +} + +@plugin "daisyui/theme" { + name: "light"; + default: true; + prefersdark: dark; + color-scheme: "light"; + --color-base-100: oklch(100% 0 0); + --color-base-200: oklch(97% 0 0); + --color-base-300: oklch(92% 0 0); + --color-base-content: oklch(20% 0 0); + --color-primary: #b71c1c; + --color-primary-content: oklch(93% 0.034 272.788); + --color-secondary: #9aa2ba; + --color-secondary-content: oklch(94% 0.028 342.258); + --color-accent: oklch(77% 0.152 181.912); + --color-accent-content: oklch(38% 0.063 188.416); + --color-neutral: oklch(14% 0.005 285.823); + --color-neutral-content: oklch(92% 0.004 286.32); + --color-info: #16c0e8; + --color-info-content: oklch(29% 0.066 243.157); + --color-success: #4caf50; + --color-success-content: oklch(37% 0.077 168.94); + --color-warning: #fb8c00; + --color-warning-content: oklch(41% 0.112 45.904); + --color-error: #F44336; + --color-error-content: oklch(27% 0.105 12.094); + --radius-selector: .75rem; + --radius-field: .75rem; + --radius-box: .75rem; + --size-selector: 0.25rem; + --size-field: 0.25rem; + --border: 1px; + --depth: 1; + --noise: 0; +} + +@plugin "daisyui/theme" { + name: "dark"; + default: false; + prefersdark: true; + color-scheme: "dark"; + + --color-base-100: oklch(25.33% 0.016 252.42); + --color-base-200: oklch(23.26% 0.014 253.1); + --color-base-300: oklch(21.15% 0.012 254.09); + --color-base-content: oklch(97.807% 0.029 256.847); + --color-primary: oklch(59.66% 0.1887 27.48); + --color-primary-content: oklch(93% 0.034 272.788); + --color-secondary: #9aa2ba; + --color-secondary-content: oklch(94% 0.028 342.258); + --color-accent: oklch(77% 0.152 181.912); + --color-accent-content: oklch(38% 0.063 188.416); + --color-neutral: oklch(14% 0.005 285.823); + --color-neutral-content: oklch(92% 0.004 286.32); + --color-info: #16c0e8; + --color-info-content: oklch(29% 0.066 243.157); + --color-success: #4caf50; + --color-success-content: oklch(37% 0.077 168.94); + --color-warning: #fb8c00; + --color-warning-content: oklch(41% 0.112 45.904); + --color-error: #F44336; + --color-error-content: oklch(27% 0.105 12.094); + --radius-selector: .75rem; + --radius-field: .75rem; + --radius-box: .75rem; + --size-selector: 0.25rem; + --size-field: 0.25rem; + --border: 1px; + --depth: 1; + --noise: 0; +} + +.menu-active { + @apply bg-primary text-primary +} + +.sidebar.menu>li { + @apply py-[0.1rem] +} + +.sidebar.menu>li>a { + @apply px-4 py-3 text-sm +} + +@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..552c198 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,13 @@ +import "./App.css"; +import { RouterProvider } from "react-router"; +import router from "./routes"; + +function App() { + return ( + <> + + + ); +} + +export default App; diff --git a/src/assets/backbone.svg b/src/assets/backbone.svg new file mode 100644 index 0000000..44ed429 --- /dev/null +++ b/src/assets/backbone.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/assets/device.svg b/src/assets/device.svg new file mode 100644 index 0000000..b7e0a91 --- /dev/null +++ b/src/assets/device.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/fishbone.svg b/src/assets/fishbone.svg new file mode 100644 index 0000000..cbffa8d --- /dev/null +++ b/src/assets/fishbone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/react copy.svg b/src/assets/react copy.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/src/assets/react copy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/react.svg b/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/speedometer.svg b/src/assets/speedometer.svg new file mode 100644 index 0000000..ab6f1e7 --- /dev/null +++ b/src/assets/speedometer.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/tower.svg b/src/assets/tower.svg new file mode 100644 index 0000000..8277339 --- /dev/null +++ b/src/assets/tower.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/users.svg b/src/assets/users.svg new file mode 100644 index 0000000..2dfc863 --- /dev/null +++ b/src/assets/users.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/actions/Button.tsx b/src/components/actions/Button.tsx new file mode 100644 index 0000000..3b811c5 --- /dev/null +++ b/src/components/actions/Button.tsx @@ -0,0 +1,183 @@ +import React, { JSX, forwardRef, ReactNode, ElementType } from 'react' +import clsx from 'clsx' +import { twMerge } from 'tailwind-merge' + +import Loading from '../feedback/Loading' +import { + IComponentBaseProps, + ComponentColor, + ComponentShape, + ComponentSize, +} from '../types' + +type ITagProps = { + a: { + attr: React.AnchorHTMLAttributes + ele: HTMLAnchorElement + } + button: { + attr: React.ButtonHTMLAttributes + ele: HTMLButtonElement + } + div: { + attr: React.HTMLAttributes + ele: HTMLDivElement + } + img: { + attr: React.ImgHTMLAttributes + ele: HTMLImageElement + } + input: { + attr: React.InputHTMLAttributes + ele: HTMLInputElement + } + label: { + attr: React.LabelHTMLAttributes + ele: HTMLLabelElement + } + span: { + attr: React.HTMLAttributes + ele: HTMLSpanElement + } +} + +type GetTagProps = T extends keyof ITagProps + ? ITagProps[T] + : ITagProps['button'] + +export type ButtonProps< + T extends ElementType = 'button', + A extends React.HTMLAttributes = GetTagProps['attr'] +> = Omit & + IComponentBaseProps & { + shape?: ComponentShape + size?: ComponentSize + variant?: 'outline' | 'link' + color?: ComponentColor + glass?: boolean + wide?: boolean + fullWidth?: boolean + responsive?: boolean + animation?: boolean + loading?: boolean + active?: boolean + startIcon?: ReactNode + endIcon?: ReactNode + disabled?: boolean + tag?: T + } +// https://developer.mozilla.org/en-US/docs/Glossary/Void_element +const VoidElementList: ElementType[] = [ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'keygen', + 'meta', + 'param', + 'source', + 'track', + 'wbr', +] +const Button = forwardRef( + ( + { + children, + shape, + size, + variant, + color, + glass, + startIcon, + endIcon, + wide, + fullWidth, + responsive, + animation = true, + loading, + active, + disabled, + dataTheme, + className, + style, + tag = 'button', + ...props + }, + ref + ): JSX.Element => { + const Tag = tag + const classes = twMerge( + 'btn', + className, + clsx(((startIcon && !loading) || endIcon) && 'gap-2', { + 'btn-lg': size === 'lg', + 'btn-md': size === 'md', + 'btn-sm': size === 'sm', + 'btn-xs': size === 'xs', + 'btn-circle': shape === 'circle', + 'btn-square': shape === 'square', + 'btn-outline': variant === 'outline', + 'btn-link': variant === 'link', + 'btn-neutral': color === 'neutral', + 'btn-primary': color === 'primary', + 'btn-secondary': color === 'secondary', + 'btn-accent': color === 'accent', + 'btn-info': color === 'info', + 'btn-success': color === 'success', + 'btn-warning': color === 'warning', + 'btn-error': color === 'error', + 'btn-ghost': color === 'ghost', + glass: glass, + 'btn-wide': wide, + 'btn-block': fullWidth, + 'btn-xs sm:btn-sm md:btn-md lg:btn-lg': responsive, + 'no-animation': !animation, + 'btn-active': active, + 'btn-disabled': disabled, + }) + ) + if (VoidElementList.includes(Tag)) { + return ( + + ) + } else { + return ( + + {loading && } + {startIcon && !loading && startIcon} + {children} + {endIcon && endIcon} + + ) + } + } +) + +Button.displayName = 'Button' + +export default Button as < + T extends ElementType = 'button', + E extends HTMLElement = GetTagProps['ele'], + A extends React.HTMLAttributes = GetTagProps['attr'] +>( + props: ButtonProps & { ref?: React.Ref } +) => JSX.Element \ No newline at end of file diff --git a/src/components/actions/Dropdown/Dropdown.tsx b/src/components/actions/Dropdown/Dropdown.tsx new file mode 100644 index 0000000..4b40227 --- /dev/null +++ b/src/components/actions/Dropdown/Dropdown.tsx @@ -0,0 +1,97 @@ +import React, { JSX, ReactNode } from 'react' +import clsx from 'clsx' +import { twMerge } from 'tailwind-merge' + +import { IComponentBaseProps } from '@/components/types' + +import DropdownDetails from './DropdownDetails' +import DropdownMenu from './DropdownMenu' +import DropdownItem from './DropdownItem' +import DropdownToggle from './DropdownToggle' + +export type DropdownProps = + React.HTMLAttributes & + IComponentBaseProps & { + item?: ReactNode + horizontal?: 'left' | 'right' + vertical?: 'top' | 'bottom' + end?: boolean + hover?: boolean + open?: boolean + } + +export const classesFn = ({ + className, + horizontal, + vertical, + end, + hover, + open, +}: Pick< + DropdownProps, + 'className' | 'horizontal' | 'vertical' | 'end' | 'hover' | 'open' +>) => + twMerge( + 'dropdown', + className, + clsx({ + 'dropdown-left': horizontal === 'left', + 'dropdown-right': horizontal === 'right', + 'dropdown-top': vertical === 'top', + 'dropdown-bottom': vertical === 'bottom', + 'dropdown-end': end, + 'dropdown-hover': hover, + 'dropdown-open': open, + }) + ) + +const Dropdown = React.forwardRef( + ( + { + children, + className, + item, + horizontal, + vertical, + end, + hover, + open, + dataTheme, + ...props + }, + ref + ): JSX.Element => { + return ( +
+ {item ? ( + <> + +
    {item}
+ + ) : ( + <>{children} + )} +
+ ) + } +) + +export default Object.assign(Dropdown, { + Details: DropdownDetails, + Toggle: DropdownToggle, + Menu: DropdownMenu, + Item: DropdownItem, +}) \ No newline at end of file diff --git a/src/components/actions/Dropdown/DropdownDetails.tsx b/src/components/actions/Dropdown/DropdownDetails.tsx new file mode 100644 index 0000000..efde958 --- /dev/null +++ b/src/components/actions/Dropdown/DropdownDetails.tsx @@ -0,0 +1,48 @@ +import React, { JSX } from 'react' + +import { classesFn, DropdownProps } from './Dropdown' +import { Summary } from './DropdownToggle' + +export type DetailsProps = Omit< + DropdownProps, + 'item' | 'hover' +> +const DropdownDetails = React.forwardRef( + ( + { + children, + className, + horizontal, + vertical, + end, + dataTheme, + open, + ...props + }, + ref + ): JSX.Element => { + return ( +
+ {children} +
+ ) + } +) + +DropdownDetails.displayName = 'DropdownDetails' +export default Object.assign(DropdownDetails, { + Toggle: Summary, +}) \ No newline at end of file diff --git a/src/components/actions/Dropdown/DropdownItem.tsx b/src/components/actions/Dropdown/DropdownItem.tsx new file mode 100644 index 0000000..f77a095 --- /dev/null +++ b/src/components/actions/Dropdown/DropdownItem.tsx @@ -0,0 +1,21 @@ +import React from 'react' + +type Anchor = React.AnchorHTMLAttributes & { + anchor?: true +} + +type NoAnchor = Pick & { anchor?: false } + +export type DropdownItemProps = Anchor | NoAnchor + +const DropdownItem = React.forwardRef( + ({ anchor = true, ...props }, ref) => { + return ( +
  • + {anchor ? : props.children} +
  • + ) + } +) + +export default DropdownItem \ No newline at end of file diff --git a/src/components/actions/Dropdown/DropdownMenu.tsx b/src/components/actions/Dropdown/DropdownMenu.tsx new file mode 100644 index 0000000..5206c1c --- /dev/null +++ b/src/components/actions/Dropdown/DropdownMenu.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import { twMerge } from 'tailwind-merge' +import { IComponentBaseProps } from '@/components/types' + +export type DropdownMenuProps = React.HTMLAttributes & + IComponentBaseProps + +const DropdownMenu = ({ + dataTheme, + className, + ...props +}: DropdownMenuProps) => { + const classes = twMerge( + 'dropdown-content menu p-2 shadow bg-base-100 rounded-box', + className + ) + + return ( +
      + ) +} + +export default DropdownMenu \ No newline at end of file diff --git a/src/components/actions/Dropdown/DropdownToggle.tsx b/src/components/actions/Dropdown/DropdownToggle.tsx new file mode 100644 index 0000000..871bddd --- /dev/null +++ b/src/components/actions/Dropdown/DropdownToggle.tsx @@ -0,0 +1,54 @@ +import React, { JSX, forwardRef } from 'react' + +import { ComponentColor, ComponentSize, IComponentBaseProps } from '@/components/types' + +import Button, { ButtonProps } from '../Button' + +export type DropdownToggleProps = Omit< + React.LabelHTMLAttributes, + 'color' +> & + IComponentBaseProps & { + color?: ComponentColor + size?: ComponentSize + button?: boolean + disabled?: boolean + } + +const DropdownToggle = ({ + children, + color, + size, + button = true, + dataTheme, + className, + disabled, + ...props +}: DropdownToggleProps) => { + return ( + + ) +} + +export type SummaryProps = Omit +export const Summary = forwardRef( + (props, ref): JSX.Element => { + return + + + {/* Header Actions */} +
      + + + {/* User Dropdown */} + + +
      + + {user?.name?.charAt(0) || "U"} + +
      +
      + + Hi, {user?.name} + + + Profile + + + + Settings + + + + + +
      +
      + + + ); +}; + +export default Header; diff --git a/src/components/layouts/Dashboard/Sidebar.tsx b/src/components/layouts/Dashboard/Sidebar.tsx new file mode 100644 index 0000000..74dda8c --- /dev/null +++ b/src/components/layouts/Dashboard/Sidebar.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { NavLink } from 'react-router'; +import { ReactSVG } from 'react-svg'; + +// Import your local SVG icons here +import DashboardIcon from '../../../assets/speedometer.svg'; +import BackboneIcon from '../../../assets/backbone.svg'; +import FishboneIcon from '../../../assets/fishbone.svg'; +import DevicesIcon from '../../../assets/device.svg'; +import TowerIcon from '../../../assets/tower.svg'; +import UserIcon from '../../../assets/users.svg'; +import Menu from '@/components/navigation/Menu/Menu'; + +interface SidebarProps { + sidebarOpen: boolean; + user?: { + name?: string; + role?: string; + } | null; + handleLogout: () => void; +} + +const Sidebar: React.FC = ({ sidebarOpen, user, handleLogout }) => { + return ( + + ); +}; + +export default Sidebar; \ No newline at end of file diff --git a/src/components/layouts/DashboardLayout.tsx b/src/components/layouts/DashboardLayout.tsx new file mode 100644 index 0000000..b9be5dd --- /dev/null +++ b/src/components/layouts/DashboardLayout.tsx @@ -0,0 +1,70 @@ +import React, { useState } from 'react'; +import { NavLink, Outlet, useNavigate } from 'react-router'; +import { useAuthStore } from '../../features/auth/store'; +import Sidebar from './Dashboard/Sidebar'; // Import the Sidebar component we created earlier +import Header from './Dashboard/Header'; + +const DashboardLayout: React.FC = () => { + const [sidebarOpen, setSidebarOpen] = useState(false); + const { user, logout } = useAuthStore(); + const navigate = useNavigate(); + + const handleLogout = () => { + logout(); + navigate('/login'); + }; + + const toggleSidebar = () => { + setSidebarOpen(!sidebarOpen); + }; + + return ( +
      + {/* Overlay for mobile sidebar */} +
      + + {/* Sidebar */} + + + {/* Main content */} +
      + {/* Header */} +
      +
      + + {/* Main content area */} +
      + +
      + + {/* Footer */} +
      +

      © {new Date().getFullYear()} Network Asset Management. All rights reserved.

      +
      +
      +
      + ); +}; + +export default DashboardLayout; \ No newline at end of file diff --git a/src/components/navigation/Breadcrumbs/Breadcrumbs.tsx b/src/components/navigation/Breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 0000000..af758cb --- /dev/null +++ b/src/components/navigation/Breadcrumbs/Breadcrumbs.tsx @@ -0,0 +1,41 @@ +import React, { JSX, ReactElement } from 'react' +import { twMerge } from 'tailwind-merge' + +import { IComponentBaseProps } from '@/components/types' + +import BreadcrumbsItem, { BreadcrumbsItemProps } from './BreadcrumbsItem' + +export type BreadcrumbsProps = React.HTMLAttributes & + IComponentBaseProps & { + children?: + | ReactElement + | ReactElement[] + innerRef?: React.Ref + innerProps?: React.HTMLAttributes + } + +const Breadcrumbs = React.forwardRef( + ( + { children, dataTheme, className, innerProps, innerRef, ...props }, + ref + ): JSX.Element => { + const classes = twMerge('breadcrumbs', 'text-sm', className) + + return ( +
      +
        + {children} +
      +
      + ) + } +) + +export default Object.assign(Breadcrumbs, { Item: BreadcrumbsItem }) \ No newline at end of file diff --git a/src/components/navigation/Breadcrumbs/BreadcrumbsItem.tsx b/src/components/navigation/Breadcrumbs/BreadcrumbsItem.tsx new file mode 100644 index 0000000..01a8f02 --- /dev/null +++ b/src/components/navigation/Breadcrumbs/BreadcrumbsItem.tsx @@ -0,0 +1,17 @@ +import React, { JSX } from 'react' + +export type BreadcrumbsItemProps = React.LiHTMLAttributes & { + href?: string +} + +const BreadcrumbsItem = React.forwardRef( + ({ children, href, ...props }, ref): JSX.Element => { + return ( +
    • + {href ? {children} : <>{children}} +
    • + ) + } +) + +export default BreadcrumbsItem \ No newline at end of file diff --git a/src/components/navigation/Menu/Menu.tsx b/src/components/navigation/Menu/Menu.tsx new file mode 100644 index 0000000..ef6da7d --- /dev/null +++ b/src/components/navigation/Menu/Menu.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import clsx from 'clsx' +import { twMerge } from 'tailwind-merge' + +import { IComponentBaseProps, ComponentSize } from '@/components/types' + +import MenuTitle, { MenuTitleProps as TitleProps } from './MenuTitle' +import MenuItem, { MenuItemProps as ItemProps } from './MenuItem' +import MenuDropdown, { + MenuDropdownProps as DropdownProps, +} from './MenuDropdown' +import MenuDetails, { MenuDetailsProps as DetailsProps } from './MenuDetails' + +export type MenuTitleProps = TitleProps +export type MenuItemProps = ItemProps +export type MenuDropdownProps = DropdownProps +export type MenuDetailsProps = DetailsProps + +export type MenuProps = React.HTMLAttributes & + IComponentBaseProps & { + vertical?: boolean // Vertical menu (default) + horizontal?: boolean // Horizontal menu + responsive?: boolean + size?: ComponentSize + } + +const Menu = React.forwardRef( + ( + { responsive, horizontal, vertical, dataTheme, className, size, ...props }, + ref + ) => { + const classes = twMerge( + 'menu', + className, + clsx({ + 'menu-vertical lg:menu-horizontal': responsive, + 'menu-lg': size === 'lg', + 'menu-md': size === 'md', + 'menu-sm': size === 'sm', + 'menu-xs': size === 'xs', + 'menu-vertical': vertical, + 'menu-horizontal': horizontal, + }) + ) + + return ( +
        + ) + } +) + +export default Object.assign(Menu, { + Title: MenuTitle, + Item: MenuItem, + Dropdown: MenuDropdown, + Details: MenuDetails, +}) \ No newline at end of file diff --git a/src/components/navigation/Menu/MenuDetails.tsx b/src/components/navigation/Menu/MenuDetails.tsx new file mode 100644 index 0000000..fcdba6e --- /dev/null +++ b/src/components/navigation/Menu/MenuDetails.tsx @@ -0,0 +1,22 @@ +import React, { ReactNode } from 'react' + +import { IComponentBaseProps } from '@/components/types' + +export type MenuDetailsProps = React.DetailsHTMLAttributes & + IComponentBaseProps & { + label: ReactNode + open?: boolean + } + +const MenuDetails = React.forwardRef( + ({ className, label, open, children, ...props }, ref) => { + return ( +
        + {label} +
          {children}
        +
        + ) + } +) + +export default MenuDetails \ No newline at end of file diff --git a/src/components/navigation/Menu/MenuDropdown.tsx b/src/components/navigation/Menu/MenuDropdown.tsx new file mode 100644 index 0000000..8440a8c --- /dev/null +++ b/src/components/navigation/Menu/MenuDropdown.tsx @@ -0,0 +1,34 @@ +import React, { ReactNode } from 'react' +import clsx from 'clsx' +import { twMerge } from 'tailwind-merge' + +import { IComponentBaseProps } from '@/components/types' + +export type MenuDropdownProps = React.HTMLAttributes & + IComponentBaseProps & { + label: ReactNode + open?: boolean + } + +const MenuDropdown = React.forwardRef( + ({ className, label, open, children, ...props }, ref) => { + const classes = twMerge( + 'menu-dropdown-toggle', + className, + clsx({ 'menu-dropdown-show': open }) + ) + + return ( + <> + + {label} + +
          + {children} +
        + + ) + } +) + +export default MenuDropdown \ No newline at end of file diff --git a/src/components/navigation/Menu/MenuItem.tsx b/src/components/navigation/Menu/MenuItem.tsx new file mode 100644 index 0000000..a89816a --- /dev/null +++ b/src/components/navigation/Menu/MenuItem.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import clsx from 'clsx' +import { twMerge } from 'tailwind-merge' + +import { IComponentBaseProps } from '@/components/types' + +export type MenuItemProps = React.LiHTMLAttributes & + IComponentBaseProps & { + disabled?: boolean + } + +const MenuItem = React.forwardRef( + ({ className, disabled, ...props }, ref) => { + const classes = twMerge( + className, + clsx({ + disabled: disabled, + }) + ) + + return
      • + } +) + +export default MenuItem \ No newline at end of file diff --git a/src/components/navigation/Menu/MenuTitle.tsx b/src/components/navigation/Menu/MenuTitle.tsx new file mode 100644 index 0000000..1c6f09c --- /dev/null +++ b/src/components/navigation/Menu/MenuTitle.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import { twMerge } from 'tailwind-merge' + +import { IComponentBaseProps } from '@/components/types' + +export type MenuTitleProps = React.LiHTMLAttributes & + IComponentBaseProps + +const MenuTitle = React.forwardRef( + ({ className, ...props }, ref) => { + const classes = twMerge('menu-title', className) + + return
      • + } +) + +export default MenuTitle \ No newline at end of file diff --git a/src/components/types.ts b/src/components/types.ts new file mode 100644 index 0000000..18fb9c9 --- /dev/null +++ b/src/components/types.ts @@ -0,0 +1,27 @@ +import { + componentStatuses, + componentColors, + componentPositions, + componentSizes, + componentShapes, + bgColors, + brandColors, + } from './constants' + import { DEFAULT_THEMES } from './defaultThemes' + + export type DataTheme = typeof DEFAULT_THEMES[number] | string + + export interface IComponentBaseProps { + dataTheme?: DataTheme + } + + export type ComponentColor = typeof componentColors[number] + + export type ComponentPosition = typeof componentPositions[number] + export type ComponentShape = typeof componentShapes[number] + export type ComponentSize = typeof componentSizes[number] + export type ComponentStatus = typeof componentStatuses[number] + export type ComponentBrandColors = typeof brandColors[number] + export type ComponentBgColors = typeof bgColors[number] + + export type ListOrItem = T[] | T | Array \ No newline at end of file diff --git a/src/features/auth/api/auth.ts b/src/features/auth/api/auth.ts new file mode 100644 index 0000000..85d0e16 --- /dev/null +++ b/src/features/auth/api/auth.ts @@ -0,0 +1,51 @@ +import { useMutation } from '@tanstack/react-query'; +import { apiClient, ApiResponse, extractApiErrors } from '../../../lib/api-client'; +import { useAuthStore } from '../store'; +// import { toast } from 'react-hot-toast'; + +interface LoginCredentials { + username: string; + password: string; +} + +interface LoginResponse { + name: string; + role: string; + token: string; +} + +interface Callback { + onError?: (errors: Record) => void; +} + +export const login = async (credentials: LoginCredentials): Promise => { + const response = await apiClient.post>('/api/v1/users/login', credentials); + + console.log(response.data); + + if (!response.data.data) { + throw new Error('Authentication failed'); + } + return response.data.data; +}; + +export const useLogin = ({ onError }: Callback) => { + const { setUser, setToken } = useAuthStore(); + + return useMutation({ + mutationFn: login, + onSuccess: (data) => { + setUser({ + name: data.name, + role: data.role, + }); + setToken(data.token); + // toast.success('Login berhasil'); + }, + onError: (error: unknown) => { + const errors = extractApiErrors(error); + + onError && onError(errors); + }, + }); +}; \ No newline at end of file diff --git a/src/features/auth/store.ts b/src/features/auth/store.ts new file mode 100644 index 0000000..3c57cfd --- /dev/null +++ b/src/features/auth/store.ts @@ -0,0 +1,32 @@ +import { create } from 'zustand'; +import { persist } from "zustand/middleware"; + +export interface User { + name: string; + role: string; +} + +interface AuthState { + user: User | null; + token: string | null; + isAuthenticated: boolean; + setUser: (user: User) => void; + setToken: (token: string) => void; + logout: () => void; +} + +export const useAuthStore = create()( + persist( + (set) => ({ + user: null, + token: null, + isAuthenticated: false, + setUser: (user) => set({ user, isAuthenticated: true }), + setToken: (token) => set({ token }), + logout: () => set({ user: null, token: null, isAuthenticated: false }), + }), + { + name: "auth-storage", + } + ) +); \ No newline at end of file diff --git a/src/features/devices/api/devices.ts b/src/features/devices/api/devices.ts new file mode 100644 index 0000000..2c2abfe --- /dev/null +++ b/src/features/devices/api/devices.ts @@ -0,0 +1,83 @@ +// src/features/devices/api/devices.ts +import { apiClient } from '@/lib/api-client'; +import { DeviceDataTable } from '../types'; +import { useQuery } from '@tanstack/react-query'; +import { ApiResponse } from '@/lib/api-client'; // Pastikan Anda memiliki tipe ApiResponse +import axios, { AxiosError } from 'axios'; +import { DataTableDummy } from '../dummy'; + +// Tipe untuk error response +interface ApiErrorResponse { + status?: { + code: number; + description: string; + }; + message?: string; +} + +export const getDevices = async (): Promise => { + try { + const response = await apiClient.get>('/api/v1/devices'); + + // Pastikan hanya response dengan status code 200 yang diterima + if (response.data.status.code === 200) { + return response.data.data as DeviceDataTable[]; + } + + // Throw error untuk status code selain 200 + throw new Error(response.data.status.description || 'Unknown error'); + } catch (error) { + // Tangani berbagai jenis error + if (DataTableDummy.length > 0) { + return DataTableDummy as unknown as DeviceDataTable[]; + } + + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + + // Handle specific error scenarios + if (axiosError.response) { + // Server responded with an error status code + switch (axiosError.response.status) { + case 400: + throw new Error('Bad Request: Invalid data'); + case 401: + throw new Error('Unauthorized: Please log in again'); + case 403: + throw new Error('Forbidden: You do not have permission'); + case 404: + throw new Error('Not Found: Devices endpoint does not exist'); + case 500: + throw new Error('Server Error: Please try again later'); + default: + throw new Error( + axiosError.response.data?.status?.description || + axiosError.response.data?.message || + 'An unexpected error occurred' + ); + } + } else if (axiosError.request) { + // Request was made but no response received (e.g., network error) + if (axiosError.message === 'Network Error') { + throw new Error('Network Error: Please check your internet connection'); + } + throw new Error('No response received from server'); + } else { + // Something happened in setting up the request + throw new Error('Error setting up the request'); + } + } + + // Handle non-axios errors + throw new Error('An unexpected error occurred'); + } +}; + +export const useDevices = () => { + return useQuery({ + queryKey: ['devices'], + queryFn: getDevices, + retry: 1, // Mencoba ulang sekali jika error + retryDelay: 1000, // Delay 1 detik antara percobaan + }); +}; \ No newline at end of file diff --git a/src/features/devices/dummy.ts b/src/features/devices/dummy.ts new file mode 100644 index 0000000..5f0db44 --- /dev/null +++ b/src/features/devices/dummy.ts @@ -0,0 +1,35 @@ +export const DataTableDummy = [ + { + "id": "99fa8f36-b483-4881-8547-1e799eb321b3", + "device_code": "YYYFEE", + "device_type": "ODP", + "longitude": -6.327583, + "latitude": 108.324936, + "port_amount": 2, + "status": "inactive", + "created_at": "2025-02-12T09:03:24.904402Z", + "updated_at": "2025-03-14T06:53:07Z" + }, + { + "id": "d6f7806a-479d-4bfd-8efc-432839a47fd4", + "device_code": "DEVICE-01", + "device_type": "ODP", + "longitude": -6.737246, + "latitude": 108.550659, + "port_amount": 16, + "status": "active", + "created_at": "2025-03-13T07:00:13Z", + "updated_at": "2025-03-14T07:22:42Z" + }, + { + "id": "21d9be7a-9887-4bde-a324-d57b7a61d83c", + "device_code": "XXXFEE", + "device_type": "ODP", + "longitude": -6.737246, + "latitude": 108.550659, + "port_amount": 40, + "status": "active", + "created_at": "2025-02-12T09:01:10.023377Z", + "updated_at": "2025-03-19T07:17:45Z" + } +] \ No newline at end of file diff --git a/src/features/devices/types.ts b/src/features/devices/types.ts new file mode 100644 index 0000000..a8d5a3b --- /dev/null +++ b/src/features/devices/types.ts @@ -0,0 +1,13 @@ +// src/features/devices/types.ts +export interface DeviceDataTable { + id: number; + device_code: string; + device_type: string; + longitude: number; + latitude: number; + address: string; + port_amount: number; + total_used_port: number; + status: string; + created_at: string; +} diff --git a/src/features/users/api/users.ts b/src/features/users/api/users.ts new file mode 100644 index 0000000..e6f43fd --- /dev/null +++ b/src/features/users/api/users.ts @@ -0,0 +1,128 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { apiClient, ApiResponse, extractApiErrors } from '../../../lib/api-client'; +// import { toast } from 'react-hot-toast'; + +// Tipe untuk model User +export interface User { + id: string; + name: string; + email: string; + role: string; + created_at: string; + updated_at: string; +} + +// Fungsi untuk mendapatkan daftar pengguna +export const getUsers = async (): Promise => { + const response = await apiClient.get>('/users'); + return response.data.data || []; +}; + +// Hook untuk query daftar pengguna +export const useUsers = () => { + return useQuery({ + queryKey: ['users'], + queryFn: getUsers, + }); +}; + +// Fungsi untuk mendapatkan detail pengguna +export const getUser = async (id: string): Promise => { + const response = await apiClient.get>(`/users/${id}`); + if (!response.data.data) { + throw new Error('User not found'); + } + return response.data.data; +}; + +// Hook untuk query detail pengguna +export const useUser = (id: string) => { + return useQuery({ + queryKey: ['users', id], + queryFn: () => getUser(id), + enabled: !!id, + }); +}; + +// Interface untuk data pembuatan atau pembaruan pengguna +export interface UserFormData { + name: string; + email: string; + password?: string; + role: string; +} + +// Fungsi untuk membuat pengguna baru +export const createUser = async (userData: UserFormData): Promise => { + const response = await apiClient.post>('/users', userData); + return response.data.data!; +}; + +// Hook untuk mutasi pembuatan pengguna +export const useCreateUser = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createUser, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }); + // toast.success('Pengguna berhasil dibuat'); + }, + onError: (error: unknown) => { + const errors = extractApiErrors(error); + Object.values(errors).forEach((message) => { + // toast.error(message); + }); + }, + }); +}; + +// Fungsi untuk memperbarui pengguna +export const updateUser = async ({ id, data }: { id: string; data: UserFormData }): Promise => { + const response = await apiClient.put>(`/users/${id}`, data); + return response.data.data!; +}; + +// Hook untuk mutasi pembaruan pengguna +export const useUpdateUser = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: updateUser, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['users'] }); + queryClient.invalidateQueries({ queryKey: ['users', data.id] }); + // toast.success('Pengguna berhasil diperbarui'); + }, + onError: (error: unknown) => { + const errors = extractApiErrors(error); + Object.values(errors).forEach((message) => { + // toast.error(message); + }); + }, + }); +}; + +// Fungsi untuk menghapus pengguna +export const deleteUser = async (id: string): Promise => { + await apiClient.delete>(`/users/${id}`); +}; + +// Hook untuk mutasi penghapusan pengguna +export const useDeleteUser = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deleteUser, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }); + // toast.success('Pengguna berhasil dihapus'); + }, + onError: (error: unknown) => { + const errors = extractApiErrors(error); + Object.values(errors).forEach((message) => { + // toast.error(message); + }); + }, + }); +}; \ No newline at end of file diff --git a/src/features/users/store.ts b/src/features/users/store.ts new file mode 100644 index 0000000..ce893af --- /dev/null +++ b/src/features/users/store.ts @@ -0,0 +1,19 @@ +import { create } from "zustand"; + +interface UsersState { + users: any[]; + selectedUser: any | null; + isLoading: boolean; + setUsers: (users: any[]) => void; + setSelectedUser: (user: any | null) => void; + setLoading: (loading: boolean) => void; +} + +export const useUsersStore = create((set) => ({ + users: [], + selectedUser: null, + isLoading: false, + setUsers: (users) => set({ users }), + setSelectedUser: (selectedUser) => set({ selectedUser }), + setLoading: (isLoading) => set({ isLoading }), +})); diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..e69de29 diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts new file mode 100644 index 0000000..c979cb2 --- /dev/null +++ b/src/lib/api-client.ts @@ -0,0 +1,73 @@ +import axios, { AxiosError, AxiosResponse } from "axios"; +import { useAuthStore } from "../features/auth/store"; + +const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000/api"; + +// Buat instance axios +export const apiClient = axios.create({ + baseURL: API_URL, + headers: { + "Content-Type": "application/json", + }, +}); + +// Interceptor untuk request +apiClient.interceptors.request.use( + (config) => { + const token = useAuthStore.getState().token; + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config; + }, (error: AxiosError) => { + return Promise.reject(error); + } +); + +// Interceptor untuk response +apiClient.interceptors.response.use( + (response: AxiosResponse) => { + return response; + }, + (error: AxiosError) => { + const { response } = error; + + // Handle expired token atau unauthorized + // if (response?.status === 401) { + // useAuthStore.getState().logout(); + // window.location.href = "/login"; + // } + + return Promise.reject(error); + } +); + +// Tipe untuk respon API berdasarkan format yang Anda berikan +export interface ApiResponse { + status: { + errors?: Record; + code: number; + description: string; + }; + data: T | null; +} + +// Helper untuk ekstrak error dari respon API +export const extractApiErrors = (error: unknown): Record => { + if (axios.isAxiosError(error)) { + if (error.response) { + + const responseData = error.response?.data as ApiResponse; + + return ( + responseData?.status?.errors || { + general: responseData?.status?.description || "Terjadi kesalahan pada server", + } + ); + } else { + return { general: error.message }; + } + } + + return { general: "Terjadi kesalahan" }; +}; diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..7d2b797 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,15 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./index.css"; +import App from "./App.tsx"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const queryClient = new QueryClient(); + +createRoot(document.getElementById("root")!).render( + + + + + +); diff --git a/src/pages/NotFoundPage.tsx b/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..5bc6257 --- /dev/null +++ b/src/pages/NotFoundPage.tsx @@ -0,0 +1,20 @@ +import { Link } from 'react-router'; + +const NotFoundPage = () => { + return ( +
        +
        +
        +

        404

        +

        Oops!

        +

        Halaman yang Anda cari tidak ditemukan. Mungkin halaman telah dipindahkan atau dihapus.

        + + Kembali ke Dashboard + +
        +
        +
        + ); +}; + +export default NotFoundPage; \ No newline at end of file diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx new file mode 100644 index 0000000..94e0544 --- /dev/null +++ b/src/pages/auth/LoginPage.tsx @@ -0,0 +1,137 @@ +import { useState } from "react"; +import { useNavigate } from "react-router"; +import { useLogin } from "../../features/auth/api/auth"; +import axios from "axios"; +import Input from "@/components/forms/Input"; +import Alert from "@/components/feedback/Alert"; +import Card from "@/components/data_display/Card/Card"; +import Button from "@/components/actions/Button"; + +const LoginPage = () => { + const navigate = useNavigate(); + const [errors, setErrors] = useState>({}); + + const { ...login } = useLogin({ + onError: (error) => { + setErrors(error) + } + }); + + const [formData, setFormData] = useState({ + username: "", + password: "", + }); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: value, + })); + // Hapus error untuk field yang sedang diubah + if (errors[name]) { + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[name]; + return newErrors; + }); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + try { + await login.mutateAsync(formData); + + } catch (error: unknown) { + if (axios.isAxiosError(error) && error.response?.status === 400) { + const apiErrors = error.response.data.status.errors; + setErrors(apiErrors || {}); + } + } + }; + + return ( +
        +
        + + +

        Login

        + {errors.general && ( + + + + } + > + + {errors.general} + + )} +
        +
        +
        + + + {errors.username && ( + + )} +
        + +
        + + + + {errors.password && ( + + )} +
        +
        +
        + +
        +
        +
        +
        +
        +
        + ); + +}; +export default LoginPage; diff --git a/src/pages/dashboard/DashboardPage.tsx b/src/pages/dashboard/DashboardPage.tsx new file mode 100644 index 0000000..26d5d51 --- /dev/null +++ b/src/pages/dashboard/DashboardPage.tsx @@ -0,0 +1,196 @@ +import { JSX, useEffect, useState } from 'react'; +import { useAuthStore } from '../../features/auth/store'; + +interface StatCard { + title: string; + value: string | number; + icon: JSX.Element; + color: string; +} + +const DashboardPage = () => { + const { user } = useAuthStore(); + const [stats, setStats] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + // Simulasi loading data statistik dari API + const loadStats = async () => { + setIsLoading(true); + try { + // Di sini Anda bisa mengganti dengan panggilan API yang sebenarnya + await new Promise(resolve => setTimeout(resolve, 1000)); + + const dummyStats: StatCard[] = [ + { + title: 'Total Users', + value: 1200, + icon: ( + + + + ), + color: 'bg-blue-500', + }, + { + title: 'Active Users', + value: 850, + icon: ( + + + + ), + color: 'bg-green-500', + }, + { + title: 'New Users', + value: 35, + icon: ( + + + + ), + color: 'bg-indigo-500', + }, + { + title: 'Admin Users', + value: 10, + icon: ( + + + + ), + color: 'bg-purple-500', + }, + ]; + + setStats(dummyStats); + } catch (error) { + console.error('Error loading stats:', error); + } finally { + setIsLoading(false); + } + }; + + loadStats(); + }, []); + + return ( +
        +
        +
        +

        Dashboard

        +

        + Selamat datang kembali, {user?.name || 'User'}! +

        +
        + + {isLoading ? ( +
        + +
        + ) : ( + <> + {/* Statistik Cards */} +
        + {stats.map((stat, index) => ( +
        +
        +
        +
        +

        {stat.title}

        +

        {stat.value.toLocaleString()}

        +
        +
        + {stat.icon} +
        +
        +
        +
        + ))} +
        + + {/* Recent Activity */} +
        +
        +

        Aktivitas Terkini

        + +
        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        UserAktivitasWaktu
        admin@example.comLogin ke sistem5 menit yang lalu
        user1@example.comUpdate profil10 menit yang lalu
        admin@example.comTambah user baru15 menit yang lalu
        user2@example.comLogin ke sistem20 menit yang lalu
        user3@example.comReset password30 menit yang lalu
        +
        +
        +
        + + {/* System Info */} +
        +
        +

        Informasi Sistem

        + +
        +
        +

        Teknologi

        +
          +
        • React + TypeScript
        • +
        • Tailwind CSS + DaisyUI
        • +
        • React Router
        • +
        • TanStack Query
        • +
        • Zustand
        • +
        • Axios
        • +
        +
        + +
        +

        Version

        +
          +
        • App Version: 1.0.0
        • +
        • React: 18.2.0
        • +
        • DaisyUI: 5.0.6
        • +
        • API: v1
        • +
        +
        +
        +
        +
        + + )} +
        +
        + ); +}; + +export default DashboardPage; \ No newline at end of file diff --git a/src/pages/device/DevicePage.tsx b/src/pages/device/DevicePage.tsx new file mode 100644 index 0000000..e98aaf8 --- /dev/null +++ b/src/pages/device/DevicePage.tsx @@ -0,0 +1,280 @@ +import { useMemo, useState } from "react"; +import { + useReactTable, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + getFilteredRowModel, + flexRender, + createColumnHelper, + SortingState, +} from "@tanstack/react-table"; +import Button from "@/components/actions/Button"; +import Card from "@/components/data_display/Card/Card"; +import { useDevices } from "@/features/devices/api/devices"; +import { DeviceDataTable } from "@/features/devices/types"; +import Loading from "@/components/feedback/Loading"; +import Alert from "@/components/feedback/Alert"; +import { Pencil } from "lucide-react"; + +import dayjs from "dayjs"; +import id from "dayjs/locale/id"; +import Dropdown from "@/components/actions/Dropdown/Dropdown"; + +dayjs.locale(id); + +export default function DevicePage() { + const { data: devices, isLoading, isError, error } = useDevices(); + // State untuk sorting dan filtering + const [sorting, setSorting] = useState([]); + const [globalFilter, setGlobalFilter] = useState(""); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, // Default 10 data per halaman + }); + + const columnHelper = createColumnHelper(); + + const columns = useMemo( + () => [ + columnHelper.display({ + id: "actions", + header: "", + cell: ({ row }) => ( + + + + + + + + + + + + Edit + + + + ), + size: 10, + }), + columnHelper.accessor("device_code", { + header: "Device Code", + cell: (info) => info.getValue(), + size: 120, + }), + columnHelper.accessor("device_type", { + header: "Device Type", + cell: (info) => info.getValue(), + size: 100, + }), + columnHelper.accessor("longitude", { + header: "Longitude", + cell: (info) => info.getValue().toFixed(4), + size: 100, + }), + columnHelper.accessor("latitude", { + header: "Latitude", + cell: (info) => info.getValue().toFixed(4), + size: 100, + }), + columnHelper.accessor("address", { + header: "Address", + cell: (info) => info.getValue(), + size: 200, + }), + columnHelper.accessor("port_amount", { + header: "Total Ports", + cell: (info) => info.getValue(), + size: 100, + }), + columnHelper.accessor("total_used_port", { + header: "Used Ports", + cell: (info) => info.getValue(), + size: 100, + }), + columnHelper.accessor("status", { + header: "Status", + cell: (info) => ( + + {info.getValue()} + + ), + size: 100, + }), + columnHelper.accessor("created_at", { + header: "Created At", + cell: (info) => dayjs(info.getValue()).format("DD MMMM YYYY, HH:mm"), + size: 150, + }), + ], + [] + ); + + const table = useReactTable({ + data: devices || [], + columns, + state: { + sorting, + globalFilter, + pagination, + }, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + }); + + return ( +
        +
        +

        Devices

        + +
        + + + + {isError && ( + + + + } + > + {error?.message || "Failed to load devices"} + + )} + +
        + {/* Search Input */} + setGlobalFilter(e.target.value)} + className="input input-bordered w-full max-w-xs" + /> + + {/* Page Size Dropdown */} + +
        + +
        + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {isLoading ? ( + + + + ) : null} + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
        + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} +
        + +
        + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
        +
        + + {/* Pagination */} +
        + + + Page{" "} + + {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} + + + +
        +
        +
        +
        + ); +} diff --git a/src/pages/users/UserFormPage.tsx b/src/pages/users/UserFormPage.tsx new file mode 100644 index 0000000..d1b50b9 --- /dev/null +++ b/src/pages/users/UserFormPage.tsx @@ -0,0 +1,206 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { useCreateUser, useUpdateUser, useUser, UserFormData } from '../../features/users/api/users'; +import { extractApiErrors } from '../../lib/api-client'; +import axios from 'axios'; + +const UserFormPage = () => { + const { id } = useParams(); + const navigate = useNavigate(); + const isEditing = !!id; + + // Query dan mutations + const { data: user, isLoading: isLoadingUser } = useUser(id || ''); + const createUser = useCreateUser(); + const updateUser = useUpdateUser(); + + // Form state + const [formData, setFormData] = useState({ + name: '', + email: '', + password: '', + role: 'user', // Default role + }); + + const [errors, setErrors] = useState>({}); + + // Isi form dengan data user jika mode edit + useEffect(() => { + if (isEditing && user) { + setFormData({ + name: user.name, + email: user.email, + role: user.role, + // Password kosong saat edit + }); + } + }, [isEditing, user]); + + const handleChange = ( + e: React.ChangeEvent + ) => { + const { name, value } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: value, + })); + + // Hapus error untuk field yang sedang diubah + if (errors[name]) { + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[name]; + return newErrors; + }); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + try { + if (isEditing && id) { + // Jika password kosong, hapus dari data yang dikirim + const dataToSubmit = { ...formData }; + if (!dataToSubmit.password) { + delete dataToSubmit.password; + } + + await updateUser.mutateAsync({ id, data: dataToSubmit }); + } else { + await createUser.mutateAsync(formData); + } + + navigate('/users'); + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 400) { + const apiErrors = error.response.data.status.errors; + setErrors(apiErrors || {}); + } else { + const extractedErrors = extractApiErrors(error); + setErrors(extractedErrors); + } + } + }; + + if (isEditing && isLoadingUser) { + return ( +
        + +
        + ); + } + + return ( +
        +
        +
        +

        + {isEditing ? 'Edit Pengguna' : 'Tambah Pengguna'} +

        + +
        +
        +
        + + + {errors.name && ( + + )} +
        + +
        + + + {errors.email && ( + + )} +
        + +
        + + + {errors.password && ( + + )} +
        + +
        + + + {errors.role && ( + + )} +
        +
        + +
        + + +
        +
        +
        +
        +
        + ); +}; + +export default UserFormPage; \ No newline at end of file diff --git a/src/pages/users/UsersListPage.tsx b/src/pages/users/UsersListPage.tsx new file mode 100644 index 0000000..d967d98 --- /dev/null +++ b/src/pages/users/UsersListPage.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react'; +import { Link } from 'react-router'; +import { useUsers, useDeleteUser } from '../../features/users/api/users'; + +const UsersListPage = () => { + const { data: users, isLoading, isError } = useUsers(); + const deleteUser = useDeleteUser(); + const [userToDelete, setUserToDelete] = useState(null); + + const handleDelete = async (id: string) => { + try { + await deleteUser.mutateAsync(id); + setUserToDelete(null); + } catch (error) { + console.error('Failed to delete user:', error); + } + }; + + if (isLoading) { + return ( +
        + +
        + ); + } + + if (isError) { + return ( +
        + + + + Gagal memuat data pengguna. +
        + ); + } + + return ( +
        +
        +

        Manajemen Pengguna

        + + Tambah Pengguna + +
        + +
        + + + + + + + + + + + {users?.map((user) => ( + + + + + + + ))} + +
        NamaEmailRoleAksi
        {user.name}{user.email}{user.role} + + Edit + + +
        +
        + + {/* Modal konfirmasi hapus */} + {userToDelete && ( +
        +
        +

        Konfirmasi Hapus

        +

        Apakah Anda yakin ingin menghapus pengguna ini?

        +
        + + +
        +
        +
        + )} +
        + ); +}; + +export default UsersListPage; \ No newline at end of file diff --git a/src/routes/ProtectedRoute.tsx b/src/routes/ProtectedRoute.tsx new file mode 100644 index 0000000..ca96c73 --- /dev/null +++ b/src/routes/ProtectedRoute.tsx @@ -0,0 +1,33 @@ +import { Navigate, Outlet } from 'react-router'; +import { useAuthStore } from '../features/auth/store'; + +interface ProtectedRouteProps { + requiresAuth?: boolean; + requiresAdmin?: boolean; +} + +const ProtectedRoute = ({ + requiresAuth = true, + requiresAdmin = false, +}: ProtectedRouteProps) => { + const { isAuthenticated, user } = useAuthStore(); + + // Jika memerlukan authentication tapi tidak authenticated, redirect ke login + if (requiresAuth && !isAuthenticated) { + return ; + } + + // Jika sudah authenticated tapi mengakses halaman login, redirect ke dashboard + if (!requiresAuth && isAuthenticated) { + return ; + } + + // Jika memerlukan role admin tapi bukan admin, redirect ke dashboard + if (requiresAdmin && user?.role !== 'admin') { + return ; + } + + return ; +}; + +export default ProtectedRoute; \ No newline at end of file diff --git a/src/routes/index.tsx b/src/routes/index.tsx new file mode 100644 index 0000000..8acfc76 --- /dev/null +++ b/src/routes/index.tsx @@ -0,0 +1,60 @@ +import { createBrowserRouter } from 'react-router'; +import ProtectedRoute from './ProtectedRoute'; +import DashboardLayout from '@/components/layouts/DashboardLayout'; +import LoginPage from '@/pages/auth/LoginPage'; +import DashboardPage from '@/pages/dashboard/DashboardPage'; +import UsersListPage from '@/pages/users/UsersListPage'; +import UserFormPage from '@/pages/users/UserFormPage'; +import NotFoundPage from '@/pages/NotFoundPage'; +import DevicePage from '@/pages/device/DevicePage'; + +const router = createBrowserRouter([ + // Public routes + { + element: , + children: [ + { + path: '/login', + element: , + }, + ], + }, + // Protected routes + { + element: , + children: [ + { + element: , + children: [ + { + path: '/dashboard', + element: , + }, + { + path: '/devices', + element: , + }, + { + path: '/users', + element: , + }, + { + path: '/users/create', + element: , + }, + { + path: '/users/:id/edit', + element: , + }, + ], + }, + ] + }, + // Not found route + { + path: '*', + element: , + } +]) + +export default router; \ No newline at end of file diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..31820c0 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + + "paths": { + "@/*": [ + "./src/*" + ], + } + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..db0becc --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..3d8af76 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +// https://vite.dev/config/ +export default defineConfig({ + resolve: { + alias: { + '@': '/src', + }, + }, + plugins: [tailwindcss(), react()], + server: { + allowedHosts: ['0ed6-103-14-20-142.ngrok-free.app'], + } +})