This commit is contained in:
ValdiANS
2025-09-26 11:06:31 +07:00
parent a5524686a6
commit 2e1b0fef2b
36 changed files with 8716 additions and 79 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+14
View File
@@ -0,0 +1,14 @@
@import 'tailwindcss';
@plugin "daisyui";
:root {
--color-primary: #1f74bf;
}
@theme {
--font-inter: var(--font-inter);
}
html {
scrollbar-gutter: initial;
}
+33
View File
@@ -0,0 +1,33 @@
import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({
variable: '--font-inter',
subsets: ['latin'],
});
export const viewport: Viewport = {
themeColor: '#1f74bf',
colorScheme: 'light',
initialScale: 1,
};
export const metadata: Metadata = {
title: 'Single Sign-On MBU Group',
description: 'Single Sign-On MBU',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang='en'>
<body className={`${inter.variable} antialiased font-inter`}>
{children}
</body>
</html>
);
}
+7
View File
@@ -0,0 +1,7 @@
export default function Home() {
return (
<main className='w-full h-full min-h-screen flex flex-row justify-center items-center'>
<h1>LTI ERP</h1>
</main>
);
}
+84
View File
@@ -0,0 +1,84 @@
import react, { JSX } from 'react';
import Link from 'next/link';
import { cn } from '@/lib/helper';
import { Color } from '@/types/theme';
interface ButtonProps extends react.ComponentProps<'button'> {
variant?: 'soft' | 'outline' | 'dash' | 'ghost' | 'link' | 'active';
color?: Color;
href?: string;
isLoading?: boolean;
}
const Button = ({
children,
type,
href,
variant,
color,
isLoading,
className,
disabled,
onClick,
}: ButtonProps) => {
const btnBaseClassName = cn(
'btn',
{
'btn-soft': variant === 'soft',
'btn-outline': variant === 'outline',
'btn-dash': variant === 'dash',
'btn-ghost': variant === 'ghost',
'btn-link': variant === 'link',
'btn-active': variant === 'active',
'btn-primary': color === 'primary',
'btn-secondary': color === 'secondary',
'btn-accent': color === 'accent',
'btn-neutral': color === 'neutral',
'btn-info': color === 'info',
'btn-success': color === 'success',
'btn-warning': color === 'warning',
'btn-error': color === 'error',
},
'h-fit justify-center items-center gap-2 rounded-xl p-2 text-base transition-all'
);
return (
<>
{!href && (
<button
type={type}
onClick={onClick}
disabled={disabled}
className={cn(
btnBaseClassName,
'disabled:pointer-events-auto! disabled:cursor-not-allowed!',
className
)}
>
{!isLoading && children}
{isLoading && <span className='loading loading-dots loading-md' />}
</button>
)}
{href && (
<Link
href={disabled ? '#' : href}
aria-disabled={disabled}
className={cn(
btnBaseClassName,
{ 'pointer-events-auto cursor-not-allowed': disabled },
className
)}
>
{!isLoading && children}
{isLoading && <span className='loading loading-dots loading-xl' />}
</Link>
)}
</>
);
};
export default Button;
+60
View File
@@ -0,0 +1,60 @@
'use client';
import { ReactNode } from 'react';
import { cn } from '@/lib/helper';
interface DrawerProps {
children?: ReactNode;
sidebarContent?: ReactNode;
open: boolean;
setOpen: (newOpenState: boolean) => void;
openOnLarge?: boolean;
}
const Drawer = ({
children,
sidebarContent,
open,
setOpen,
openOnLarge,
}: DrawerProps) => {
const toggleDrawer = () => {
setOpen(!open);
};
const closeDrawer = () => {
setOpen(false);
};
return (
<div
className={cn('drawer', {
'lg:drawer-open': openOnLarge,
})}
>
<input
type='checkbox'
checked={open}
onChange={toggleDrawer}
className='drawer-toggle'
/>
<div className='drawer-content'>{children}</div>
<div className='drawer-side border-r border-solid border-gray-200 z-20'>
<label
aria-label='close sidebar'
className='drawer-overlay'
onClick={closeDrawer}
/>
<div className='min-h-full w-full max-w-[300px] lg:w-[300px] bg-base-100'>
{sidebarContent}
</div>
</div>
</div>
);
};
export default Drawer;
+54
View File
@@ -0,0 +1,54 @@
'use client';
import { Icon } from '@iconify/react';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import Button from '@/components/Button';
interface NavbarProps {
title: string;
toggleSidebar?: () => void;
}
const Navbar = ({ title, toggleSidebar }: NavbarProps) => {
return (
<div className='navbar px-4 bg-base-100 shadow-sm'>
<div className='flex-1'>
<div className='flex flex-row items-center gap-4'>
{toggleSidebar && (
<Button onClick={toggleSidebar} className='block lg:hidden'>
<Icon
icon='material-symbols:menu-rounded'
width={24}
height={24}
/>
</Button>
)}
<span className='font-bold text-xl text-primary'>{title}</span>
</div>
</div>
<div className='flex gap-2'>
<div className='dropdown dropdown-end'>
<div
tabIndex={0}
role='button'
className='btn btn-ghost btn-circle avatar'
>
<div className='w-10 rounded-full border grid place-items-center'>
<Icon icon='uil:user' width={40} height={40} />
</div>
</div>
<Menu className='dropdown-content w-52 mt-3 p-2 bg-base-100 shadow rounded-box menu-sm'>
<MenuItem title='Settings' href='#' />
<MenuItem title='Logout' href='#' />
</Menu>
</div>
</div>
</div>
);
};
export default Navbar;
+324
View File
@@ -0,0 +1,324 @@
'use client';
import { ReactNode } from 'react';
import { Icon } from '@iconify/react';
import { cn } from '@/lib/helper';
const range = (start: number, end: number) =>
Array.from({ length: end - start + 1 }, (_, i) => i + start);
const PaginationButton = ({
content = '',
disabled = false,
onClick = () => {},
}: {
content?: ReactNode;
disabled?: boolean;
onClick?: () => void;
}) => (
<button
className={cn(
'join-item btn btn-ghost p-2.5 rounded-lg text-sm font-medium text-gray-500 aspect-square',
'disabled:text-gray-700 disabled:pointer-events-auto! disabled:cursor-not-allowed! disabled:bg-gray-50 disabled:active:translate-y-0'
)}
disabled={disabled}
onClick={onClick}
>
{content}
</button>
);
const EtcPaginationButton = ({
startPage = 0,
endPage = 0,
onPageItemClick = (pageNumber: number) => {},
}) => {
const pages = range(startPage, endPage);
return (
<>
{startPage > 0 && endPage >= startPage && (
<div className='dropdown dropdown-top dropdown-center'>
<button
tabIndex={0}
role='button'
className={cn(
'join-item btn btn-ghost p-2.5 rounded-lg text-sm font-medium text-gray-500 aspect-square'
)}
>
...
</button>
<div className='dropdown-content'>
<ul
tabIndex={0}
className='menu bg-base-100 rounded-lg z-1 w-fit min-w-max max-h-64 p-1 shadow-sm mb-2 overflow-y-auto flex-nowrap'
>
{pages.map((pageNumber) => (
<li key={pageNumber}>
<PaginationButton
content={pageNumber}
onClick={() => onPageItemClick(pageNumber)}
/>
</li>
))}
</ul>
</div>
</div>
)}
{(startPage === 0 || endPage < startPage) && (
<button
disabled
className={cn(
'join-item btn btn-ghost p-2.5 rounded-lg text-sm font-medium text-gray-500 aspect-square'
)}
>
...
</button>
)}
</>
);
};
const Pagination = ({
currentPage = 1,
totalItems = 0,
itemsPerPage = 10,
onPageChange = (pageNumber: number) => {},
onPrevPage = () => {},
onNextPage = () => {},
}) => {
const totalPages =
Math.ceil(totalItems / itemsPerPage) === 0
? 1
: Math.ceil(totalItems / itemsPerPage);
const pageChangeHandler = (pageNumber: number) => onPageChange(pageNumber);
return (
<div>
<div className='join w-full justify-between items-center gap-3'>
<button
disabled={currentPage === 1}
onClick={onPrevPage}
className={cn(
'join-item btn btn-outline group px-3 py-2 text-sm font-semibold rounded-lg border border-gray-300 shadow-xs hidden sm:flex justify-center items-center gap-1.5',
'disabled:bg-[initial]! disabled:text-gray-400 disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
)}
>
<Icon
icon='uil:arrow-left'
width={20}
height={20}
className='text-gray-400 group-disabled:text-gray-300'
/>{' '}
Previous
</button>
{totalPages <= 7 && (
<div className='join-item join gap-0.5'>
{range(1, totalPages).map((pageNumber) => (
<PaginationButton
key={pageNumber}
content={pageNumber}
disabled={currentPage === pageNumber}
onClick={() => pageChangeHandler(pageNumber)}
/>
))}
</div>
)}
{totalPages > 7 && (
<div className='join-item join gap-0.5'>
<PaginationButton
content={1}
disabled={currentPage === 1}
onClick={() => pageChangeHandler(1)}
/>
{totalPages >= 2 &&
(currentPage <= 3 || currentPage >= totalPages - 2) && (
<PaginationButton
content={2}
disabled={currentPage === 2}
onClick={() => pageChangeHandler(2)}
/>
)}
{totalPages >= 2 &&
currentPage > 3 &&
currentPage < totalPages - 2 && (
<EtcPaginationButton
startPage={2}
endPage={currentPage - 2}
onPageItemClick={pageChangeHandler}
/>
)}
{totalPages >= 3 &&
(currentPage <= 4 || currentPage >= totalPages - 2) &&
currentPage !== totalPages - 2 && (
<PaginationButton
content={3}
disabled={currentPage === 3}
onClick={() => pageChangeHandler(3)}
/>
)}
{totalPages >= 7 &&
(currentPage <= 2 || currentPage >= totalPages - 2) && (
<EtcPaginationButton
startPage={
currentPage <= 2
? currentPage + 2
: currentPage === totalPages - 2
? 3
: currentPage >= totalPages - 1
? 4
: 1
}
endPage={
currentPage <= 2 || currentPage >= totalPages - 1
? totalPages - 3
: currentPage === totalPages - 2
? totalPages - 4
: 2
}
onPageItemClick={pageChangeHandler}
/>
)}
{totalPages >= 3 &&
currentPage > 4 &&
currentPage < totalPages - 1 && (
<PaginationButton
content={currentPage - 1}
onClick={() => pageChangeHandler(currentPage - 1)}
/>
)}
{totalPages >= 7 &&
currentPage > 3 &&
currentPage < totalPages - 2 && (
<PaginationButton content={currentPage} disabled />
)}
{totalPages >= 5 &&
currentPage > 2 &&
currentPage < totalPages - 2 && (
<PaginationButton
content={currentPage + 1}
onClick={() => pageChangeHandler(currentPage + 1)}
/>
)}
{totalPages >= 5 &&
(currentPage <= 2 || currentPage >= totalPages - 2) && (
<PaginationButton
content={totalPages - 2}
disabled={currentPage === totalPages - 2}
onClick={() => pageChangeHandler(totalPages - 2)}
/>
)}
{totalPages >= 6 &&
currentPage > 2 &&
currentPage < totalPages - 3 && (
<EtcPaginationButton
startPage={
currentPage <= 3
? currentPage + 2
: currentPage >= 4
? currentPage + 2
: 1
}
endPage={
currentPage <= 3
? totalPages - 2
: currentPage >= 4
? totalPages - 1
: 0
}
onPageItemClick={pageChangeHandler}
/>
)}
{totalPages >= 6 &&
(currentPage <= 3 || currentPage >= totalPages - 3) && (
<PaginationButton
content={totalPages - 1}
disabled={currentPage === totalPages - 1}
onClick={() => pageChangeHandler(totalPages - 1)}
/>
)}
{totalPages >= 7 && (
<PaginationButton
content={totalPages}
disabled={currentPage === totalPages}
onClick={() => pageChangeHandler(totalPages)}
/>
)}
</div>
)}
<button
disabled={currentPage === totalPages}
onClick={onNextPage}
className={cn(
'join-item btn btn-outline group px-3 py-2 text-sm font-semibold rounded-lg border border-gray-300 shadow-xs hidden sm:flex justify-center items-center gap-1.5',
'disabled:bg-[initial]! disabled:text-gray-400 disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
)}
>
Next{' '}
<Icon
icon='uil:arrow-right'
width={20}
height={20}
className='text-gray-400 group-disabled:text-gray-300'
/>
</button>
</div>
<div className='flex gap-2 mt-2 sm:hidden'>
<button
disabled={currentPage === 1}
onClick={onPrevPage}
className={cn(
'join-item btn btn-outline group px-3 py-2 text-sm font-semibold rounded-lg border border-gray-300 shadow-xs flex justify-center items-center gap-1.5',
'disabled:bg-[initial]! disabled:text-gray-400 disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
)}
>
<Icon
icon='uil:arrow-left'
width={20}
height={20}
className='text-gray-400 group-disabled:text-gray-300'
/>{' '}
Previous
</button>
<button
disabled={currentPage === totalPages}
onClick={onNextPage}
className={cn(
'join-item btn btn-outline group px-3 py-2 text-sm font-semibold rounded-lg border border-gray-300 shadow-xs flex justify-center items-center gap-1.5',
'disabled:bg-[initial]! disabled:text-gray-400 disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
)}
>
Next{' '}
<Icon
icon='uil:arrow-right'
width={20}
height={20}
className='text-gray-400 group-disabled:text-gray-300'
/>
</button>
</div>
</div>
);
};
export default Pagination;
+199
View File
@@ -0,0 +1,199 @@
'use client';
import { useState } from 'react';
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
TableOptions,
useReactTable,
ColumnDef,
} from '@tanstack/react-table';
import { rankItem } from '@tanstack/match-sorter-utils';
import { Icon } from '@iconify/react';
import Pagination from '@/components/Pagination';
import { cn } from '@/lib/helper';
interface TableClassNames {
containerClassName?: string;
tableWrapperClassName?: string;
tableClassName?: string;
tableHeaderClassName?: string;
headerRowClassName?: string;
headerColumnClassName?: string;
tableBodyClassName?: string;
bodyRowClassName?: string;
bodyColumnClassName?: string;
paginationClassName?: string;
}
// Type for the Table component props
interface TableProps<TData extends object> {
data: TData[];
columns: ColumnDef<TData, any>[];
pageSize?: number;
isLoading?: boolean;
fuzzySearchValue?: string | null;
onFuzzySearchValueChange?: (value: string) => void;
className?: TableClassNames;
}
const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}];
const fuzzyFilter = (
row: any,
columnId: string,
value: string,
addMeta: (meta: any) => void
) => {
const itemRank = rankItem(row.getValue(columnId), value);
addMeta({ itemRank });
return itemRank.passed;
};
const Table = <TData extends object>({
data = [],
columns = [],
pageSize = 10,
isLoading = false,
fuzzySearchValue = null,
onFuzzySearchValueChange = () => {},
className = {
containerClassName: '',
tableWrapperClassName: '',
tableClassName: '',
tableHeaderClassName: '',
headerRowClassName: '',
headerColumnClassName: '',
tableBodyClassName: '',
bodyRowClassName: '',
bodyColumnClassName: '',
paginationClassName: '',
},
}: TableProps<TData>) => {
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: pageSize,
});
const tableOptions: TableOptions<TData> = {
columns,
data: isLoading ? (DUMMY_SKELETON_DATA as TData[]) : data, // Type assertion
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
state: {
pagination,
globalFilter: fuzzySearchValue,
},
filterFns: {
fuzzy: fuzzyFilter,
},
globalFilterFn: fuzzyFilter,
};
if (fuzzySearchValue !== null) {
tableOptions.onGlobalFilterChange = onFuzzySearchValueChange;
tableOptions.getFilteredRowModel = getFilteredRowModel();
}
const table = useReactTable(tableOptions);
return (
<div className={className.containerClassName}>
<div className={className.tableWrapperClassName}>
<table className={className.tableClassName}>
<thead className={className.tableHeaderClassName}>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className={className.headerRowClassName}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
colSpan={header.colSpan}
onClick={header.column.getToggleSortingHandler()}
className={cn(
header.column.getCanSort()
? 'cursor-pointer select-none'
: '',
className.headerColumnClassName
)}
>
<div className='flex items-center gap-1'>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{header.column.getCanSort() && (
<div className='flex items-center'>
<Icon
icon='lucide:arrow-up'
width={12}
height={12}
className={cn(
'transition-all ease-in-out duration-200',
header.column.getIsSorted() === 'asc'
? 'text-black'
: 'text-black/30'
)}
/>
<Icon
icon='lucide:arrow-down'
width={12}
height={12}
className={cn(
'transition-all ease-in-out duration-200',
header.column.getIsSorted() === 'desc'
? 'text-black'
: 'text-black/30'
)}
/>
</div>
)}
</div>
</th>
))}
</tr>
))}
</thead>
<tbody className={className.tableBodyClassName}>
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className={className.bodyRowClassName}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className={className.bodyColumnClassName}>
{!isLoading &&
flexRender(cell.column.columnDef.cell, cell.getContext())}
{isLoading && <div className='skeleton w-full h-4' />}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{data.length > 0 && !isLoading && (
<div className={cn('mt-5', className.paginationClassName)}>
<Pagination
totalItems={table.getRowCount()}
itemsPerPage={table.getState().pagination.pageSize}
currentPage={table.getState().pagination.pageIndex + 1}
onPrevPage={() => table.previousPage()}
onNextPage={() => table.nextPage()}
onPageChange={(pageNumber) =>
table.setPageIndex(pageNumber ? pageNumber - 1 : 0)
}
/>
</div>
)}
</div>
);
};
export default Table;
+88
View File
@@ -0,0 +1,88 @@
import { Ref } from 'react';
import { cn } from '@/lib/helper';
import { TextInputProps } from '@/components/input/TextInput';
interface FileInputProps
extends Omit<
TextInputProps,
| 'type'
| 'value'
| 'isValid'
| 'startAdornment'
| 'endAdornment'
| 'isLoading'
> {
ref?: Ref<HTMLInputElement>;
accept?: string;
className?: {
wrapper?: string;
label?: string;
input?: string;
};
}
const FileInput = ({
ref,
label,
bottomLabel,
name,
placeholder,
accept = '*',
className,
isError,
errorMessage,
disabled = false,
onChange,
onBlur,
readOnly = false,
}: FileInputProps) => {
return (
<div
className={cn(
'w-full flex flex-col gap-2 text-start',
className?.wrapper
)}
>
{label && (
<label
htmlFor={name}
className={cn(
'w-full text-sm font-normal leading-5',
{
'text-error': isError,
},
className?.label
)}
>
{label}
</label>
)}
<input
ref={ref}
type='file'
accept={accept}
id={name}
name={name}
placeholder={placeholder}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
className={cn(
'grow file-input w-full h-12 rounded-lg!',
className?.input
)}
readOnly={readOnly}
/>
{bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
</div>
);
};
export default FileInput;
+47
View File
@@ -0,0 +1,47 @@
'use client';
import { useState } from 'react';
import { Icon } from '@iconify/react';
import TextInput, { TextInputProps } from '@/components/input/TextInput';
import Button from '@/components/Button';
interface PasswordInputProps
extends Omit<TextInputProps, 'type' | 'startAdornment' | 'endAdornment'> {}
const PasswordInput = (props: PasswordInputProps) => {
const [type, setType] = useState('password');
const showPasswordHandler = () => {
setType((prevType) => {
if (prevType === 'password') return 'text';
return 'password';
});
};
return (
<TextInput
{...props}
type={type}
endAdornment={
<Button
tabIndex={-1}
type='button'
variant='ghost'
onClick={showPasswordHandler}
className='btn btn-ghost w-fit h-fit p-2 rounded-full'
disabled={props.disabled}
>
<Icon
icon={type === 'password' ? 'mdi:eye' : 'mdi:eye-off'}
width={16}
height={16}
/>
</Button>
}
/>
);
};
export default PasswordInput;
+203
View File
@@ -0,0 +1,203 @@
'use client';
import { ComponentType, ReactNode, useMemo } from 'react';
import Select, { OptionProps, GroupBase } from 'react-select';
import makeAnimated from 'react-select/animated';
import { cn } from '@/lib/helper';
export interface OptionType {
value: string | number;
label: string;
className?: string; // for multi select
labelClassName?: string; // for multi select
}
export type OptionComponent<T = OptionType> = ComponentType<
OptionProps<T, boolean, GroupBase<T>>
>;
interface SelectInputProps<T = OptionType> {
label?: ReactNode;
bottomLabel?: ReactNode;
value?: T | T[];
onChange?: (val: T | T[] | null) => void;
options: T[];
optionComponent?: OptionComponent<T>;
isDisabled?: boolean;
isLoading?: boolean;
isClearable?: boolean;
isRtl?: boolean;
isSearchable?: boolean;
isMulti?: boolean;
placeholder?: string;
required?: boolean;
className?: {
wrapper?: string;
label?: string;
select?: string;
};
isError?: boolean;
errorMessage?: string;
isAnimated?: boolean;
openMenu?: boolean;
}
const animatedComponents = makeAnimated();
const SelectInput = <T extends OptionType>({
label,
bottomLabel,
value,
onChange,
options,
optionComponent,
isDisabled,
isLoading,
isClearable,
isRtl,
isSearchable = true,
isMulti,
placeholder,
required,
className,
isError,
errorMessage,
isAnimated = true,
openMenu,
}: SelectInputProps) => {
const components = useMemo(() => {
const base = isAnimated ? animatedComponents : {};
return {
...base,
IndicatorSeparator: () => null,
};
}, [isAnimated]);
return (
<div
className={cn(
'w-full flex flex-col gap-2 text-start',
className?.wrapper
)}
>
{label && (
<span
className={cn(
'w-full text-sm font-normal leading-5',
{
'text-error': isError,
},
className?.label
)}
>
{label}
{required && (
<>
{' '}
<span className='tooltip tooltip-error' data-tip='required'>
<span className='text-error'> *</span>
</span>
</>
)}
</span>
)}
<Select
instanceId='select'
value={value}
onChange={(val) => onChange?.(val as T)}
options={options}
menuIsOpen={openMenu}
isMulti={isMulti}
isDisabled={isDisabled}
isLoading={isLoading}
isClearable={isClearable}
isRtl={isRtl}
isSearchable={isSearchable}
placeholder={placeholder}
className={cn('w-full', className)}
classNames={{
control: ({ isFocused, isDisabled }) =>
cn(
'w-full min-h-12! rounded-lg! border bg-white transition-shadow cursor-pointer!',
{
'border-red-500! focus-within:border-red-500 focus-within:ring-2 focus-within:ring-red-200':
isError,
'border-indigo-500 ring-2 ring-indigo-200': isFocused,
'border-gray-300': !isError && !isFocused,
'bg-gray-100 text-gray-400 cursor-not-allowed': isDisabled,
}
),
valueContainer: () => cn('flex-1 px-4! py-2! gap-1'),
placeholder: () =>
cn({ 'text-gray-400': !isError, 'text-red-300!': isError }),
singleValue: () =>
cn({ 'text-gray-900': !isError, 'text-error!': isError }),
input: () => cn('text-gray-900'),
indicatorsContainer: () => cn('flex items-center gap-1 pr-2'),
indicatorSeparator: () => cn('mx-1 h-4 w-px bg-gray-200'),
clearIndicator: () => cn('p-1 rounded-md hover:bg-gray-100'),
dropdownIndicator: ({ isFocused }) =>
cn('p-1 rounded-md hover:bg-gray-100', {
'text-gray-900': isFocused,
'text-gray-500': !isFocused,
'text-error!': isError,
}),
menu: () =>
cn(
'border border-gray-200 rounded-lg bg-white shadow-lg rounded-lg!'
),
menuList: () => cn('p-2! max-h-60 overflow-auto'),
groupHeading: () =>
cn('ml-2 mt-2 mb-1 text-xs font-medium text-gray-500'),
option: ({ isFocused, isSelected, isDisabled }) =>
cn('mt-1 px-3 py-2 rounded-md cursor-pointer! select-none', {
'text-gray-300': isDisabled,
'bg-indigo-600 text-white': isFocused,
'text-gray-700': !isDisabled && !isFocused,
'active:bg-indigo-50': !isDisabled,
'bg-blue-500!': isSelected,
}),
noOptionsMessage: () => cn('px-3 py-2 text-gray-500'),
loadingMessage: () => cn('px-3 py-2 text-gray-500'),
multiValue: ({ getValue, index }) => {
const selectedValues = getValue();
return cn(
'bg-indigo-50 rounded-md py-0.5 pl-2 pr-1 flex items-center gap-1 rounded-md!',
selectedValues[index]?.className
);
},
multiValueLabel: ({ getValue, index }) => {
const selectedValues = getValue();
return cn('text-indigo-700', selectedValues[index]?.labelClassName);
},
multiValueRemove: () =>
cn('p-1 rounded-sm! hover:bg-indigo-100 hover:text-indigo-800'),
}}
components={{
...components,
...(optionComponent ? { Option: optionComponent } : {}),
}}
// make the menu float above modals/etc.
menuPortalTarget={
typeof document !== 'undefined' ? document.body : undefined
}
styles={{
// Tailwind can't set inline z-index on a portal; use styles here:
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
}}
/>
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
{!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)}
</div>
);
};
export default SelectInput;
+130
View File
@@ -0,0 +1,130 @@
'use client';
import {
ChangeEventHandler,
FocusEventHandler,
HTMLInputTypeAttribute,
ReactNode,
} from 'react';
import { cn } from '@/lib/helper';
export interface TextInputProps {
type?: HTMLInputTypeAttribute;
label?: string;
bottomLabel?: string;
name: string;
value?: string | number;
placeholder?: string;
className?: {
wrapper?: string;
label?: string;
inputWrapper?: string;
input?: string;
};
isError?: boolean;
isValid?: boolean;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
isLoading?: boolean;
errorMessage?: string;
startAdornment?: ReactNode;
endAdornment?: ReactNode;
onChange?: ChangeEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>;
}
const TextInput = ({
type = 'text',
label,
bottomLabel,
name,
value,
placeholder,
className,
isError,
isValid,
errorMessage,
startAdornment,
endAdornment,
disabled = false,
required = false,
onChange,
onBlur,
readOnly = false,
isLoading = false,
}: TextInputProps) => {
return (
<div
className={cn(
'w-full flex flex-col gap-2 text-start',
className?.wrapper
)}
>
{label && (
<label
htmlFor={name}
className={cn(
'w-full text-sm font-normal leading-5',
{
'text-error': isError,
},
className?.label
)}
>
{label}
{required && (
<>
{' '}
<span className='tooltip tooltip-error' data-tip='required'>
<span className='text-error'> *</span>
</span>
</>
)}
</label>
)}
<div
className={cn(
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded-lg! outline-none! transition-all duration-200',
{
'border-error': isError,
'border-success!': isValid,
},
className?.inputWrapper
)}
>
{startAdornment && startAdornment}
<input
type={type}
id={name}
name={name}
placeholder={placeholder}
value={value}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
className={cn('grow', className?.input)}
readOnly={readOnly}
/>
{(isLoading || endAdornment) && (
<div className='flex flex-row gap-2'>
{isLoading && <span className='loading loading-spinner' />}
{endAdornment && endAdornment}
</div>
)}
</div>
{!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
</div>
);
};
export default TextInput;
+16
View File
@@ -0,0 +1,16 @@
import { ReactNode } from 'react';
import { cn } from '@/lib/helper';
interface MenuProps {
children?: ReactNode;
className?: string;
}
const Menu = ({ children, className }: MenuProps) => {
return (
<ul className={cn('menu w-full p-0 gap-0.5', className)}>{children}</ul>
);
};
export default Menu;
+64
View File
@@ -0,0 +1,64 @@
import Link from 'next/link';
import { Icon } from '@iconify/react';
import { cn } from '@/lib/helper';
interface MenuItemProps {
title: string;
href?: string;
icon?: string;
active?: boolean;
onClick?: () => void;
className?: string;
}
const MenuItem = ({
title,
href,
icon,
active = false,
className,
onClick,
}: MenuItemProps) => {
const menuItemBaseClassName = cn(
'group px-3 py-2 text-base text-black font-semibold flex flex-row items-center rounded-md',
{ 'bg-gray-100 border-l-2 border-l-primary': active },
className
);
const menuItemContent = (
<>
{icon && (
<Icon
icon={icon}
width={20}
height={20}
className={cn({
'text-gray-400': !active,
'text-black': active,
})}
/>
)}
<span
className={cn({ 'opacity-40': !active }, 'group-active:opacity-100')}
>
{title}
</span>
</>
);
return (
<li onClick={onClick}>
{href && (
<Link href={href} className={menuItemBaseClassName}>
{menuItemContent}
</Link>
)}
{!href && <a className={menuItemBaseClassName}>{menuItemContent}</a>}
</li>
);
};
export default MenuItem;
View File
+7
View File
@@ -0,0 +1,7 @@
import { BaseApiResponse, SuccessApiResponse } from '@/types/api';
export const isResponseSuccess = <T>(
res?: BaseApiResponse<T>
): res is SuccessApiResponse<T> => {
return res?.status === 'success';
};
+29
View File
@@ -0,0 +1,29 @@
import moment from 'moment';
import { twMerge } from 'tailwind-merge';
import clsx, { ClassValue } from 'clsx';
export const sleep = (ms: number = 1000) =>
new Promise((resolve) => setTimeout(resolve, ms));
export const formatDate = (date: moment.MomentInput, format?: string) => {
return moment(date).format(format);
};
export const cn = (...inputs: ClassValue[]) => {
return twMerge(clsx(inputs));
};
export const formatCurrency = (
value: number | bigint | Intl.StringNumericLiteral,
currency = 'USD',
locale = 'en-US',
minimumFractionDigits = 0,
maximumFractionDigits = 2
) => {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
minimumFractionDigits,
maximumFractionDigits,
}).format(value);
};
+13
View File
@@ -0,0 +1,13 @@
import * as Yup from 'yup';
export const EmailSchema = Yup.string().email('Format email invalid!');
export const PasswordSchema = Yup.string()
.min(8, 'Password harus setidaknya memiliki 8 karakter')
.matches(/[a-z]/, 'Harus memiliki setidaknya 1 huruf kecil')
.matches(/[A-Z]/, 'Harus memiliki setidaknya 1 huruf besar')
.matches(/\d/, 'Harus memiliki setidaknya 1 angka')
.matches(
/[@$!%*?&^#()_\-+={}[\]|:;"'<>,.\\/]/,
'Harus memiliki setidaknya 1 karakter spesial'
);
View File
+25
View File
@@ -0,0 +1,25 @@
import { create } from 'zustand';
import { UserWithRoles } from '@/types/api';
type AuthStore = {
user?: UserWithRoles;
isLoadingUser?: boolean;
setUser: (newUserData?: UserWithRoles) => void;
setIsLoadingUser: (isLoading?: boolean) => void;
};
const useAuthStore = create<AuthStore>()((set) => ({
user: undefined,
isLoadingUser: false,
setUser: (newUserData) => set({ user: newUserData }),
setIsLoadingUser: (isLoading) => set({ isLoadingUser: Boolean(isLoading) }),
}));
export const useAuth = () => {
const { user, setUser } = useAuthStore();
return {
user,
setUser,
};
};
+22
View File
@@ -0,0 +1,22 @@
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
export type AuthMode = 'none' | 'cookie' | 'bearer';
export type RequestOptions<B = unknown> = {
method?: HttpMethod;
body?: B;
query?: Record<string, unknown>;
headers?: Record<string, string>;
auth?: AuthMode; // 'cookie' | 'bearer' | 'none'
token?: string; // required if auth === 'bearer'
timeoutMs?: number;
};
export class HttpError extends Error {
constructor(
public status: number,
public code?: string,
public data?: unknown
) {
super(`HTTP ${status}${code ? ` ${code}` : ''}`);
}
}
+63
View File
@@ -0,0 +1,63 @@
import axios from 'axios';
import type { AxiosRequestConfig } from 'axios';
import { HttpError, RequestOptions } from '@/services/http/base';
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? '';
const axiosClient = axios.create({ baseURL: BASE_URL, timeout: 10_000 });
export async function httpClient<T, B = unknown>(
path: string,
opts: RequestOptions<B> = {}
): Promise<T> {
const isCookieAuth = opts.auth === 'cookie';
const isBearerAuth = opts.auth === 'bearer' && !!opts.token;
const config: AxiosRequestConfig = {
url: path,
method: opts.method ?? 'GET',
params: opts.query,
data: opts.body,
timeout: opts.timeoutMs ?? 10_000,
withCredentials: isCookieAuth,
headers: {
'Content-Type': 'application/json',
...(opts.headers ?? {}),
...(isBearerAuth ? { Authorization: `Bearer ${opts.token}` } : {}),
},
};
try {
const res = await axiosClient.request<T>(config);
return res.data;
} catch (e: any) {
if (axios.isAxiosError(e)) {
throw e;
}
throw new HttpError(e.response?.status ?? 0, e.code, e.response?.data);
}
}
type SWRHttpKey<B = unknown> =
| string
| [path: string, opts?: RequestOptions<B>]
| { path: string; opts?: RequestOptions<B> };
export async function httpClientFetcher<T = unknown, B = unknown>(
key: SWRHttpKey<B>
): Promise<T> {
if (!key) throw new Error('Invalid SWR key');
let path: string;
let opts: RequestOptions<B> | undefined;
if (typeof key === 'string') {
path = key;
} else if (Array.isArray(key)) {
[path, opts] = key;
} else {
({ path, opts } = key);
}
return httpClient<T, B>(path, opts);
}
+12
View File
@@ -0,0 +1,12 @@
'use client';
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { UIStore } from '@/types/stores';
export const useUiStore = create<UIStore>()(
devtools((...args) => ({}), {
name: 'UIStore',
})
);
+35
View File
@@ -0,0 +1,35 @@
type ErrorApiResponse = {
code: number;
status: 'error';
message: string;
errors?: { [key: string]: string };
};
type SuccessApiResponse<T = unknown> = {
code: number;
status: 'success';
message: string;
meta?: {
page: number;
limit: number;
total_pages: number;
total_results: number;
};
data: T;
};
type BaseApiResponse<T = unknown> = ErrorApiResponse | SuccessApiResponse<T>;
export type User = {
id: number;
email: string;
npk: string;
name: string;
image?: string | null;
created_at: string;
updated_at: string;
};
export type UserWithRoles = User & {
roles: RoleWithPermissions[];
};
+1
View File
@@ -0,0 +1 @@
export type UIStore = {};
+11
View File
@@ -0,0 +1,11 @@
type Color =
| 'primary'
| 'secondary'
| 'accent'
| 'neutral'
| 'info'
| 'success'
| 'warning'
| 'error';
export { Color };