From 7e57debb987d8a23079ca1b3546fb3177ebe6033 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 29 Sep 2025 11:58:19 +0700 Subject: [PATCH 001/174] chore: update MenuItem component --- src/components/menu/MenuItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/menu/MenuItem.tsx b/src/components/menu/MenuItem.tsx index 55206d20..5046f8ff 100644 --- a/src/components/menu/MenuItem.tsx +++ b/src/components/menu/MenuItem.tsx @@ -33,7 +33,7 @@ const MenuItem = ({ icon={icon} width={20} height={20} - className={cn({ + className={cn('group-active:text-[inherit]', { 'text-gray-400': !active, 'text-black': active, })} From 978285021e1a19d13c03be28f8642550ed2637d1 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 29 Sep 2025 11:58:57 +0700 Subject: [PATCH 002/174] feat: add Collapse component --- src/components/Collapse.tsx | 132 ++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 src/components/Collapse.tsx diff --git a/src/components/Collapse.tsx b/src/components/Collapse.tsx new file mode 100644 index 00000000..d076888d --- /dev/null +++ b/src/components/Collapse.tsx @@ -0,0 +1,132 @@ +import React, { useCallback, useId, useMemo, useState } from 'react'; +import { cn } from '@/lib/helper'; + +export type CollapseVariant = 'default' | 'arrow' | 'plus'; + +export type CollapseProps = { + /** Unique name used when `asRadio` is true (Accordion single-open). */ + name?: string; + /** If provided, component is controlled. */ + open?: boolean; + /** Initial open state for uncontrolled usage. */ + defaultOpen?: boolean; + /** Callback when open state changes. */ + onOpenChange?: (open: boolean) => void; + /** Title row content. Accepts string or custom node. */ + title?: React.ReactNode; + /** Optional secondary text displayed under/next to title. */ + subtitle?: React.ReactNode; + /** Content of the panel. */ + children?: React.ReactNode; + /** Visual variant: default / arrow / plus */ + variant?: CollapseVariant; + /** Add a bordered look */ + bordered?: boolean; + /** Disable interactions */ + disabled?: boolean; + /** Allow only one open at a time by switching to radio input */ + asRadio?: boolean; + /** Extra classnames */ + className?: string; + titleClassName?: string; + contentClassName?: string; +}; + +export const Collapse = ({ + name, + open, + defaultOpen, + onOpenChange, + title, + subtitle, + children, + variant = 'default', + bordered, + disabled, + asRadio = false, + className, + titleClassName, + contentClassName, +}: CollapseProps) => { + const inputId = useId(); + const isControlled = typeof open === 'boolean'; + const [internalOpen, setInternalOpen] = useState(!!defaultOpen); + const isOpen = isControlled ? !!open : internalOpen; + + // Manage change from checkbox/radio + const handleChange = useCallback( + (next: boolean) => { + if (!isControlled) setInternalOpen(next); + onOpenChange?.(next); + }, + [isControlled, onOpenChange] + ); + + const inputType = asRadio ? 'radio' : 'checkbox'; + + const rootClass = cn( + 'collapse', + variant === 'arrow' && 'collapse-arrow', + variant === 'plus' && 'collapse-plus', + bordered && 'border base-content/20 border-opacity-20 rounded-box', + disabled && 'opacity-60 pointer-events-none', + className + ); + + const titleNode = useMemo(() => { + if (subtitle) { + return ( +
+ {title} + {subtitle} +
+ ); + } + return
{title}
; + }, [title, subtitle]); + + return ( +
+ handleChange(e.currentTarget.checked)} + aria-expanded={isOpen} + aria-controls={`${inputId}-content`} + disabled={disabled} + /> + +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleChange(!isOpen); + } + }} + onClick={() => handleChange(!isOpen)} + > + {titleNode} +
+ +
+ {children} +
+
+ ); +}; + +export default Collapse; From 02c44ced92bf5f041226bb5e7d52c7a61a5aa7ca Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Tue, 30 Sep 2025 11:25:38 +0700 Subject: [PATCH 003/174] chore: update Collapse component --- src/components/Collapse.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/Collapse.tsx b/src/components/Collapse.tsx index d076888d..4007cae0 100644 --- a/src/components/Collapse.tsx +++ b/src/components/Collapse.tsx @@ -92,10 +92,8 @@ export const Collapse = ({ type={inputType} name={asRadio ? name : undefined} className='peer p-0 hidden' - checked={isControlled ? isOpen : undefined} - defaultChecked={!isControlled ? isOpen : undefined} + checked={isOpen} onChange={(e) => handleChange(e.currentTarget.checked)} - aria-expanded={isOpen} aria-controls={`${inputId}-content`} disabled={disabled} /> From 33f5ca2a578dfe01a0f28821f5ddacff9adbd776 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Tue, 30 Sep 2025 15:33:57 +0700 Subject: [PATCH 004/174] chore: update Table component --- src/components/Table.tsx | 81 +++++++++++++++++++++++++++++++++------- 1 file changed, 68 insertions(+), 13 deletions(-) diff --git a/src/components/Table.tsx b/src/components/Table.tsx index 53665386..97f767aa 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { ReactNode, useState } from 'react'; import { flexRender, getCoreRowModel, @@ -30,15 +30,18 @@ interface TableClassNames { paginationClassName?: string; } -// Type for the Table component props -interface TableProps { +export interface TableProps { data: TData[]; columns: ColumnDef[]; pageSize?: number; + totalItems?: number; + page?: number; + onPageChange?: (page: number) => void; isLoading?: boolean; fuzzySearchValue?: string | null; onFuzzySearchValueChange?: (value: string) => void; className?: TableClassNames; + emptyContent?: ReactNode; } const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}]; @@ -54,13 +57,24 @@ const fuzzyFilter = ( return itemRank.passed; }; +const emptyContentDefaultValue = ( +
+ + Tidak ada data yang dapat ditampilkan... + +
+); + const Table = ({ data = [], columns = [], pageSize = 10, + totalItems, + page, + onPageChange, isLoading = false, - fuzzySearchValue = null, - onFuzzySearchValueChange = () => {}, + fuzzySearchValue, + onFuzzySearchValueChange, className = { containerClassName: '', tableWrapperClassName: '', @@ -73,7 +87,13 @@ const Table = ({ bodyColumnClassName: '', paginationClassName: '', }, + emptyContent = emptyContentDefaultValue, }: TableProps) => { + const isServerSideTable = + totalItems !== undefined && + page !== undefined && + onPageChange !== undefined; + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: pageSize, @@ -103,6 +123,32 @@ const Table = ({ const table = useReactTable(tableOptions); + const prevPageClickHandler = () => { + table.previousPage(); + + if (isServerSideTable) { + onPageChange(page - 1); + } + }; + + const nextPageClickHandler = () => { + table.nextPage(); + + if (isServerSideTable) { + onPageChange(page + 1); + } + }; + + const pageChangeHandler = (pageNumber: number) => { + const currentPage = pageNumber - 1; + + table.setPageIndex(pageNumber ? currentPage : 0); + + if (isServerSideTable) { + onPageChange(pageNumber); + } + }; + return (
@@ -178,17 +224,26 @@ const Table = ({
- {data.length > 0 && !isLoading && ( + {(data.length === 0 || table.getRowModel().rows.length === 0) && + emptyContent} + + {data.length > 0 && table.getRowModel().rows.length > 0 && !isLoading && (
table.previousPage()} - onNextPage={() => table.nextPage()} - onPageChange={(pageNumber) => - table.setPageIndex(pageNumber ? pageNumber - 1 : 0) + totalItems={isServerSideTable ? totalItems : table.getRowCount()} + itemsPerPage={ + isServerSideTable + ? undefined + : table.getState().pagination.pageSize } + currentPage={ + isServerSideTable + ? page + : table.getState().pagination.pageIndex + 1 + } + onPrevPage={prevPageClickHandler} + onNextPage={nextPageClickHandler} + onPageChange={pageChangeHandler} />
)} From 691b49a9025cc2e41bc0b6656bc6b4c79766de03 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Tue, 30 Sep 2025 15:42:03 +0700 Subject: [PATCH 005/174] chore: update Table component --- src/components/Table.tsx | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/src/components/Table.tsx b/src/components/Table.tsx index 97f767aa..e20d4d11 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ReactNode, useState } from 'react'; +import { ReactNode, useCallback, useState } from 'react'; import { flexRender, getCoreRowModel, @@ -10,6 +10,7 @@ import { TableOptions, useReactTable, ColumnDef, + FilterFn, } from '@tanstack/react-table'; import { rankItem } from '@tanstack/match-sorter-utils'; import { Icon } from '@iconify/react'; @@ -32,7 +33,7 @@ interface TableClassNames { export interface TableProps { data: TData[]; - columns: ColumnDef[]; + columns: ColumnDef[]; pageSize?: number; totalItems?: number; page?: number; @@ -46,17 +47,6 @@ export interface TableProps { 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 emptyContentDefaultValue = (
@@ -99,6 +89,15 @@ const Table = ({ pageSize: pageSize, }); + const fuzzyFilter: FilterFn = useCallback( + (row, columnId, value, addMeta) => { + const itemRank = rankItem(row.getValue(columnId), value); + addMeta({ itemRank }); + return itemRank.passed; + }, + [] + ); + const tableOptions: TableOptions = { columns, data: isLoading ? (DUMMY_SKELETON_DATA as TData[]) : data, // Type assertion @@ -231,11 +230,7 @@ const Table = ({
Date: Tue, 30 Sep 2025 15:42:25 +0700 Subject: [PATCH 006/174] chore: update Pagination component --- src/components/Pagination.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx index 0eace3f0..86d3a67a 100644 --- a/src/components/Pagination.tsx +++ b/src/components/Pagination.tsx @@ -32,7 +32,11 @@ const PaginationButton = ({ const EtcPaginationButton = ({ startPage = 0, endPage = 0, - onPageItemClick = (pageNumber: number) => {}, + onPageItemClick, +}: { + startPage: number; + endPage: number; + onPageItemClick: (a: number) => void; }) => { const pages = range(startPage, endPage); @@ -86,9 +90,16 @@ const Pagination = ({ currentPage = 1, totalItems = 0, itemsPerPage = 10, - onPageChange = (pageNumber: number) => {}, + onPageChange, onPrevPage = () => {}, onNextPage = () => {}, +}: { + currentPage: number; + totalItems: number; + itemsPerPage: number; + onPageChange: (pageNumber: number) => void; + onPrevPage: () => void; + onNextPage: () => void; }) => { const totalPages = Math.ceil(totalItems / itemsPerPage) === 0 From 8e23a805a9824c4cf4c35fa2bdce9e206ccb38b8 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Tue, 30 Sep 2025 15:43:04 +0700 Subject: [PATCH 007/174] chore: update Button component --- src/components/Button.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 79ecb6d9..a74a7e4f 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -1,4 +1,4 @@ -import react, { JSX } from 'react'; +import react from 'react'; import Link from 'next/link'; @@ -17,11 +17,12 @@ const Button = ({ type, href, variant, - color, + color = 'primary', isLoading, className, disabled, onClick, + ...props }: ButtonProps) => { const btnBaseClassName = cn( 'btn', @@ -49,6 +50,7 @@ const Button = ({ <> {!href && ( )} From a364a860fa5dc0ba4cfa1d224419fe5e8ba8e126 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Tue, 30 Sep 2025 15:43:48 +0700 Subject: [PATCH 008/174] chore: update PasswordInput component --- src/components/input/PasswordInput.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/input/PasswordInput.tsx b/src/components/input/PasswordInput.tsx index 993915fc..86be59d7 100644 --- a/src/components/input/PasswordInput.tsx +++ b/src/components/input/PasswordInput.tsx @@ -6,8 +6,10 @@ import { Icon } from '@iconify/react'; import TextInput, { TextInputProps } from '@/components/input/TextInput'; import Button from '@/components/Button'; -interface PasswordInputProps - extends Omit {} +type PasswordInputProps = Omit< + TextInputProps, + 'type' | 'startAdornment' | 'endAdornment' +>; const PasswordInput = (props: PasswordInputProps) => { const [type, setType] = useState('password'); From 3be69eeff82dcb39cb6fda0aa484d903fb6e8b5e Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Tue, 30 Sep 2025 15:44:48 +0700 Subject: [PATCH 009/174] fix: error type --- src/services/http/client.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/services/http/client.ts b/src/services/http/client.ts index 29ffc6db..1a898e13 100644 --- a/src/services/http/client.ts +++ b/src/services/http/client.ts @@ -1,6 +1,6 @@ import axios from 'axios'; import type { AxiosRequestConfig } from 'axios'; -import { HttpError, RequestOptions } from '@/services/http/base'; +import { 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 }); @@ -29,12 +29,8 @@ export async function httpClient( try { const res = await axiosClient.request(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); + } catch (e: unknown) { + throw e; } } From 037e4776a8af78da3d1f9a41c7fc95ae0d80cb32 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Tue, 30 Sep 2025 15:45:33 +0700 Subject: [PATCH 010/174] chore: run eslint command in dev command --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c88a9618..93fd5b8d 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --turbopack", + "dev": "eslint && next dev --turbopack", "build": "next build --turbopack", "start": "next start", "lint": "eslint" From 19b7c53ec2a5343a8492bc1ca66901a293331331 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 1 Oct 2025 13:44:02 +0700 Subject: [PATCH 011/174] feat(FE-40): use MainDrawer component in root layout --- src/app/layout.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 793f0b93..9d807425 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata, Viewport } from 'next'; import { Inter } from 'next/font/google'; -import './globals.css'; +import '@/app/globals.css'; +import MainDrawer from '@/components/MainDrawer'; const inter = Inter({ variable: '--font-inter', @@ -26,7 +27,7 @@ export default function RootLayout({ return ( - {children} + {children} ); From c068fe51665c3a36056714d41951757880a7ef2d Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 1 Oct 2025 13:44:18 +0700 Subject: [PATCH 012/174] feat(FE-40): redirect to /dashboard --- src/app/page.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/page.tsx b/src/app/page.tsx index 8ac3364a..db9638df 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,4 +1,8 @@ +import { redirect } from 'next/navigation'; + export default function Home() { + redirect('/dashboard'); + return (

LTI ERP

From 2456d64a685804574228474c9bd3f5ec39d596e8 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 1 Oct 2025 13:44:26 +0700 Subject: [PATCH 013/174] feat(FE-40): create MainDrawer component --- src/components/MainDrawer.tsx | 205 ++++++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 src/components/MainDrawer.tsx diff --git a/src/components/MainDrawer.tsx b/src/components/MainDrawer.tsx new file mode 100644 index 00000000..ad9a7a9c --- /dev/null +++ b/src/components/MainDrawer.tsx @@ -0,0 +1,205 @@ +'use client'; + +import { useState } from 'react'; +import { usePathname } from 'next/navigation'; + +import Image from 'next/image'; +import { Icon } from '@iconify/react'; +import Drawer from '@/components/Drawer'; +import Menu from '@/components/menu/Menu'; +import MenuItem from '@/components/menu/MenuItem'; +import Navbar from '@/components/Navbar'; +import Collapse from '@/components/Collapse'; + +import { useUiStore } from '@/stores/ui/ui.store'; +import { MAIN_DRAWER_LINKS } from '@/config/constant'; +import { cn } from '@/lib/helper'; + +type CollapseMenuProps = { + title: string; + link: string; + icon: string; + submenu?: CollapseMenuProps[]; + depth?: number; +}; + +const isPathActive = (pathname: string, link?: string) => { + if (!link) return false; + + const splittedPathname = pathname.split('/'); + const splittedLink = link.split('/'); + + return splittedPathname.every((pathnameChunk, idx) => { + return pathnameChunk === splittedLink[idx]; + }); +}; + +const isCollapseActive = (pathname: string, link?: string) => { + if (!link) return false; + + return pathname === link || pathname.startsWith(link); +}; + +const CollapseMenu = ({ + title, + link, + icon, + submenu, + depth = 0, +}: CollapseMenuProps) => { + const pathname = usePathname(); + const [open, setOpen] = useState(isCollapseActive(pathname, link)); + + const menuCollapseTitle = ( +
+
+ + {title} +
+ + +
+ ); + + const paddingLeftDepth = `pl-${4 * (depth + 1)}`; + + return ( + + + {submenu?.map((item, idx) => { + const hasSubmenu = item.submenu && item.submenu.length > 0; + + if (!hasSubmenu) { + return ( + + ); + } + + return ( + + ); + })} + + + ); +}; + +const MainDrawerMenu = () => { + const pathname = usePathname(); + + return ( + + {MAIN_DRAWER_LINKS.map((item, idx) => { + const hasSubmenu = item.submenu && item.submenu.length > 0; + + if (!hasSubmenu) { + return ( + + ); + } + + return ( + + ); + })} + + ); +}; + +const MainDrawerContent = () => { + return ( +
+
+ MBU Logo + +

LTI ERP

+
+ + +
+ ); +}; +const MainDrawer = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + const { mainDrawerOpen, setMainDrawerOpen } = useUiStore(); + const pathname = usePathname(); + + const pageTitle = MAIN_DRAWER_LINKS.find((item) => + pathname.startsWith(item.link) + )?.title; + + const toggleSidebar = () => { + setMainDrawerOpen(!mainDrawerOpen); + }; + + return ( + } + > +
+ + + {children} +
+
+ ); +}; + +export default MainDrawer; From 87ce1e50aae93ebcc416da3e3a4b15b6cdb8739c Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 1 Oct 2025 13:44:42 +0700 Subject: [PATCH 014/174] feat(FE-40): create MAIN_DRAWER_LINKS constant --- src/config/constant.ts | 75 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/src/config/constant.ts b/src/config/constant.ts index e69de29b..84b24e04 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -0,0 +1,75 @@ +export const MAIN_DRAWER_LINKS = [ + { + title: 'Dashboard', + link: '/dashboard', + icon: 'gg:chart', + }, + + { + title: 'Master Data', + link: '/master-data', + icon: 'majesticons:data-line', + submenu: [ + { + title: 'Product', + link: '/master-data/product', + icon: 'fluent-mdl2:product-variant', + }, + { + title: 'Product Category', + link: '/master-data/product-category', + icon: 'carbon:categories', + }, + { + title: 'Bank', + link: '/master-data/Bank', + icon: 'mdi:bank-outline', + }, + { + title: 'Area', + link: '/master-data/area', + icon: 'majesticons:map-marker-area-line', + }, + { + title: 'Location', + link: '/master-data/location', + icon: 'mingcute:location-line', + }, + { + title: 'Kandang', + link: '/master-data/kandang', + icon: 'mdi:farm-home-outline', + }, + { + title: 'Warehouse', + link: '/master-data/warehouse', + icon: 'hugeicons:warehouse', + }, + { + title: 'Customer', + link: '/master-data/customer', + icon: 'ix:customer', + }, + { + title: 'UOM', + link: '/master-data/uom', + icon: 'lsicon:measure-outline', + }, + { + title: 'Non-Stock', + link: '/master-data/nonstock', + icon: 'fluent:box-32-regular', + }, + { + title: 'FCR', + link: '/master-data/FCR', + icon: 'fluent:food-chicken-leg-16-regular', + }, + { + title: 'Supplier', + link: '/master-data/supplier', + icon: 'material-symbols:add-business-outline-rounded', + }, + ], + }, +]; From 6cbdff5398ea7b4bcb80abb84b664f2dad0705be Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 1 Oct 2025 13:45:00 +0700 Subject: [PATCH 015/174] feat(FE-40): add main UI slice to useUiStore --- src/stores/ui/ui.store.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/stores/ui/ui.store.ts b/src/stores/ui/ui.store.ts index 86016974..2e64dcc1 100644 --- a/src/stores/ui/ui.store.ts +++ b/src/stores/ui/ui.store.ts @@ -4,9 +4,15 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import { UIStore } from '@/types/stores'; +import { createMainUiSlice } from './slices/main.slice'; export const useUiStore = create()( - devtools((...args) => ({}), { - name: 'UIStore', - }) + devtools( + (...args) => ({ + ...createMainUiSlice(...args), + }), + { + name: 'UIStore', + } + ) ); From a5b392ae76863a8344cd70a32bffe6ce23e05352 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 1 Oct 2025 13:45:16 +0700 Subject: [PATCH 016/174] feat(FE-40): create main UI slice --- src/stores/ui/slices/main.slice.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/stores/ui/slices/main.slice.ts diff --git a/src/stores/ui/slices/main.slice.ts b/src/stores/ui/slices/main.slice.ts new file mode 100644 index 00000000..31f8f447 --- /dev/null +++ b/src/stores/ui/slices/main.slice.ts @@ -0,0 +1,12 @@ +import { StateCreator } from 'zustand'; +import { MainUiSlice } from '@/types/stores'; + +export const createMainUiSlice: StateCreator< + MainUiSlice, + [], + [], + MainUiSlice +> = (set): MainUiSlice => ({ + mainDrawerOpen: false, + setMainDrawerOpen: (open) => set(() => ({ mainDrawerOpen: open })), +}); From 8206f7de5f3c6893bfe9f13f82c484ec0add3ccc Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 1 Oct 2025 13:45:25 +0700 Subject: [PATCH 017/174] feat(FE-40): create MainUiSlice type --- src/types/stores.d.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/types/stores.d.ts b/src/types/stores.d.ts index 08900862..1a3046ae 100644 --- a/src/types/stores.d.ts +++ b/src/types/stores.d.ts @@ -1 +1,6 @@ -export type UIStore = {}; +type MainUiSlice = { + mainDrawerOpen: boolean; + setMainDrawerOpen: (open: boolean) => void; +}; + +export type UIStore = MainUiSlice; From 6969a2bcb8193c4eba4e484a20ae923449284ffb Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 1 Oct 2025 13:56:59 +0700 Subject: [PATCH 018/174] chore(FE-40): move api.d.ts to /types/api/api-general.d.ts --- src/types/{api.d.ts => api/api-general.d.ts} | 4 ++++ 1 file changed, 4 insertions(+) rename src/types/{api.d.ts => api/api-general.d.ts} (85%) diff --git a/src/types/api.d.ts b/src/types/api/api-general.d.ts similarity index 85% rename from src/types/api.d.ts rename to src/types/api/api-general.d.ts index 1ae9f613..dfede516 100644 --- a/src/types/api.d.ts +++ b/src/types/api/api-general.d.ts @@ -20,6 +20,10 @@ type SuccessApiResponse = { type BaseApiResponse = ErrorApiResponse | SuccessApiResponse; +export type LogoutResponse = BaseApiResponse; + +export type GetMeResponse = BaseApiResponse; + export type User = { id: number; email: string; From a3470241881c330ee46134c78e0bd76b3e8f2d64 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 1 Oct 2025 14:04:50 +0700 Subject: [PATCH 019/174] chore(FE-40): update CollapseMenu styling --- src/components/MainDrawer.tsx | 56 +++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/src/components/MainDrawer.tsx b/src/components/MainDrawer.tsx index ad9a7a9c..4a21b7df 100644 --- a/src/components/MainDrawer.tsx +++ b/src/components/MainDrawer.tsx @@ -48,14 +48,15 @@ const CollapseMenu = ({ depth = 0, }: CollapseMenuProps) => { const pathname = usePathname(); - const [open, setOpen] = useState(isCollapseActive(pathname, link)); + const isActive = isCollapseActive(pathname, link); + const [open, setOpen] = useState(isActive); const menuCollapseTitle = (
@@ -76,8 +77,6 @@ const CollapseMenu = ({
); - const paddingLeftDepth = `pl-${4 * (depth + 1)}`; - return ( - - {submenu?.map((item, idx) => { - const hasSubmenu = item.submenu && item.submenu.length > 0; + +
+ {submenu?.map((item, idx) => { + const hasSubmenu = item.submenu && item.submenu.length > 0; + + if (!hasSubmenu) { + return ( + + ); + } - if (!hasSubmenu) { return ( - ); - } - - return ( - - ); - })} + })} +
); From 05886896f1d20d09f00507eac8e93d936377e1c8 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 1 Oct 2025 14:07:04 +0700 Subject: [PATCH 020/174] chore(FE-40): update import path --- src/lib/api-helper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/api-helper.ts b/src/lib/api-helper.ts index 3e85ecf0..9081e41f 100644 --- a/src/lib/api-helper.ts +++ b/src/lib/api-helper.ts @@ -1,4 +1,4 @@ -import { BaseApiResponse, SuccessApiResponse } from '@/types/api'; +import { BaseApiResponse, SuccessApiResponse } from '@/types/api/api-general'; export const isResponseSuccess = ( res?: BaseApiResponse From 3d3df42576ff8687df0c332b3d7deb95185226ad Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 1 Oct 2025 14:07:30 +0700 Subject: [PATCH 021/174] chore(FE-40): update import path and return isLoadingUser and setIsLoadingUser in useAuth --- src/services/hooks/useAuth.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/services/hooks/useAuth.tsx b/src/services/hooks/useAuth.tsx index 012badfa..86bf43ed 100644 --- a/src/services/hooks/useAuth.tsx +++ b/src/services/hooks/useAuth.tsx @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import { UserWithRoles } from '@/types/api'; +import { UserWithRoles } from '@/types/api/api-general'; type AuthStore = { user?: UserWithRoles; @@ -16,10 +16,12 @@ const useAuthStore = create()((set) => ({ })); export const useAuth = () => { - const { user, setUser } = useAuthStore(); + const { user, setUser, isLoadingUser, setIsLoadingUser } = useAuthStore(); return { user, setUser, + isLoadingUser, + setIsLoadingUser, }; }; From fa96d7a98aec7bb999147716a34fbf44d7d03c4a Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 1 Oct 2025 14:08:02 +0700 Subject: [PATCH 022/174] chore(FE-40): set opts.auth default to 'cookie' and export SWRHttpKey type --- src/services/http/client.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/services/http/client.ts b/src/services/http/client.ts index 1a898e13..adba75e9 100644 --- a/src/services/http/client.ts +++ b/src/services/http/client.ts @@ -9,7 +9,9 @@ export async function httpClient( path: string, opts: RequestOptions = {} ): Promise { - const isCookieAuth = opts.auth === 'cookie'; + const isCookieAuth = + opts.auth === 'cookie' || + (!opts.auth && opts.auth !== 'none' && opts.auth !== 'bearer'); const isBearerAuth = opts.auth === 'bearer' && !!opts.token; const config: AxiosRequestConfig = { @@ -18,11 +20,13 @@ export async function httpClient( params: opts.query, data: opts.body, timeout: opts.timeoutMs ?? 10_000, - withCredentials: isCookieAuth, + withCredentials: isCookieAuth && !isBearerAuth, headers: { 'Content-Type': 'application/json', ...(opts.headers ?? {}), - ...(isBearerAuth ? { Authorization: `Bearer ${opts.token}` } : {}), + ...(isBearerAuth && !isCookieAuth + ? { Authorization: `Bearer ${opts.token}` } + : {}), }, }; @@ -34,7 +38,7 @@ export async function httpClient( } } -type SWRHttpKey = +export type SWRHttpKey = | string | [path: string, opts?: RequestOptions] | { path: string; opts?: RequestOptions }; From 6924aef8c47667c5f3a58ec58706e81926805bc4 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 1 Oct 2025 14:59:46 +0700 Subject: [PATCH 023/174] feat(FE-40): create RequireAuth helper component --- src/components/helper/RequireAuth.tsx | 55 +++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/components/helper/RequireAuth.tsx diff --git a/src/components/helper/RequireAuth.tsx b/src/components/helper/RequireAuth.tsx new file mode 100644 index 00000000..9bc199f9 --- /dev/null +++ b/src/components/helper/RequireAuth.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { ReactNode, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import useSWRImmutable from 'swr/immutable'; + +import { useAuth } from '@/services/hooks/useAuth'; +import { httpClientFetcher, SWRHttpKey } from '@/services/http/client'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { GetMeResponse } from '@/types/api/api-general'; + +interface RequireAuthProps { + children?: ReactNode; +} + +const RequireAuth = ({ children }: RequireAuthProps) => { + const router = useRouter(); + const { setUser, setIsLoadingUser } = useAuth(); + + const { data: userResponse, isLoading: isLoadingUserResponse } = + useSWRImmutable( + '/auth/get-me', + httpClientFetcher, + { + shouldRetryOnError: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshInterval: 0, + } + ); + + useEffect(() => { + setIsLoadingUser(isLoadingUserResponse); + }, [isLoadingUserResponse, setIsLoadingUser]); + + useEffect(() => { + if (isResponseSuccess(userResponse)) { + setUser(userResponse.data); + } else { + router.replace(process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string); + } + }, [userResponse, setIsLoadingUser, setUser]); + + if (isLoadingUserResponse && !userResponse) { + return ( +
+ +
+ ); + } + + return <>{children}; +}; + +export default RequireAuth; From 8a6a1e6b5caca0604101a38457ee1d4f90d5a0b4 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 1 Oct 2025 15:00:06 +0700 Subject: [PATCH 024/174] chore(FE-40): use RequireAuth in root layout --- src/app/layout.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9d807425..8843cc64 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,9 @@ import type { Metadata, Viewport } from 'next'; import { Inter } from 'next/font/google'; import '@/app/globals.css'; + import MainDrawer from '@/components/MainDrawer'; +import RequireAuth from '@/components/helper/RequireAuth'; const inter = Inter({ variable: '--font-inter', @@ -27,7 +29,9 @@ export default function RootLayout({ return ( - {children} + + {children} + ); From 9b2930375d486ffdd4dce2ec617fdb0f169aaac2 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 1 Oct 2025 15:00:44 +0700 Subject: [PATCH 025/174] chore(FE-40): fix bank link --- src/config/constant.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/constant.ts b/src/config/constant.ts index 84b24e04..1abf588a 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -22,7 +22,7 @@ export const MAIN_DRAWER_LINKS = [ }, { title: 'Bank', - link: '/master-data/Bank', + link: '/master-data/bank', icon: 'mdi:bank-outline', }, { From 0afde48135667baf14afb99c4ea255575d7ab661 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 1 Oct 2025 15:24:33 +0700 Subject: [PATCH 026/174] chore(FE-40): set correct page title --- src/components/MainDrawer.tsx | 48 ++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/src/components/MainDrawer.tsx b/src/components/MainDrawer.tsx index 4a21b7df..5851ee2b 100644 --- a/src/components/MainDrawer.tsx +++ b/src/components/MainDrawer.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { usePathname } from 'next/navigation'; import Image from 'next/image'; @@ -29,15 +29,11 @@ const isPathActive = (pathname: string, link?: string) => { const splittedPathname = pathname.split('/'); const splittedLink = link.split('/'); - return splittedPathname.every((pathnameChunk, idx) => { - return pathnameChunk === splittedLink[idx]; + const isActiveLinkValid = splittedLink.every((linkChunk, idx) => { + return linkChunk === splittedPathname[idx]; }); -}; -const isCollapseActive = (pathname: string, link?: string) => { - if (!link) return false; - - return pathname === link || pathname.startsWith(link); + return pathname.startsWith(link) && isActiveLinkValid; }; const CollapseMenu = ({ @@ -48,7 +44,7 @@ const CollapseMenu = ({ depth = 0, }: CollapseMenuProps) => { const pathname = usePathname(); - const isActive = isCollapseActive(pathname, link); + const isActive = isPathActive(pathname, link); const [open, setOpen] = useState(isActive); const menuCollapseTitle = ( @@ -184,9 +180,37 @@ const MainDrawer = ({ const { mainDrawerOpen, setMainDrawerOpen } = useUiStore(); const pathname = usePathname(); - const pageTitle = MAIN_DRAWER_LINKS.find((item) => - pathname.startsWith(item.link) - )?.title; + const getPageTitle = useCallback(() => { + let title = ''; + + const activeMenu = MAIN_DRAWER_LINKS.find((item) => + isPathActive(pathname, item.link) + ); + + const traverseMenuTitle = (menu: typeof activeMenu) => { + const hasSubmenu = menu?.submenu && menu?.submenu.length > 0; + + if (!title) { + title += menu?.title; + } else { + title += ' - ' + menu?.title; + } + + if (!hasSubmenu) return; + + const activeSubmenu = menu.submenu.find((item) => + isPathActive(pathname, item.link) + ); + + traverseMenuTitle(activeSubmenu); + }; + + traverseMenuTitle(activeMenu); + + return title; + }, [pathname]); + + const pageTitle = getPageTitle(); const toggleSidebar = () => { setMainDrawerOpen(!mainDrawerOpen); From 4ff196cb9d1740ed2dc9aee42888475946fdaec2 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 1 Oct 2025 15:32:35 +0700 Subject: [PATCH 027/174] chore(FE-40): remove unnecessary gap --- src/components/MainDrawer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MainDrawer.tsx b/src/components/MainDrawer.tsx index 5851ee2b..8cf3d3b3 100644 --- a/src/components/MainDrawer.tsx +++ b/src/components/MainDrawer.tsx @@ -223,7 +223,7 @@ const MainDrawer = ({ openOnLarge sidebarContent={} > -
+
{children} From 8ad49a448002a0f4ab337600bf11a098843b9078 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 1 Oct 2025 16:01:47 +0700 Subject: [PATCH 028/174] chore(FE-40): export ErrorApiResponse and SucessApiResponse type --- src/types/api/api-general.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/types/api/api-general.d.ts b/src/types/api/api-general.d.ts index dfede516..90df754b 100644 --- a/src/types/api/api-general.d.ts +++ b/src/types/api/api-general.d.ts @@ -1,11 +1,11 @@ -type ErrorApiResponse = { +export type ErrorApiResponse = { code: number; status: 'error'; message: string; errors?: { [key: string]: string }; }; -type SuccessApiResponse = { +export type SuccessApiResponse = { code: number; status: 'success'; message: string; From 14046a1add4da75675d832831fdbdb8e2fdb4b81 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 1 Oct 2025 16:02:12 +0700 Subject: [PATCH 029/174] chore(FE-40): create isResponseError api helper function --- src/lib/api-helper.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/lib/api-helper.ts b/src/lib/api-helper.ts index 9081e41f..7ce3ac64 100644 --- a/src/lib/api-helper.ts +++ b/src/lib/api-helper.ts @@ -1,7 +1,17 @@ -import { BaseApiResponse, SuccessApiResponse } from '@/types/api/api-general'; +import { + BaseApiResponse, + ErrorApiResponse, + SuccessApiResponse, +} from '@/types/api/api-general'; export const isResponseSuccess = ( res?: BaseApiResponse ): res is SuccessApiResponse => { return res?.status === 'success'; }; + +export const isResponseError = ( + res?: BaseApiResponse +): res is ErrorApiResponse => { + return res?.status === 'error'; +}; From ca9205618aa05cfe853e90b6c0449c93a2974b5c Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 2 Oct 2025 11:45:11 +0700 Subject: [PATCH 030/174] feat: add Modal component --- src/components/Modal.tsx | 62 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/components/Modal.tsx diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx new file mode 100644 index 00000000..ec503392 --- /dev/null +++ b/src/components/Modal.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { ReactNode, RefObject, useCallback, useRef, useState } from 'react'; +import { cn } from '@/lib/helper'; + +export const useModal = () => { + const ref = useRef(null); + const [open, setOpen] = useState(false); + + const openModal = useCallback(() => { + setOpen(true); + + ref.current?.showModal(); + }, []); + + const closeModal = useCallback(() => { + setOpen(false); + ref.current?.close(); + }, []); + + const toggle = useCallback(() => { + if (open) { + closeModal(); + } else { + openModal(); + } + }, [open]); + + if (ref.current) { + ref.current.addEventListener('close', () => { + closeModal(); + }); + } + + return { ref, open, setOpen, openModal, closeModal, toggle } as const; +}; + +interface ModalProps { + ref: RefObject; + children?: ReactNode; + closeOnBackdrop?: boolean; + className?: { + modal?: string; + modalBox?: string; + }; +} + +const Modal = ({ ref, children, closeOnBackdrop, className }: ModalProps) => { + return ( + +
{children}
+ + {closeOnBackdrop && ( +
+ +
+ )} +
+ ); +}; + +export default Modal; From d40a5dd898e1e2874dfc1701f4f443a3e85bc0e8 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 2 Oct 2025 11:46:09 +0700 Subject: [PATCH 031/174] chore: update Collapse styling --- src/components/Collapse.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/Collapse.tsx b/src/components/Collapse.tsx index 4007cae0..cb05d5b0 100644 --- a/src/components/Collapse.tsx +++ b/src/components/Collapse.tsx @@ -70,6 +70,7 @@ export const Collapse = ({ variant === 'plus' && 'collapse-plus', bordered && 'border base-content/20 border-opacity-20 rounded-box', disabled && 'opacity-60 pointer-events-none', + !open && 'w-fit', className ); @@ -82,7 +83,7 @@ export const Collapse = ({
); } - return
{title}
; + return title; }, [title, subtitle]); return ( @@ -102,7 +103,7 @@ export const Collapse = ({ role='button' tabIndex={0} className={cn( - 'collapse-title p-0', + 'collapse-title w-fit p-0', 'focus:outline-none focus-visible:ring focus-visible:ring-primary/40', titleClassName )} From 2a6f2a164609ddce711921e6bffb55f4b8cc4094 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 2 Oct 2025 11:46:27 +0700 Subject: [PATCH 032/174] chore(FE-40): update MainDrawer component styling --- src/components/MainDrawer.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/MainDrawer.tsx b/src/components/MainDrawer.tsx index 8cf3d3b3..6a5e6f38 100644 --- a/src/components/MainDrawer.tsx +++ b/src/components/MainDrawer.tsx @@ -78,7 +78,8 @@ const CollapseMenu = ({ open={open} title={menuCollapseTitle} onOpenChange={setOpen} - titleClassName='p-0!' + className='w-full' + titleClassName='w-full p-0!' >
Date: Thu, 2 Oct 2025 12:00:02 +0700 Subject: [PATCH 033/174] chore: update toggle dependencies --- src/components/Modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index ec503392..a84c1827 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -24,7 +24,7 @@ export const useModal = () => { } else { openModal(); } - }, [open]); + }, [open, closeModal, openModal]); if (ref.current) { ref.current.addEventListener('close', () => { From 36b66d9b2fa48c7d294734b41f76058a88b5e2fc Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 2 Oct 2025 12:00:18 +0700 Subject: [PATCH 034/174] feat(FE-40): create Dashboard page --- src/app/dashboard/page.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/app/dashboard/page.tsx diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 00000000..4f2c344e --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,9 @@ +const Dashboard = () => { + return ( +
+

Dashboard

+
+ ); +}; + +export default Dashboard; From e6acfc12147f9323ecf44c03691783cc72a675dd Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 2 Oct 2025 12:01:00 +0700 Subject: [PATCH 035/174] feat(FE-40): create NonstockForm component --- .../nonstock/form/NonstockForm.schema.ts | 9 + .../nonstock/form/NonstockForm.tsx | 178 ++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 src/components/pages/master-data/nonstock/form/NonstockForm.schema.ts create mode 100644 src/components/pages/master-data/nonstock/form/NonstockForm.tsx diff --git a/src/components/pages/master-data/nonstock/form/NonstockForm.schema.ts b/src/components/pages/master-data/nonstock/form/NonstockForm.schema.ts new file mode 100644 index 00000000..50f69c7d --- /dev/null +++ b/src/components/pages/master-data/nonstock/form/NonstockForm.schema.ts @@ -0,0 +1,9 @@ +import * as Yup from 'yup'; + +export const NonstockFormSchema = Yup.object({ + name: Yup.string().required('Nama wajib diisi!'), +}); + +export const UpdateNonstockFormSchema = NonstockFormSchema; + +export type NonstockFormValues = Yup.InferType; diff --git a/src/components/pages/master-data/nonstock/form/NonstockForm.tsx b/src/components/pages/master-data/nonstock/form/NonstockForm.tsx new file mode 100644 index 00000000..bf2fce73 --- /dev/null +++ b/src/components/pages/master-data/nonstock/form/NonstockForm.tsx @@ -0,0 +1,178 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useFormik } from 'formik'; + +import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; +import TextInput from '@/components/input/TextInput'; + +import { + NonstockFormSchema, + NonstockFormValues, + UpdateNonstockFormSchema, +} from '@/components/pages/master-data/nonstock/form/NonstockForm.schema'; +import { isResponseError } from '@/lib/api-helper'; +import { + CreateNonstockPayload, + Nonstock, + UpdateNonstockPayload, +} from '@/types/api/master-data/nonstock'; +import { + createNonstock, + updateNonstock, +} from '@/services/api/master-data/nonstock'; + +interface NonstockFormProps { + type?: 'add' | 'edit' | 'detail'; + initialValues?: Nonstock; +} + +const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => { + const router = useRouter(); + + const [nonstockFormErrorMessage, setNonstockFormErrorMessage] = useState(''); + + const createNonstockHandler = useCallback( + async (payload: CreateNonstockPayload) => { + const createNonstockRes = await createNonstock(payload); + + if (isResponseError(createNonstockRes)) { + setNonstockFormErrorMessage(createNonstockRes.message); + return; + } + + alert(createNonstockRes?.message); + router.push('/master-data/nonstock'); + }, + [router] + ); + + const updateNonstockHandler = useCallback( + async (nonstockId: number, payload: UpdateNonstockPayload) => { + const updateNonstockRes = await updateNonstock(nonstockId, payload); + + if (updateNonstockRes?.status === 'error') { + setNonstockFormErrorMessage(updateNonstockRes.message); + return; + } + + alert(updateNonstockRes?.message); + router.refresh(); + router.push('/master-data/nonstock'); + }, + [router] + ); + + const formikInitialValues = useMemo(() => { + return { + name: initialValues?.name ?? '', + }; + }, [initialValues]); + + const formik = useFormik({ + initialValues: formikInitialValues, + validationSchema: + type === 'edit' ? UpdateNonstockFormSchema : NonstockFormSchema, + onSubmit: async (values) => { + setNonstockFormErrorMessage(''); + + const nonstockPayload: CreateNonstockPayload = { + name: values.name, + }; + + switch (type) { + case 'add': + await createNonstockHandler(nonstockPayload); + break; + + case 'edit': + await updateNonstockHandler( + initialValues?.id as number, + nonstockPayload + ); + break; + } + }, + }); + + useEffect(() => { + formik.setValues(formikInitialValues); + }, [formikInitialValues]); + + return ( +
+
+ + +

+ {type === 'add' && 'Tambah Non Stock'} + {type === 'edit' && 'Edit Non Stock'} + {type === 'detail' && 'Detail Non Stock'} +

+
+ +
+
+ +
+ + {type !== 'detail' && ( + <> +
+ + + +
+ + {nonstockFormErrorMessage && ( +
+ + {nonstockFormErrorMessage} +
+ )} + + )} +
+
+ ); +}; + +export default NonstockForm; From 35c809193ba2bab5295db28f2be8ac37b8b2d931 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 2 Oct 2025 12:01:14 +0700 Subject: [PATCH 036/174] feat(FE-40): create NonstocksTable component --- .../master-data/nonstock/NonstocksTable.tsx | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 src/components/pages/master-data/nonstock/NonstocksTable.tsx diff --git a/src/components/pages/master-data/nonstock/NonstocksTable.tsx b/src/components/pages/master-data/nonstock/NonstocksTable.tsx new file mode 100644 index 00000000..4a2dbc5b --- /dev/null +++ b/src/components/pages/master-data/nonstock/NonstocksTable.tsx @@ -0,0 +1,243 @@ +'use client'; + +import { ChangeEventHandler, useState } from 'react'; +import useSWR from 'swr'; +import { CellContext, ColumnDef } from '@tanstack/react-table'; + +import { Icon } from '@iconify/react'; +import Table from '@/components/Table'; +import TextInput from '@/components/input/TextInput'; +import Button from '@/components/Button'; +import Collapse from '@/components/Collapse'; + +import { httpClientFetcher } from '@/services/http/client'; +import { Nonstock, NonstocksResponse } from '@/types/api/master-data/nonstock'; +import { cn } from '@/lib/helper'; +import { deleteNonstock } from '@/services/api/master-data/nonstock'; +import { isResponseSuccess } from '@/lib/api-helper'; + +const RowOptionsMenu = ({ + type = 'dropdown', + props, + deleteClickHandler, +}: { + type: 'dropdown' | 'collapse'; + props: CellContext; + deleteClickHandler: () => Promise; +}) => { + return ( +
+ + + + + +
+ ); +}; + +const RowDropdownOptions = ({ + props, + isLast2Rows, + deleteClickHandler, +}: { + props: CellContext; + isLast2Rows: boolean; + deleteClickHandler: () => Promise; +}) => { + return ( +
+ + + +
+ ); +}; + +const RowCollapseOptions = ({ + props, + deleteClickHandler, +}: { + props: CellContext; + deleteClickHandler: () => Promise; +}) => { + return ( + + + + } + className='w-fit' + titleClassName='p-0! justify-self-end' + > + + + ); +}; + +const NonstocksTable = () => { + const { + data: nonstocks, + isLoading, + mutate: refreshNonstocks, + } = useSWR('/master-data/nonstocks', httpClientFetcher); + + const [searchValue, setSearchValue] = useState(''); + + const nonstocksColumns: ColumnDef[] = [ + { + header: '#', + cell: (props) => props.row.index + 1, + }, + { + header: 'Nama', + accessorKey: 'name', + }, + { + header: 'Aksi', + cell: (props) => { + const currentPageSize = props.table.getPaginationRowModel().rows.length; + const currentPageRows = props.table.getPaginationRowModel().flatRows; + const currentRowRelativeIndex = + currentPageRows.findIndex((r) => r.id === props.row.id) + 1; + + const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; + + const deleteClickHandler = async () => { + const confirmation = confirm( + 'Apakah anda yakin untuk menghapus non stock ini?' + ); + + if (confirmation) { + await deleteNonstock(props.row.original.id); + refreshNonstocks(); + alert('Nonstock berhasil dihapus!'); + } + }; + + return ( + <> + {currentPageSize > 2 && ( + + )} + + {currentPageSize <= 2 && ( + + )} + + ); + }, + }, + ]; + + const searchChangeHandler: ChangeEventHandler = (e) => { + setSearchValue(e.target.value); + }; + + return ( +
+
+
+ +
+ + +
+ + + data={isResponseSuccess(nonstocks) ? nonstocks?.data : []} + columns={nonstocksColumns} + pageSize={10} + fuzzySearchValue={searchValue} + onFuzzySearchValueChange={setSearchValue} + isLoading={isLoading} + className={{ + containerClassName: cn({ + 'mb-20': + isResponseSuccess(nonstocks) && nonstocks?.data?.length === 0, + }), + tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableClassName: 'font-inter w-full table-auto min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', + bodyRowClassName: 'border-b border-b-gray-200', + bodyColumnClassName: + 'px-6 py-3 last:flex last:flex-row last:justify-end', + }} + /> +
+ ); +}; + +export default NonstocksTable; From 8dd1ebdfe447fa8d6bff97c876209d6b6a2135d3 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 2 Oct 2025 12:01:43 +0700 Subject: [PATCH 037/174] feat(FE-40): create api service for nonstock --- src/services/api/master-data/nonstock.ts | 87 ++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/services/api/master-data/nonstock.ts diff --git a/src/services/api/master-data/nonstock.ts b/src/services/api/master-data/nonstock.ts new file mode 100644 index 00000000..7340e37b --- /dev/null +++ b/src/services/api/master-data/nonstock.ts @@ -0,0 +1,87 @@ +import axios from 'axios'; +import { httpClient } from '@/services/http/client'; + +import { + CreateNonstockPayload, + DeleteNonstockResponse, + NonstockResponse, + UpdateNonstockPayload, +} from '@/types/api/master-data/nonstock'; + +export const getNonstock = async (nonstockId: number) => { + try { + const getNonstockRes = await httpClient( + `/master-data/nonstocks/${nonstockId}` + ); + + return getNonstockRes; + } catch (error: unknown) { + if (axios.isAxiosError(error)) { + return error.response?.data; + } + + return undefined; + } +}; + +export const createNonstock = async (payload: CreateNonstockPayload) => { + try { + const createNonstockRes = await httpClient( + '/master-data/nonstocks', + { + method: 'POST', + body: payload, + } + ); + + return createNonstockRes; + } catch (error: unknown) { + if (axios.isAxiosError(error)) { + return error.response?.data; + } + + return undefined; + } +}; + +export const updateNonstock = async ( + nonstockId: number, + payload: UpdateNonstockPayload +) => { + try { + const updateNonstockRes = await httpClient( + `/master-data/nonstocks/${nonstockId}`, + { + method: 'PATCH', + body: payload, + } + ); + + return updateNonstockRes; + } catch (error: unknown) { + if (axios.isAxiosError(error)) { + return error.response?.data; + } + + return undefined; + } +}; + +export const deleteNonstock = async (nonstockId: number) => { + try { + const deleteNonstockRes = await httpClient( + `/master-data/nonstocks/${nonstockId}`, + { + method: 'DELETE', + } + ); + + return deleteNonstockRes; + } catch (error) { + if (axios.isAxiosError(error)) { + return error.response?.data; + } + + return undefined; + } +}; From 62b38949830bd93edbf422cc01c4a5180683e670 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 2 Oct 2025 12:02:02 +0700 Subject: [PATCH 038/174] feat(FE-40): create api type for nonstock --- src/types/api/master-data/nonstock.d.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/types/api/master-data/nonstock.d.ts diff --git a/src/types/api/master-data/nonstock.d.ts b/src/types/api/master-data/nonstock.d.ts new file mode 100644 index 00000000..682f7852 --- /dev/null +++ b/src/types/api/master-data/nonstock.d.ts @@ -0,0 +1,18 @@ +import { BaseApiResponse } from '@/types/api/api-general'; + +export type Nonstock = { + id: number; + name: string; +}; + +export type CreateNonstockPayload = { + name: string; +}; + +export type UpdateNonstockPayload = CreateNonstockPayload; + +export type NonstockResponse = BaseApiResponse; + +export type NonstocksResponse = BaseApiResponse; + +export type DeleteNonstockResponse = BaseApiResponse; From 230e96619788e87856f22c566e19955c3b511e32 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 2 Oct 2025 12:02:11 +0700 Subject: [PATCH 039/174] feat(FE-40): create Nonstock page --- src/app/master-data/nonstock/page.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/app/master-data/nonstock/page.tsx diff --git a/src/app/master-data/nonstock/page.tsx b/src/app/master-data/nonstock/page.tsx new file mode 100644 index 00000000..0812a5e2 --- /dev/null +++ b/src/app/master-data/nonstock/page.tsx @@ -0,0 +1,11 @@ +import NonstocksTable from '@/components/pages/master-data/nonstock/NonstocksTable'; + +const Nonstock = () => { + return ( +
+ +
+ ); +}; + +export default Nonstock; From c3da39ef1b4de77c71fb304e0d11da0bc1a8bf1a Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 2 Oct 2025 12:02:21 +0700 Subject: [PATCH 040/174] feat(FE-40): create Add Nonstock page --- src/app/master-data/nonstock/add/page.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/app/master-data/nonstock/add/page.tsx diff --git a/src/app/master-data/nonstock/add/page.tsx b/src/app/master-data/nonstock/add/page.tsx new file mode 100644 index 00000000..2bde94ed --- /dev/null +++ b/src/app/master-data/nonstock/add/page.tsx @@ -0,0 +1,11 @@ +import NonstockForm from '@/components/pages/master-data/nonstock/form/NonstockForm'; + +const AddNonstock = () => { + return ( +
+ +
+ ); +}; + +export default AddNonstock; From c40c707c1786fee4dea9685cf7892568e1466a84 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 2 Oct 2025 12:03:06 +0700 Subject: [PATCH 041/174] feat(FE-40): create Nonstock Detail page --- src/app/master-data/nonstock/detail/page.tsx | 49 ++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/app/master-data/nonstock/detail/page.tsx diff --git a/src/app/master-data/nonstock/detail/page.tsx b/src/app/master-data/nonstock/detail/page.tsx new file mode 100644 index 00000000..375ec999 --- /dev/null +++ b/src/app/master-data/nonstock/detail/page.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import NonstockForm from '@/components/pages/master-data/nonstock/form/NonstockForm'; + +import { getNonstock } from '@/services/api/master-data/nonstock'; +import { isResponseSuccess } from '@/lib/api-helper'; + +const NonstockDetail = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const nonstockId = searchParams.get('nonstockId'); + + const { data: nonstock, isLoading: isLoadingNonstock } = useSWR( + nonstockId, + getNonstock + ); + + if (!nonstockId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingNonstock && !nonstock) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingNonstock && ( + + )} + {!isLoadingNonstock && isResponseSuccess(nonstock) && ( + + )} +
+ ); +}; + +export default NonstockDetail; From 75a5caa63b87cefb436c9c6db740c7a839fa071d Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 2 Oct 2025 12:03:23 +0700 Subject: [PATCH 042/174] feat(FE-40): create Nonstock Edit page --- .../master-data/nonstock/detail/edit/page.tsx | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/app/master-data/nonstock/detail/edit/page.tsx diff --git a/src/app/master-data/nonstock/detail/edit/page.tsx b/src/app/master-data/nonstock/detail/edit/page.tsx new file mode 100644 index 00000000..a0fbb6b3 --- /dev/null +++ b/src/app/master-data/nonstock/detail/edit/page.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import NonstockForm from '@/components/pages/master-data/nonstock/form/NonstockForm'; + +import { getNonstock } from '@/services/api/master-data/nonstock'; +import { isResponseSuccess } from '@/lib/api-helper'; + +const NonstockEdit = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const nonstockId = searchParams.get('nonstockId'); + + const { data: nonstock, isLoading: isLoadingNonstock } = useSWR( + nonstockId, + getNonstock + ); + + if (!nonstockId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingNonstock && !nonstock) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingNonstock && ( + + )} + {!isLoadingNonstock && isResponseSuccess(nonstock) && ( + + )} +
+ ); +}; + +export default NonstockEdit; From e765a7a5fbdd158034bfb90fe9107009555ce7ff Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 2 Oct 2025 16:50:37 +0700 Subject: [PATCH 043/174] chore(FE-40): hide empty content if is loading --- src/components/Table.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Table.tsx b/src/components/Table.tsx index e20d4d11..d28f6e72 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -224,6 +224,7 @@ const Table = ({
{(data.length === 0 || table.getRowModel().rows.length === 0) && + !isLoading && emptyContent} {data.length > 0 && table.getRowModel().rows.length > 0 && !isLoading && ( From 83701a9689bd3f130e97070de0789c6d8d6c70a9 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 3 Oct 2025 14:12:02 +0700 Subject: [PATCH 044/174] chore(FE-40): update table pageSize if it change --- src/components/Table.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/Table.tsx b/src/components/Table.tsx index d28f6e72..ef99f6a7 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ReactNode, useCallback, useState } from 'react'; +import { ReactNode, useCallback, useEffect, useState } from 'react'; import { flexRender, getCoreRowModel, @@ -148,6 +148,10 @@ const Table = ({ } }; + useEffect(() => { + table.setPageSize(pageSize); + }, [pageSize]); + return (
From 60d0d77dffe15eab9b4e65276952893879f9cdbf Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 3 Oct 2025 22:13:35 +0700 Subject: [PATCH 045/174] feat(FE-40): add Alert component --- src/components/Alert.tsx | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/components/Alert.tsx diff --git a/src/components/Alert.tsx b/src/components/Alert.tsx new file mode 100644 index 00000000..61792d0c --- /dev/null +++ b/src/components/Alert.tsx @@ -0,0 +1,27 @@ +import { ReactNode } from 'react'; + +import { cn } from '@/lib/helper'; + +interface AlertProps { + variant?: 'outline' | 'dash' | 'soft'; + color?: 'info' | 'success' | 'warning' | 'error'; + children?: ReactNode; + className?: string; +} + +const Alert = ({ children, variant, color, className }: AlertProps) => { + const alertBaseClassName = cn('alert', { + 'alert-soft': variant === 'soft', + 'alert-outline': variant === 'outline', + 'alert-dash': variant === 'dash', + + 'alert-info': color === 'info', + 'alert-success': color === 'success', + 'alert-warning': color === 'warning', + 'alert-error': color === 'error', + }); + + return
{children}
; +}; + +export default Alert; From 42dd91117e568e1bea955b1671d8602cfb511b94 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 11:01:21 +0700 Subject: [PATCH 046/174] chore(FE-40): install react-hot-toast --- package-lock.json | 27 +++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 28 insertions(+) diff --git a/package-lock.json b/package-lock.json index ba8fc9b0..632d5b21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "next": "15.5.3", "react": "19.1.0", "react-dom": "19.1.0", + "react-hot-toast": "^2.6.0", "react-select": "^5.10.2", "swr": "^2.3.6", "tailwind-merge": "^3.3.1", @@ -4039,6 +4040,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5760,6 +5770,23 @@ "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==", "license": "MIT" }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index 93fd5b8d..55c2e5ba 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "next": "15.5.3", "react": "19.1.0", "react-dom": "19.1.0", + "react-hot-toast": "^2.6.0", "react-select": "^5.10.2", "swr": "^2.3.6", "tailwind-merge": "^3.3.1", From 5983a4431103512f1fa23a75a23218872e1a6277 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 11:18:08 +0700 Subject: [PATCH 047/174] chore(FE-40): add DebouncedTextInput component --- src/components/input/DebouncedTextInput.tsx | 42 +++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/components/input/DebouncedTextInput.tsx diff --git a/src/components/input/DebouncedTextInput.tsx b/src/components/input/DebouncedTextInput.tsx new file mode 100644 index 00000000..61dbf61c --- /dev/null +++ b/src/components/input/DebouncedTextInput.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { ChangeEvent, ChangeEventHandler, useEffect, useState } from 'react'; +import { useDebounce } from 'use-debounce'; + +import TextInput, { TextInputProps } from '@/components/input/TextInput'; + +interface DebouncedTextInputProps extends TextInputProps { + delay?: number; +} + +const DebouncedTextInput = (props: DebouncedTextInputProps) => { + const { delay, onChange } = props; + + const [internalChangeEvent, setInternalChangeEvent] = + useState>(); + const [internalValue, setInternalValue] = useState(props.value); + + const [debouncedChangeEvent] = useDebounce(internalChangeEvent, delay ?? 300); + const [debouncedValue] = useDebounce(internalValue, delay ?? 300); + + const internalChangeHandler: ChangeEventHandler = (e) => { + setInternalValue(e.target.value); + setInternalChangeEvent(e); + }; + + useEffect(() => { + if (debouncedChangeEvent) { + onChange?.(debouncedChangeEvent); + } + }, [debouncedValue, debouncedChangeEvent, onChange]); + + return ( + + ); +}; + +export default DebouncedTextInput; From 2976ffffbfc2e46f03d1447c9226f372d8671023 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 11:18:23 +0700 Subject: [PATCH 048/174] chore(FE-40): install use-debounce --- package-lock.json | 13 +++++++++++++ package.json | 1 + 2 files changed, 14 insertions(+) diff --git a/package-lock.json b/package-lock.json index 632d5b21..1aa69d33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "react-select": "^5.10.2", "swr": "^2.3.6", "tailwind-merge": "^3.3.1", + "use-debounce": "^10.0.6", "yup": "^1.7.0", "zustand": "^5.0.8" }, @@ -6819,6 +6820,18 @@ "punycode": "^2.1.0" } }, + "node_modules/use-debounce": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.6.tgz", + "integrity": "sha512-C5OtPyhAZgVoteO9heXMTdW7v/IbFI+8bSVKYCJrSmiWWCLsbUxiBSp4t9v0hNBTGY97bT72ydDIDyGSFWfwXg==", + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/use-isomorphic-layout-effect": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", diff --git a/package.json b/package.json index 55c2e5ba..8adf6787 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "react-select": "^5.10.2", "swr": "^2.3.6", "tailwind-merge": "^3.3.1", + "use-debounce": "^10.0.6", "yup": "^1.7.0", "zustand": "^5.0.8" }, From 18027f0bb9910c5930392f55621beeab1f740343 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 11:31:23 +0700 Subject: [PATCH 049/174] chore(FE-43): get setPageSize from table object --- src/components/Table.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/Table.tsx b/src/components/Table.tsx index ef99f6a7..100fb595 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -121,6 +121,7 @@ const Table = ({ } const table = useReactTable(tableOptions); + const { setPageSize } = table; const prevPageClickHandler = () => { table.previousPage(); @@ -149,8 +150,8 @@ const Table = ({ }; useEffect(() => { - table.setPageSize(pageSize); - }, [pageSize]); + setPageSize(pageSize); + }, [pageSize, setPageSize]); return (
From 20f6686afc4a5ea02b2fb2b7c1a1f977037e7b2b Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 12:08:08 +0700 Subject: [PATCH 050/174] chore(FE-43): add sorting and setSorting props --- src/components/Table.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/components/Table.tsx b/src/components/Table.tsx index 100fb595..882c1fe1 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -11,6 +11,8 @@ import { useReactTable, ColumnDef, FilterFn, + SortingState, + OnChangeFn, } from '@tanstack/react-table'; import { rankItem } from '@tanstack/match-sorter-utils'; import { Icon } from '@iconify/react'; @@ -43,6 +45,8 @@ export interface TableProps { onFuzzySearchValueChange?: (value: string) => void; className?: TableClassNames; emptyContent?: ReactNode; + sorting?: SortingState; + setSorting?: OnChangeFn; } const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}]; @@ -78,6 +82,8 @@ const Table = ({ paginationClassName: '', }, emptyContent = emptyContentDefaultValue, + sorting, + setSorting, }: TableProps) => { const isServerSideTable = totalItems !== undefined && @@ -105,9 +111,11 @@ const Table = ({ getSortedRowModel: getSortedRowModel(), getPaginationRowModel: getPaginationRowModel(), onPaginationChange: setPagination, + onSortingChange: setSorting, state: { pagination, globalFilter: fuzzySearchValue, + sorting, }, filterFns: { fuzzy: fuzzyFilter, From df1b4c29e55792069851ef22bd8ffc9736273703 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 12:13:49 +0700 Subject: [PATCH 051/174] feat(FE-43): create useTableFilter hooks --- src/services/hooks/useTableFilter.tsx | 246 ++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 src/services/hooks/useTableFilter.tsx diff --git a/src/services/hooks/useTableFilter.tsx b/src/services/hooks/useTableFilter.tsx new file mode 100644 index 00000000..d11d477f --- /dev/null +++ b/src/services/hooks/useTableFilter.tsx @@ -0,0 +1,246 @@ +import { useCallback, useMemo, useReducer } from 'react'; + +/** Core filter shape (page + pageSize) extended by your custom fields */ +export type TableFilterState> = { + page: number; + pageSize: number; +} & TExtra; + +type Action> = + | { type: 'SET_PAGE'; page: number } + | { type: 'SET_PAGE_SIZE'; pageSize: number; resetPage?: boolean } + | { type: 'SET_FILTERS'; filters: Partial; resetPage?: boolean } + | { + type: 'UPDATE_FILTER'; + key: keyof TExtra; + value: TExtra[keyof TExtra]; + resetPage?: boolean; + } + | { type: 'REPLACE_ALL'; next: TableFilterState } + | { type: 'RESET' }; + +export type UseTableFilterOptions> = { + /** Initial state; anything you omit falls back to defaults */ + initial?: Partial>; + /** Called after any state change */ + onChange?: (state: TableFilterState) => void; + /** Default page size (if not provided in initial) */ + defaultPageSize?: number; + /** Optional mapping to rename keys when exporting to URL (e.g., { pageSize: "rows" }) */ + paramMap?: Partial, string>>; + /** If true, `toSearchParams`/`toQueryString` will omit values equal to defaults */ + omitDefaultsInUrl?: boolean; +}; + +function clampToInt(n: number, min = 1) { + const v = Number.isFinite(n) ? Math.floor(n) : min; + return v < min ? min : v; +} + +function createInitialState>( + opts: UseTableFilterOptions | undefined +): TableFilterState { + const defaults = { + page: 1, + pageSize: opts?.defaultPageSize ?? 10, + } as TableFilterState; + + return { + ...defaults, + ...(opts?.initial as object), + } as TableFilterState; +} + +function serializeValue(v: unknown): string | null { + if (v === undefined || v === null) return null; + if (v instanceof Date) return v.toISOString(); + if (Array.isArray(v)) return v.map((x) => x ?? '').join(','); // e.g., ids=1,2,3 + const t = typeof v; + if (t === 'string' || t === 'number' || t === 'boolean') return String(v); + try { + return JSON.stringify(v); + } catch { + return null; + } +} + +// function shallowEqual(a: unknown, b: unknown): boolean { +// if (a === b) return true; +// if (!a || !b) return false; +// const ka = Object.keys(a); +// const kb = Object.keys(b); +// if (ka.length !== kb.length) return false; +// for (const k of ka) if (a[k] !== b[k]) return false; +// return true; +// } + +function shallowEqual>( + a: T | undefined | null, + b: T | undefined | null +): boolean { + if (a === b) return true; + if (!a || !b) return false; + const ka = Object.keys(a) as (keyof T)[]; + const kb = Object.keys(b) as (keyof T)[]; + if (ka.length !== kb.length) return false; + for (const k of ka) if (a[k] !== b[k]) return false; + return true; +} + +export function useTableFilter>( + options?: UseTableFilterOptions +) { + const defaults = useMemo( + () => createInitialState(options), + [options] + ); + + const [state, dispatch] = useReducer( + ( + s: TableFilterState, + a: Action + ): TableFilterState => { + switch (a.type) { + case 'SET_PAGE': + return { ...s, page: clampToInt(a.page) }; + case 'SET_PAGE_SIZE': { + const pageSize = clampToInt(a.pageSize); + const page = a.resetPage ? 1 : s.page; + return { ...s, pageSize, page }; + } + case 'SET_FILTERS': { + const page = a.resetPage ? 1 : s.page; + return { ...s, ...a.filters, page }; + } + case 'UPDATE_FILTER': { + const page = a.resetPage ? 1 : s.page; + return { ...s, [a.key]: a.value, page } as TableFilterState; + } + case 'REPLACE_ALL': + return { + ...a.next, + page: clampToInt(a.next.page), + pageSize: clampToInt(a.next.pageSize), + }; + case 'RESET': + return defaults; + default: + return s; + } + }, + defaults + ); + + // Notify consumer on change (stable ref) + const onChange = options?.onChange; + useMemo(() => { + if (onChange) onChange(state); + }, [state, onChange]); + + // Helpers (stable) + const setPage = useCallback((page: number) => { + dispatch({ type: 'SET_PAGE', page }); + }, []); + + const setPageSize = useCallback((pageSize: number, resetPage = true) => { + dispatch({ type: 'SET_PAGE_SIZE', pageSize, resetPage }); + }, []); + + const setFilters = useCallback( + (filters: Partial, resetPage = true) => { + dispatch({ type: 'SET_FILTERS', filters, resetPage }); + }, + [] + ); + + const updateFilter = useCallback( + (key: K, value: TExtra[K], resetPage = true) => { + dispatch({ type: 'UPDATE_FILTER', key, value, resetPage }); + }, + [dispatch] + ); + + const replaceAll = useCallback((next: TableFilterState) => { + dispatch({ type: 'REPLACE_ALL', next }); + }, []); + + const reset = useCallback(() => dispatch({ type: 'RESET' }), []); + + const core = useMemo( + () => ({ page: state.page, pageSize: state.pageSize }), + [state.page, state.pageSize] + ); + + const extras = useMemo(() => { + const { page, pageSize, ...rest } = state as TableFilterState< + Record + >; + return rest as TExtra; + }, [state]); + + /** Map a key using paramMap (if provided) */ + const mapKey = useCallback( + (key: string) => { + const m = options?.paramMap as Record | undefined; + return (m && m[key]) || key; + }, + [options?.paramMap] + ); + + /** Build URLSearchParams from current state */ + const toSearchParams = useCallback(() => { + const params = new URLSearchParams(); + const source = state as Record; + const baseline = options?.omitDefaultsInUrl + ? (defaults as Record) + : null; + + for (const key of Object.keys(source)) { + const value = source[key]; + if (value === undefined || value === null) continue; + + if ( + baseline && + shallowEqual( + value as Record, + baseline[key] as Record + ) + ) { + continue; + } + + const mapped = mapKey(key); + const serialized = serializeValue(value); + if (serialized !== null) params.set(mapped, serialized); + } + return params; + }, [state, defaults, options?.omitDefaultsInUrl, mapKey]); + + /** Build query string (prefixed with '?', or empty string if none) */ + const toQueryString = useCallback(() => { + const sp = toSearchParams(); + const s = sp.toString(); + return s ? `?${s}` : ''; + }, [toSearchParams]); + + return { + /** Full state (page, pageSize, and extras) */ + state, + /** Convenience accessors */ + page: state.page, + pageSize: state.pageSize, + filters: extras, + /** Setters */ + setPage, + setPageSize, + setFilters, + updateFilter, + replaceAll, + reset, + /** Sometimes handy to have just the core pair */ + core, + /** URL helpers */ + toSearchParams, + toQueryString, + }; +} From fa5d09e4fbed13cfe292d6cc006ed3c387b39f98 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 12:14:18 +0700 Subject: [PATCH 052/174] chore(FE-40): add Toaster component in root layout --- src/app/layout.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8843cc64..ef28da38 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata, Viewport } from 'next'; import { Inter } from 'next/font/google'; import '@/app/globals.css'; +import { Toaster } from 'react-hot-toast'; import MainDrawer from '@/components/MainDrawer'; import RequireAuth from '@/components/helper/RequireAuth'; @@ -32,6 +33,8 @@ export default function RootLayout({ {children} + + ); From 56476c7dd99d5354189dd18a1a668228ff724028 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 12:14:47 +0700 Subject: [PATCH 053/174] chore(FE-40): adjust loading dots size --- src/components/Button.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Button.tsx b/src/components/Button.tsx index a74a7e4f..c67a29c2 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -61,7 +61,7 @@ const Button = ({ )} > {!isLoading && children} - {isLoading && } + {isLoading && } )} @@ -76,7 +76,7 @@ const Button = ({ )} > {!isLoading && children} - {isLoading && } + {isLoading && } )} From ae159b9617e7beea83bc3b84bf7e8344325c6c20 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 12:15:06 +0700 Subject: [PATCH 054/174] chore(FE-40): remove unnecessary dependencies --- src/components/input/DebouncedTextInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/input/DebouncedTextInput.tsx b/src/components/input/DebouncedTextInput.tsx index 61dbf61c..4b62aaf7 100644 --- a/src/components/input/DebouncedTextInput.tsx +++ b/src/components/input/DebouncedTextInput.tsx @@ -28,7 +28,7 @@ const DebouncedTextInput = (props: DebouncedTextInputProps) => { if (debouncedChangeEvent) { onChange?.(debouncedChangeEvent); } - }, [debouncedValue, debouncedChangeEvent, onChange]); + }, [debouncedValue]); return ( Date: Sat, 4 Oct 2025 12:17:17 +0700 Subject: [PATCH 055/174] feat(FE-40): create ConfirmationModal component --- src/components/modal/ConfirmationModal.tsx | 124 +++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 src/components/modal/ConfirmationModal.tsx diff --git a/src/components/modal/ConfirmationModal.tsx b/src/components/modal/ConfirmationModal.tsx new file mode 100644 index 00000000..04c221e6 --- /dev/null +++ b/src/components/modal/ConfirmationModal.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { RefObject } from 'react'; + +import { Icon } from '@iconify/react'; +import Modal from '@/components/Modal'; +import Button from '@/components/Button'; + +import { cn } from '@/lib/helper'; +import { Color } from '@/types/theme'; + +interface ConfirmationModalProps { + ref: RefObject; + type?: 'info' | 'success' | 'error'; + text?: string; + closeOnBackdrop?: boolean; + primaryButton?: { + text?: string; + color?: Color; + isLoading?: boolean; + onClick?: () => void; + }; + secondaryButton?: { + text?: string; + color?: Color; + isLoading?: boolean; + onClick?: () => void; + }; + className?: { + modal?: string; + modalBox?: string; + }; +} + +const ConfirmationModal = ({ + ref, + type = 'info', + text, + closeOnBackdrop, + primaryButton, + secondaryButton, + className, +}: ConfirmationModalProps) => { + const closeModalHandler = () => { + ref.current?.close(); + }; + + return ( + +
+
+ {type === 'info' && ( + + )} + + {type === 'success' && ( + + )} + + {type === 'error' && ( + + )} +
+ +

+ {text ?? 'Apakah anda yakin ingin melakukan hal ini?'} +

+ +
+ {secondaryButton && secondaryButton.text && ( + + )} + + {primaryButton && primaryButton.text && ( + + )} +
+
+
+ ); +}; + +export default ConfirmationModal; From e259d1720c9583f0da9e3e7e8e6b66505fe098a4 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 12:17:42 +0700 Subject: [PATCH 056/174] feat(FE-43): create UomsTable component --- .../pages/master-data/uom/UomsTable.tsx | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 src/components/pages/master-data/uom/UomsTable.tsx diff --git a/src/components/pages/master-data/uom/UomsTable.tsx b/src/components/pages/master-data/uom/UomsTable.tsx new file mode 100644 index 00000000..080dfaf8 --- /dev/null +++ b/src/components/pages/master-data/uom/UomsTable.tsx @@ -0,0 +1,276 @@ +'use client'; + +import { ChangeEventHandler, useEffect, useState } from 'react'; +import useSWR from 'swr'; +import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; +import toast from 'react-hot-toast'; + +import { Icon } from '@iconify/react'; +import Table from '@/components/Table'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import Button from '@/components/Button'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import RowCollapseOptions from '@/components/table/RowCollapseOptions'; + +import { Uom } from '@/types/api/master-data/uom'; +import { UomApi } from '@/services/api/master-data'; +import { cn } from '@/lib/helper'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { ROWS_OPTIONS } from '@/config/constant'; + +const RowOptionsMenu = ({ + type = 'dropdown', + props, + deleteClickHandler, +}: { + type: 'dropdown' | 'collapse'; + props: CellContext; + deleteClickHandler: () => void; +}) => { + return ( +
+ + + + + +
+ ); +}; + +const UomsTable = () => { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { search: '', nameSort: '' }, + paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' }, + }); + + const { + data: uoms, + isLoading, + mutate: refreshUoms, + } = useSWR( + `${UomApi.basePath}${getTableFilterQueryString()}`, + UomApi.getAllFetcher + ); + + const deleteModal = useModal(); + + const [selectedUom, setSelectedUom] = useState(undefined); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const [sorting, setSorting] = useState([]); + + const uomsColumns: ColumnDef[] = [ + { + header: '#', + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, + }, + { + accessorKey: 'name', + header: 'Nama', + }, + { + header: 'Aksi', + cell: (props) => { + const currentPageSize = props.table.getPaginationRowModel().rows.length; + const currentPageRows = props.table.getPaginationRowModel().flatRows; + const currentRowRelativeIndex = + currentPageRows.findIndex((r) => r.id === props.row.id) + 1; + + const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; + + const deleteClickHandler = () => { + setSelectedUom(props.row.original); + deleteModal.openModal(); + }; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]; + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await UomApi.delete(selectedUom?.id as number); + refreshUoms(); + + deleteModal.closeModal(); + toast.success('Successfully delete UOM!'); + setIsDeleteLoading(false); + }; + + const searchChangeHandler: ChangeEventHandler = (e) => { + updateFilter('search', e.target.value); + }; + + const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + + setPageSize(newVal.value as number); + }; + + // track sorting + useEffect(() => { + const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name'); + + if (!isNameSorted) { + updateFilter('nameSort', ''); + } else { + updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc'); + } + }, [sorting]); + + return ( + <> +
+
+
+
+ +
+ + +
+ +
+ +
+
+ + + data={isResponseSuccess(uoms) ? uoms?.data : []} + columns={uomsColumns} + pageSize={tableFilterState.pageSize} + page={isResponseSuccess(uoms) ? uoms?.meta?.page : 0} + totalItems={isResponseSuccess(uoms) ? uoms?.meta?.total_results : 0} + onPageChange={setPage} + isLoading={isLoading} + sorting={sorting} + setSorting={setSorting} + className={{ + containerClassName: cn({ + 'mb-20': isResponseSuccess(uoms) && uoms?.data?.length === 0, + }), + tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableClassName: 'font-inter w-full table-auto min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', + bodyRowClassName: 'border-b border-b-gray-200', + bodyColumnClassName: + 'px-6 py-3 last:flex last:flex-row last:justify-end', + }} + /> +
+ + + + ); +}; + +export default UomsTable; From 36113f6c2ad839a0c47f5dfb9762598ac6687701 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 12:18:15 +0700 Subject: [PATCH 057/174] feat(FE-40,41): create UomForm component --- .../pages/master-data/uom/form/UomForm.tsx | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 src/components/pages/master-data/uom/form/UomForm.tsx diff --git a/src/components/pages/master-data/uom/form/UomForm.tsx b/src/components/pages/master-data/uom/form/UomForm.tsx new file mode 100644 index 00000000..87cbdf34 --- /dev/null +++ b/src/components/pages/master-data/uom/form/UomForm.tsx @@ -0,0 +1,174 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useFormik } from 'formik'; +import { toast } from 'react-hot-toast'; + +import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; +import TextInput from '@/components/input/TextInput'; + +import { + UomFormSchema, + UomFormValues, + UpdateUomFormSchema, +} from '@/components/pages/master-data/uom/form/UomForm.schema'; +import { isResponseError } from '@/lib/api-helper'; +import { + CreateUomPayload, + Uom, + UpdateUomPayload, +} from '@/types/api/master-data/uom'; +import { UomApi } from '@/services/api/master-data'; + +interface UomFormProps { + type?: 'add' | 'edit' | 'detail'; + initialValues?: Uom; +} + +const UomForm = ({ type = 'add', initialValues }: UomFormProps) => { + const router = useRouter(); + + const [uomFormErrorMessage, setUomFormErrorMessage] = useState(''); + + const createUomHandler = useCallback( + async (payload: CreateUomPayload) => { + const createUomRes = await UomApi.create(payload); + + if (isResponseError(createUomRes)) { + setUomFormErrorMessage(createUomRes.message); + return; + } + + toast.success(createUomRes?.message as string); + router.push('/master-data/uom'); + }, + [router] + ); + + const updateUomHandler = useCallback( + async (uomId: number, payload: UpdateUomPayload) => { + const updateUomRes = await UomApi.update(uomId, payload); + + if (updateUomRes?.status === 'error') { + setUomFormErrorMessage(updateUomRes.message); + return; + } + + toast.success(updateUomRes?.message as string); + router.refresh(); + router.push('/master-data/uom'); + }, + [router] + ); + + const formikInitialValues = useMemo(() => { + return { + name: initialValues?.name ?? '', + }; + }, [initialValues]); + + const formik = useFormik({ + initialValues: formikInitialValues, + validationSchema: type === 'edit' ? UpdateUomFormSchema : UomFormSchema, + onSubmit: async (values) => { + setUomFormErrorMessage(''); + + const uomPayload: CreateUomPayload = { + name: values.name, + }; + + switch (type) { + case 'add': + await createUomHandler(uomPayload); + break; + + case 'edit': + await updateUomHandler(initialValues?.id as number, uomPayload); + break; + } + }, + }); + + const { setValues: formikSetValues } = formik; + + useEffect(() => { + formikSetValues(formikInitialValues); + }, [formikSetValues, formikInitialValues]); + + return ( +
+
+ + +

+ {type === 'add' && 'Tambah UOM'} + {type === 'edit' && 'Edit UOM'} + {type === 'detail' && 'Detail UOM'} +

+
+ +
+
+ +
+ + {type !== 'detail' && ( + <> +
+ + + +
+ + {uomFormErrorMessage && ( +
+ + {uomFormErrorMessage} +
+ )} + + )} +
+
+ ); +}; + +export default UomForm; From f1a8fda6678b01221902da1e2963978413856b10 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 12:18:39 +0700 Subject: [PATCH 058/174] feat(FE-42): create UOM form validation schema --- .../pages/master-data/uom/form/UomForm.schema.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/components/pages/master-data/uom/form/UomForm.schema.ts diff --git a/src/components/pages/master-data/uom/form/UomForm.schema.ts b/src/components/pages/master-data/uom/form/UomForm.schema.ts new file mode 100644 index 00000000..e7cb5d06 --- /dev/null +++ b/src/components/pages/master-data/uom/form/UomForm.schema.ts @@ -0,0 +1,9 @@ +import * as Yup from 'yup'; + +export const UomFormSchema = Yup.object({ + name: Yup.string().required('Nama wajib diisi!'), +}); + +export const UpdateUomFormSchema = UomFormSchema; + +export type UomFormValues = Yup.InferType; From 34e9e6017388e1363089f50da7edc9fb748aaca7 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 12:19:01 +0700 Subject: [PATCH 059/174] feat(FE-43): create RowCollapseOptions component --- src/components/table/RowCollapseOptions.tsx | 27 +++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/components/table/RowCollapseOptions.tsx diff --git a/src/components/table/RowCollapseOptions.tsx b/src/components/table/RowCollapseOptions.tsx new file mode 100644 index 00000000..42f9720a --- /dev/null +++ b/src/components/table/RowCollapseOptions.tsx @@ -0,0 +1,27 @@ +import { ReactNode } from 'react'; + +import { Icon } from '@iconify/react'; +import Collapse from '@/components/Collapse'; +import Button from '@/components/Button'; + +interface RowCollapseOptionsProps { + children?: ReactNode; +} + +const RowCollapseOptions = ({ children }: RowCollapseOptionsProps) => { + return ( + + + + } + className='w-fit' + titleClassName='p-0! justify-self-end' + > + {children} + + ); +}; + +export default RowCollapseOptions; From 65e3833cd51ca78a66d32d4d82b20365d354eea5 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 12:19:15 +0700 Subject: [PATCH 060/174] feat(FE-43): create RowDropdownOptions component --- src/components/table/RowDropdownOptions.tsx | 33 +++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/components/table/RowDropdownOptions.tsx diff --git a/src/components/table/RowDropdownOptions.tsx b/src/components/table/RowDropdownOptions.tsx new file mode 100644 index 00000000..1f1bc81b --- /dev/null +++ b/src/components/table/RowDropdownOptions.tsx @@ -0,0 +1,33 @@ +import { ReactNode } from 'react'; + +import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; + +import { cn } from '@/lib/helper'; + +interface RowDropdownOptionsProps { + children?: ReactNode; + isLast2Rows: boolean; +} + +const RowDropdownOptions = ({ + children, + isLast2Rows, +}: RowDropdownOptionsProps) => { + return ( +
+ + + {children} +
+ ); +}; + +export default RowDropdownOptions; From 6a396ccce6953bf9b261c75e6928bc23796ee0b8 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 12:19:52 +0700 Subject: [PATCH 061/174] feat(FE-43): create Master Data UOM page --- src/app/master-data/uom/page.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/app/master-data/uom/page.tsx diff --git a/src/app/master-data/uom/page.tsx b/src/app/master-data/uom/page.tsx new file mode 100644 index 00000000..689b9d0d --- /dev/null +++ b/src/app/master-data/uom/page.tsx @@ -0,0 +1,11 @@ +import UomsTable from '@/components/pages/master-data/uom/UomsTable'; + +const Nonstock = () => { + return ( +
+ +
+ ); +}; + +export default Nonstock; From f27b261869fff3ac00d41ee8433407913a25dc19 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 12:20:19 +0700 Subject: [PATCH 062/174] feat(FE-40,41): create Master Data Add UOM page --- src/app/master-data/uom/add/page.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/app/master-data/uom/add/page.tsx diff --git a/src/app/master-data/uom/add/page.tsx b/src/app/master-data/uom/add/page.tsx new file mode 100644 index 00000000..452aadf8 --- /dev/null +++ b/src/app/master-data/uom/add/page.tsx @@ -0,0 +1,11 @@ +import UomForm from '@/components/pages/master-data/uom/form/UomForm'; + +const AddNonstock = () => { + return ( +
+ +
+ ); +}; + +export default AddNonstock; From e6c14f57d9f14956273cbe9ef72c6e4be8d3d845 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 12:21:14 +0700 Subject: [PATCH 063/174] feat(FE-40,41): create Master Data Detail UOM page --- src/app/master-data/uom/detail/page.tsx | 46 +++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/app/master-data/uom/detail/page.tsx diff --git a/src/app/master-data/uom/detail/page.tsx b/src/app/master-data/uom/detail/page.tsx new file mode 100644 index 00000000..59ad0755 --- /dev/null +++ b/src/app/master-data/uom/detail/page.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import UomForm from '@/components/pages/master-data/uom/form/UomForm'; + +import { UomApi } from '@/services/api/master-data'; +import { isResponseSuccess } from '@/lib/api-helper'; + +const UomDetail = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const uomId = searchParams.get('uomId'); + + const { data: uom, isLoading: isLoadingUom } = useSWR(uomId, (id: number) => + UomApi.getSingle(id) + ); + + if (!uomId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingUom && !uom) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingUom && } + {!isLoadingUom && isResponseSuccess(uom) && ( + + )} +
+ ); +}; + +export default UomDetail; From d941674f9a66fb9e3279d03a6bad2127fa6304b6 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 12:21:38 +0700 Subject: [PATCH 064/174] feat(FE-40,41): create Master Data Edit UOM page --- src/app/master-data/uom/detail/edit/page.tsx | 46 ++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/app/master-data/uom/detail/edit/page.tsx diff --git a/src/app/master-data/uom/detail/edit/page.tsx b/src/app/master-data/uom/detail/edit/page.tsx new file mode 100644 index 00000000..95566351 --- /dev/null +++ b/src/app/master-data/uom/detail/edit/page.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import UomForm from '@/components/pages/master-data/uom/form/UomForm'; + +import { UomApi } from '@/services/api/master-data'; +import { isResponseSuccess } from '@/lib/api-helper'; + +const UomEdit = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const uomId = searchParams.get('uomId'); + + const { data: uom, isLoading: isLoadingUom } = useSWR(uomId, (id: number) => + UomApi.getSingle(id) + ); + + if (!uomId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingUom && !uom) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingUom && } + {!isLoadingUom && isResponseSuccess(uom) && ( + + )} +
+ ); +}; + +export default UomEdit; From b5d9c55fbc78c22f15af2e1f7ee31e3cca767e4b Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 12:22:00 +0700 Subject: [PATCH 065/174] chore(FE-41): create UOM type --- src/types/api/master-data/uom.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/types/api/master-data/uom.ts diff --git a/src/types/api/master-data/uom.ts b/src/types/api/master-data/uom.ts new file mode 100644 index 00000000..394f6050 --- /dev/null +++ b/src/types/api/master-data/uom.ts @@ -0,0 +1,12 @@ +import { BaseMetadata } from '@/types/api/api-general'; + +export type Uom = BaseMetadata & { + id: number; + name: string; +}; + +export type CreateUomPayload = { + name: string; +}; + +export type UpdateUomPayload = CreateUomPayload; From 6dec9268c95641820fd38ad766f34d2734e42f04 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 12:22:25 +0700 Subject: [PATCH 066/174] feat(FE-41): create Master Data API Service class --- src/services/api/master-data.ts | 93 +++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 src/services/api/master-data.ts diff --git a/src/services/api/master-data.ts b/src/services/api/master-data.ts new file mode 100644 index 00000000..10d7bbee --- /dev/null +++ b/src/services/api/master-data.ts @@ -0,0 +1,93 @@ +import axios from 'axios'; +import { httpClient, httpClientFetcher } from '@/services/http/client'; +import { BaseApiResponse } from '@/types/api/api-general'; +import { + CreateUomPayload, + Uom, + UpdateUomPayload, +} from '@/types/api/master-data/uom'; + +export class MasterDataApi { + basePath: string; + + constructor(basePath: string) { + this.basePath = basePath; + } + + async getAllFetcher(endpoint: string): Promise> { + return await httpClientFetcher>(endpoint); + } + + async getSingle(id: number) { + try { + const getSinglePath = `${this.basePath}/${id}`; + const getSingleRes = await httpClient>(getSinglePath); + + return getSingleRes; + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + + return undefined; + } + } + + async create(payload: CreatePayloadGeneric) { + try { + const createRes = await httpClient>(this.basePath, { + method: 'POST', + body: payload, + }); + + return createRes; + } catch (error: unknown) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + + return undefined; + } + } + + async update(id: number, payload: UpdatePayloadGeneric) { + try { + const updatePath = `${this.basePath}/${id}`; + const updateRes = await httpClient>(updatePath, { + method: 'PATCH', + body: payload, + }); + + return updateRes; + } catch (error: unknown) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + + return undefined; + } + } + + async delete(id: number) { + try { + const deletePath = `${this.basePath}/${id}`; + const deleteRes = await httpClient(deletePath, { + method: 'DELETE', + }); + + return deleteRes; + } catch (error) { + if (axios.isAxiosError(error)) { + return error.response?.data; + } + + return undefined; + } + } +} + +export const UomApi = new MasterDataApi< + Uom, + CreateUomPayload, + UpdateUomPayload +>('/master-data/uoms'); From bbe55ee4c38b8cb8f8804982b5b54b57e7bdc0c8 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 12:22:43 +0700 Subject: [PATCH 067/174] chore(FE-43): add ROWS_OPTIONS constant --- src/config/constant.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/config/constant.ts b/src/config/constant.ts index 1abf588a..269f5da9 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -72,4 +72,23 @@ export const MAIN_DRAWER_LINKS = [ }, ], }, +] as const; + +export const ROWS_OPTIONS = [ + { + label: '10', + value: 10, + }, + { + label: '20', + value: 20, + }, + { + label: '50', + value: 50, + }, + { + label: '100', + value: 100, + }, ]; From d29d1f27f8760aade741a64a27750d4b26e987b4 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 12:23:14 +0700 Subject: [PATCH 068/174] chore(FE-41): create CreatedUser and BaseMetadata type --- src/types/api/api-general.d.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/types/api/api-general.d.ts b/src/types/api/api-general.d.ts index 90df754b..f33b94ee 100644 --- a/src/types/api/api-general.d.ts +++ b/src/types/api/api-general.d.ts @@ -37,3 +37,16 @@ export type User = { export type UserWithRoles = User & { roles: RoleWithPermissions[]; }; + +export type CreatedUser = { + id: number; + id_user: number; + email: string; + name: string; +}; + +export type BaseMetadata = { + created_user: CreatedUser; + created_at: string; + updated_at: string; +}; From 777f0f5e8132311007bd10c6fdd281796d060901 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 12:23:31 +0700 Subject: [PATCH 069/174] feat(FE-40): add none type to Color --- src/types/theme.d.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/types/theme.d.ts b/src/types/theme.d.ts index cff880fd..f83750e4 100644 --- a/src/types/theme.d.ts +++ b/src/types/theme.d.ts @@ -6,6 +6,7 @@ type Color = | 'info' | 'success' | 'warning' - | 'error'; + | 'error' + | 'none'; export { Color }; From eebc9940cc71f39965761a7cb603cbfffb445431 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 13:55:28 +0700 Subject: [PATCH 070/174] feat(FE-43): create AreasTable component --- .../pages/master-data/area/AreasTable.tsx | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 src/components/pages/master-data/area/AreasTable.tsx diff --git a/src/components/pages/master-data/area/AreasTable.tsx b/src/components/pages/master-data/area/AreasTable.tsx new file mode 100644 index 00000000..4d0bec73 --- /dev/null +++ b/src/components/pages/master-data/area/AreasTable.tsx @@ -0,0 +1,276 @@ +'use client'; + +import { ChangeEventHandler, useEffect, useState } from 'react'; +import useSWR from 'swr'; +import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; +import toast from 'react-hot-toast'; + +import { Icon } from '@iconify/react'; +import Table from '@/components/Table'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import Button from '@/components/Button'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import RowCollapseOptions from '@/components/table/RowCollapseOptions'; + +import { Area } from '@/types/api/master-data/area'; +import { AreaApi } from '@/services/api/master-data'; +import { cn } from '@/lib/helper'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { ROWS_OPTIONS } from '@/config/constant'; + +const RowOptionsMenu = ({ + type = 'dropdown', + props, + deleteClickHandler, +}: { + type: 'dropdown' | 'collapse'; + props: CellContext; + deleteClickHandler: () => void; +}) => { + return ( +
+ + + + + +
+ ); +}; + +const AreasTable = () => { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { search: '', nameSort: '' }, + paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' }, + }); + + const { + data: areas, + isLoading, + mutate: refreshAreas, + } = useSWR( + `${AreaApi.basePath}${getTableFilterQueryString()}`, + AreaApi.getAllFetcher + ); + + const deleteModal = useModal(); + + const [selectedArea, setSelectedArea] = useState(undefined); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const [sorting, setSorting] = useState([]); + + const areasColumns: ColumnDef[] = [ + { + header: '#', + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, + }, + { + accessorKey: 'name', + header: 'Nama', + }, + { + header: 'Aksi', + cell: (props) => { + const currentPageSize = props.table.getPaginationRowModel().rows.length; + const currentPageRows = props.table.getPaginationRowModel().flatRows; + const currentRowRelativeIndex = + currentPageRows.findIndex((r) => r.id === props.row.id) + 1; + + const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; + + const deleteClickHandler = () => { + setSelectedArea(props.row.original); + deleteModal.openModal(); + }; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]; + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await AreaApi.delete(selectedArea?.id as number); + refreshAreas(); + + deleteModal.closeModal(); + toast.success('Successfully delete Area!'); + setIsDeleteLoading(false); + }; + + const searchChangeHandler: ChangeEventHandler = (e) => { + updateFilter('search', e.target.value); + }; + + const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + + setPageSize(newVal.value as number); + }; + + // track sorting + useEffect(() => { + const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name'); + + if (!isNameSorted) { + updateFilter('nameSort', ''); + } else { + updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc'); + } + }, [sorting]); + + return ( + <> +
+
+
+
+ +
+ + +
+ +
+ +
+
+ + + data={isResponseSuccess(areas) ? areas?.data : []} + columns={areasColumns} + pageSize={tableFilterState.pageSize} + page={isResponseSuccess(areas) ? areas?.meta?.page : 0} + totalItems={isResponseSuccess(areas) ? areas?.meta?.total_results : 0} + onPageChange={setPage} + isLoading={isLoading} + sorting={sorting} + setSorting={setSorting} + className={{ + containerClassName: cn({ + 'mb-20': isResponseSuccess(areas) && areas?.data?.length === 0, + }), + tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableClassName: 'font-inter w-full table-auto min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', + bodyRowClassName: 'border-b border-b-gray-200', + bodyColumnClassName: + 'px-6 py-3 last:flex last:flex-row last:justify-end', + }} + /> +
+ + + + ); +}; + +export default AreasTable; From b82637fb3b01988838fa85a507849d0a9f460b16 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 13:55:52 +0700 Subject: [PATCH 071/174] feat(FE-40,41): create AreaForm component --- .../pages/master-data/area/form/AreaForm.tsx | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 src/components/pages/master-data/area/form/AreaForm.tsx diff --git a/src/components/pages/master-data/area/form/AreaForm.tsx b/src/components/pages/master-data/area/form/AreaForm.tsx new file mode 100644 index 00000000..6cdb4c5d --- /dev/null +++ b/src/components/pages/master-data/area/form/AreaForm.tsx @@ -0,0 +1,236 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useFormik } from 'formik'; +import { toast } from 'react-hot-toast'; + +import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; +import TextInput from '@/components/input/TextInput'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; + +import { + AreaFormSchema, + AreaFormValues, + UpdateAreaFormSchema, +} from '@/components/pages/master-data/area/form/AreaForm.schema'; +import { isResponseError } from '@/lib/api-helper'; +import { + Area, + CreateAreaPayload, + UpdateAreaPayload, +} from '@/types/api/master-data/area'; +import { AreaApi } from '@/services/api/master-data'; +import { cn } from '@/lib/helper'; + +interface AreaFormProps { + type?: 'add' | 'edit' | 'detail'; + initialValues?: Area; +} + +const AreaForm = ({ type = 'add', initialValues }: AreaFormProps) => { + const router = useRouter(); + const deleteModal = useModal(); + + const [areaFormErrorMessage, setAreaFormErrorMessage] = useState(''); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const createAreaHandler = useCallback( + async (payload: CreateAreaPayload) => { + const createAreaRes = await AreaApi.create(payload); + + if (isResponseError(createAreaRes)) { + setAreaFormErrorMessage(createAreaRes.message); + return; + } + + toast.success(createAreaRes?.message as string); + router.push('/master-data/area'); + }, + [router] + ); + + const updateAreaHandler = useCallback( + async (areaId: number, payload: UpdateAreaPayload) => { + const updateAreaRes = await AreaApi.update(areaId, payload); + + if (updateAreaRes?.status === 'error') { + setAreaFormErrorMessage(updateAreaRes.message); + return; + } + + toast.success(updateAreaRes?.message as string); + router.refresh(); + router.push('/master-data/area'); + }, + [router] + ); + + const formikInitialValues = useMemo(() => { + return { + name: initialValues?.name ?? '', + }; + }, [initialValues]); + + const formik = useFormik({ + initialValues: formikInitialValues, + validationSchema: type === 'edit' ? UpdateAreaFormSchema : AreaFormSchema, + onSubmit: async (values) => { + setAreaFormErrorMessage(''); + + const areaPayload: CreateAreaPayload = { + name: values.name, + }; + + switch (type) { + case 'add': + await createAreaHandler(areaPayload); + break; + + case 'edit': + await updateAreaHandler(initialValues?.id as number, areaPayload); + break; + } + }, + }); + + const { setValues: formikSetValues } = formik; + + const deleteAreaClickHandler = () => { + deleteModal.openModal(); + }; + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await AreaApi.delete(initialValues?.id as number); + + deleteModal.closeModal(); + toast.success('Successfully delete Area!'); + setIsDeleteLoading(false); + router.push('/master-data/area'); + }; + + useEffect(() => { + formikSetValues(formikInitialValues); + }, [formikSetValues, formikInitialValues]); + + return ( + <> +
+
+ + +

+ {type === 'add' && 'Tambah Area'} + {type === 'edit' && 'Edit Area'} + {type === 'detail' && 'Detail Area'} +

+
+ +
+
+ +
+ +
+ {type !== 'add' && ( +
+ +
+ )} + + {type !== 'detail' && ( +
+ + + +
+ )} +
+ + {areaFormErrorMessage && ( +
+ + {areaFormErrorMessage} +
+ )} +
+
+ + {type !== 'add' && ( + + )} + + ); +}; + +export default AreaForm; From 211951132aecfe01d5d018338bec36785d4af31c Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 13:56:17 +0700 Subject: [PATCH 072/174] feat(FE-42): create Area form validation schema --- .../pages/master-data/area/form/AreaForm.schema.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/components/pages/master-data/area/form/AreaForm.schema.ts diff --git a/src/components/pages/master-data/area/form/AreaForm.schema.ts b/src/components/pages/master-data/area/form/AreaForm.schema.ts new file mode 100644 index 00000000..96c70243 --- /dev/null +++ b/src/components/pages/master-data/area/form/AreaForm.schema.ts @@ -0,0 +1,9 @@ +import * as Yup from 'yup'; + +export const AreaFormSchema = Yup.object({ + name: Yup.string().required('Nama wajib diisi!'), +}); + +export const UpdateAreaFormSchema = AreaFormSchema; + +export type AreaFormValues = Yup.InferType; From c494f8dbd5158debc9c6d5e35701e37e90f1acfb Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 13:56:51 +0700 Subject: [PATCH 073/174] feat(FE-41): create Area type --- src/types/api/master-data/area.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/types/api/master-data/area.ts diff --git a/src/types/api/master-data/area.ts b/src/types/api/master-data/area.ts new file mode 100644 index 00000000..e9db38db --- /dev/null +++ b/src/types/api/master-data/area.ts @@ -0,0 +1,12 @@ +import { BaseMetadata } from '@/types/api/api-general'; + +export type Area = BaseMetadata & { + id: number; + name: string; +}; + +export type CreateAreaPayload = { + name: string; +}; + +export type UpdateAreaPayload = CreateAreaPayload; From 6dd6147c29d0f3899498a65408e1c5fd7b837623 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 13:57:05 +0700 Subject: [PATCH 074/174] feat(FE-41): create AreaApi service --- src/services/api/master-data.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/services/api/master-data.ts b/src/services/api/master-data.ts index 10d7bbee..941fa77b 100644 --- a/src/services/api/master-data.ts +++ b/src/services/api/master-data.ts @@ -6,6 +6,11 @@ import { Uom, UpdateUomPayload, } from '@/types/api/master-data/uom'; +import { + Area, + CreateAreaPayload, + UpdateAreaPayload, +} from '@/types/api/master-data/area'; export class MasterDataApi { basePath: string; @@ -91,3 +96,9 @@ export const UomApi = new MasterDataApi< CreateUomPayload, UpdateUomPayload >('/master-data/uoms'); + +export const AreaApi = new MasterDataApi< + Area, + CreateAreaPayload, + UpdateAreaPayload +>('/master-data/areas'); From 4bf4981fd4587613fabcc830f173f9bcee451a23 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 13:57:21 +0700 Subject: [PATCH 075/174] feat(FE-43): create Master Data Area page --- src/app/master-data/area/page.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/app/master-data/area/page.tsx diff --git a/src/app/master-data/area/page.tsx b/src/app/master-data/area/page.tsx new file mode 100644 index 00000000..f8789af2 --- /dev/null +++ b/src/app/master-data/area/page.tsx @@ -0,0 +1,11 @@ +import AreasTable from '@/components/pages/master-data/area/AreasTable'; + +const Nonstock = () => { + return ( +
+ +
+ ); +}; + +export default Nonstock; From 69ecacc1be17f88b83a82c83a95d2d9a0292f617 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 13:57:48 +0700 Subject: [PATCH 076/174] feat(FE-40,41): create Master Data Add Area page --- src/app/master-data/area/add/page.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/app/master-data/area/add/page.tsx diff --git a/src/app/master-data/area/add/page.tsx b/src/app/master-data/area/add/page.tsx new file mode 100644 index 00000000..ed23b0b7 --- /dev/null +++ b/src/app/master-data/area/add/page.tsx @@ -0,0 +1,11 @@ +import AreaForm from '@/components/pages/master-data/area/form/AreaForm'; + +const AddNonstock = () => { + return ( +
+ +
+ ); +}; + +export default AddNonstock; From 172d8efd8e88061b9c053fceecf41297079db4a6 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 13:58:11 +0700 Subject: [PATCH 077/174] feat(FE-40,41): create Master Data Detail Area page --- src/app/master-data/area/detail/page.tsx | 47 ++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/app/master-data/area/detail/page.tsx diff --git a/src/app/master-data/area/detail/page.tsx b/src/app/master-data/area/detail/page.tsx new file mode 100644 index 00000000..55bfbc7d --- /dev/null +++ b/src/app/master-data/area/detail/page.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import AreaForm from '@/components/pages/master-data/area/form/AreaForm'; + +import { AreaApi } from '@/services/api/master-data'; +import { isResponseSuccess } from '@/lib/api-helper'; + +const AreaDetail = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const areaId = searchParams.get('areaId'); + + const { data: area, isLoading: isLoadingArea } = useSWR( + areaId, + (id: number) => AreaApi.getSingle(id) + ); + + if (!areaId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingArea && !area) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingArea && } + {!isLoadingArea && isResponseSuccess(area) && ( + + )} +
+ ); +}; + +export default AreaDetail; From 9ba7b5dba4f5b4dcb672cd4f2fcd495e2857d2f5 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 13:58:53 +0700 Subject: [PATCH 078/174] feat(FE-40,41): create Master Data Edit Area page --- src/app/master-data/area/detail/edit/page.tsx | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/app/master-data/area/detail/edit/page.tsx diff --git a/src/app/master-data/area/detail/edit/page.tsx b/src/app/master-data/area/detail/edit/page.tsx new file mode 100644 index 00000000..a8b5138f --- /dev/null +++ b/src/app/master-data/area/detail/edit/page.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import AreaForm from '@/components/pages/master-data/area/form/AreaForm'; + +import { AreaApi } from '@/services/api/master-data'; +import { isResponseSuccess } from '@/lib/api-helper'; + +const AreaEdit = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const areaId = searchParams.get('areaId'); + + const { data: area, isLoading: isLoadingArea } = useSWR( + areaId, + (id: number) => AreaApi.getSingle(id) + ); + + if (!areaId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingArea && !area) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingArea && } + {!isLoadingArea && isResponseSuccess(area) && ( + + )} +
+ ); +}; + +export default AreaEdit; From 73cefbb7a31b654e26da4474e872f42b56fa30e6 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 14:03:27 +0700 Subject: [PATCH 079/174] chore(FE-40): change delete confirmation modal text --- src/components/pages/master-data/area/form/AreaForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/pages/master-data/area/form/AreaForm.tsx b/src/components/pages/master-data/area/form/AreaForm.tsx index 6cdb4c5d..d61b1b84 100644 --- a/src/components/pages/master-data/area/form/AreaForm.tsx +++ b/src/components/pages/master-data/area/form/AreaForm.tsx @@ -217,7 +217,7 @@ const AreaForm = ({ type = 'add', initialValues }: AreaFormProps) => { Date: Sat, 4 Oct 2025 14:03:53 +0700 Subject: [PATCH 080/174] chore(FE-40): add delete button and delete confirmation modal --- .../pages/master-data/uom/form/UomForm.tsx | 194 ++++++++++++------ 1 file changed, 128 insertions(+), 66 deletions(-) diff --git a/src/components/pages/master-data/uom/form/UomForm.tsx b/src/components/pages/master-data/uom/form/UomForm.tsx index 87cbdf34..8ece22a8 100644 --- a/src/components/pages/master-data/uom/form/UomForm.tsx +++ b/src/components/pages/master-data/uom/form/UomForm.tsx @@ -8,6 +8,8 @@ import { toast } from 'react-hot-toast'; import { Icon } from '@iconify/react'; import Button from '@/components/Button'; import TextInput from '@/components/input/TextInput'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; import { UomFormSchema, @@ -21,6 +23,7 @@ import { UpdateUomPayload, } from '@/types/api/master-data/uom'; import { UomApi } from '@/services/api/master-data'; +import { cn } from '@/lib/helper'; interface UomFormProps { type?: 'add' | 'edit' | 'detail'; @@ -29,8 +32,10 @@ interface UomFormProps { const UomForm = ({ type = 'add', initialValues }: UomFormProps) => { const router = useRouter(); + const deleteModal = useModal(); const [uomFormErrorMessage, setUomFormErrorMessage] = useState(''); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); const createUomHandler = useCallback( async (payload: CreateUomPayload) => { @@ -93,81 +98,138 @@ const UomForm = ({ type = 'add', initialValues }: UomFormProps) => { const { setValues: formikSetValues } = formik; + const deleteUomClickHandler = () => { + deleteModal.openModal(); + }; + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await UomApi.delete(initialValues?.id as number); + + deleteModal.closeModal(); + toast.success('Successfully delete UOM!'); + setIsDeleteLoading(false); + router.push('/master-data/uom'); + }; + useEffect(() => { formikSetValues(formikInitialValues); }, [formikSetValues, formikInitialValues]); return ( -
-
- + +

+ {type === 'add' && 'Tambah UOM'} + {type === 'edit' && 'Edit UOM'} + {type === 'detail' && 'Detail UOM'} +

+
+ +
- - Kembali - +
+ +
-

- {type === 'add' && 'Tambah UOM'} - {type === 'edit' && 'Edit UOM'} - {type === 'detail' && 'Detail UOM'} -

- - - -
- -
- - {type !== 'detail' && ( - <> -
- - - -
- - {uomFormErrorMessage && ( -
- - {uomFormErrorMessage} +
+ {type !== 'add' && ( +
+
)} - - )} - -
+ + {type !== 'detail' && ( +
+ + + +
+ )} +
+ + {uomFormErrorMessage && ( +
+ + {uomFormErrorMessage} +
+ )} + + + + {type !== 'add' && ( + + )} + ); }; From e1c34cf0fb2a60d9ae58d0a3c80032d904905808 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 14:08:53 +0700 Subject: [PATCH 081/174] chore(FE-41): create BaseUom type --- src/types/api/master-data/uom.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/types/api/master-data/uom.ts b/src/types/api/master-data/uom.ts index 394f6050..0d2f0210 100644 --- a/src/types/api/master-data/uom.ts +++ b/src/types/api/master-data/uom.ts @@ -1,10 +1,12 @@ import { BaseMetadata } from '@/types/api/api-general'; -export type Uom = BaseMetadata & { +export type BaseUom = { id: number; name: string; }; +export type Uom = BaseMetadata & BaseUom; + export type CreateUomPayload = { name: string; }; From a2345165c14f5566b59577401c0ef2422354712d Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 14:09:03 +0700 Subject: [PATCH 082/174] chore(FE-41): create BaseArea type --- src/types/api/master-data/area.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/types/api/master-data/area.ts b/src/types/api/master-data/area.ts index e9db38db..74e0196e 100644 --- a/src/types/api/master-data/area.ts +++ b/src/types/api/master-data/area.ts @@ -1,10 +1,12 @@ import { BaseMetadata } from '@/types/api/api-general'; -export type Area = BaseMetadata & { +export type BaseArea = { id: number; name: string; }; +export type Area = BaseMetadata & BaseArea; + export type CreateAreaPayload = { name: string; }; From 7ea599168cc65f64bbf8c1272aeb93cee5270912 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 14:25:29 +0700 Subject: [PATCH 083/174] chore(FE-43): add conditional to set sorting and setSorting and add manualSorting props --- src/components/Table.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/components/Table.tsx b/src/components/Table.tsx index 882c1fe1..cfd77df6 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -47,6 +47,7 @@ export interface TableProps { emptyContent?: ReactNode; sorting?: SortingState; setSorting?: OnChangeFn; + manualSorting?: boolean; } const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}]; @@ -84,6 +85,7 @@ const Table = ({ emptyContent = emptyContentDefaultValue, sorting, setSorting, + manualSorting = false, }: TableProps) => { const isServerSideTable = totalItems !== undefined && @@ -111,11 +113,10 @@ const Table = ({ getSortedRowModel: getSortedRowModel(), getPaginationRowModel: getPaginationRowModel(), onPaginationChange: setPagination, - onSortingChange: setSorting, + manualSorting, state: { pagination, globalFilter: fuzzySearchValue, - sorting, }, filterFns: { fuzzy: fuzzyFilter, @@ -128,6 +129,14 @@ const Table = ({ tableOptions.getFilteredRowModel = getFilteredRowModel(); } + if (sorting && setSorting) { + tableOptions.onSortingChange = setSorting; + tableOptions.state = { + ...tableOptions.state, + sorting, + }; + } + const table = useReactTable(tableOptions); const { setPageSize } = table; From 8ed12578b437362119c19487e8cf17da82d8b5b4 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 14:32:42 +0700 Subject: [PATCH 084/174] chore(FE-40): fix name input placeholder --- src/components/pages/master-data/area/form/AreaForm.tsx | 2 +- src/components/pages/master-data/nonstock/form/NonstockForm.tsx | 2 +- src/components/pages/master-data/uom/form/UomForm.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/pages/master-data/area/form/AreaForm.tsx b/src/components/pages/master-data/area/form/AreaForm.tsx index d61b1b84..d0a80105 100644 --- a/src/components/pages/master-data/area/form/AreaForm.tsx +++ b/src/components/pages/master-data/area/form/AreaForm.tsx @@ -147,7 +147,7 @@ const AreaForm = ({ type = 'add', initialValues }: AreaFormProps) => { required label='Nama' name='name' - placeholder='Masukkan nama user' + placeholder='Masukkan nama area' value={formik.values.name} onChange={formik.handleChange} onBlur={formik.handleBlur} diff --git a/src/components/pages/master-data/nonstock/form/NonstockForm.tsx b/src/components/pages/master-data/nonstock/form/NonstockForm.tsx index bf2fce73..33dcba54 100644 --- a/src/components/pages/master-data/nonstock/form/NonstockForm.tsx +++ b/src/components/pages/master-data/nonstock/form/NonstockForm.tsx @@ -130,7 +130,7 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => { required label='Nama' name='name' - placeholder='Masukkan nama user' + placeholder='Masukkan nama nonstock' value={formik.values.name} onChange={formik.handleChange} onBlur={formik.handleBlur} diff --git a/src/components/pages/master-data/uom/form/UomForm.tsx b/src/components/pages/master-data/uom/form/UomForm.tsx index 8ece22a8..da99f813 100644 --- a/src/components/pages/master-data/uom/form/UomForm.tsx +++ b/src/components/pages/master-data/uom/form/UomForm.tsx @@ -147,7 +147,7 @@ const UomForm = ({ type = 'add', initialValues }: UomFormProps) => { required label='Nama' name='name' - placeholder='Masukkan nama user' + placeholder='Masukkan nama UOM' value={formik.values.name} onChange={formik.handleChange} onBlur={formik.handleBlur} From 5b1dab286036614ed257d1707c77dd4287a6d536 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 14:53:05 +0700 Subject: [PATCH 085/174] feat(FE-40): add onInputChange prop --- src/components/input/SelectInput.tsx | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/components/input/SelectInput.tsx b/src/components/input/SelectInput.tsx index 5b6ae098..930b5ed5 100644 --- a/src/components/input/SelectInput.tsx +++ b/src/components/input/SelectInput.tsx @@ -1,8 +1,9 @@ 'use client'; -import { ComponentType, ReactNode, useMemo } from 'react'; -import Select, { OptionProps, GroupBase } from 'react-select'; +import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react'; +import Select, { OptionProps, GroupBase, InputActionMeta } from 'react-select'; import makeAnimated from 'react-select/animated'; +import { useDebounce } from 'use-debounce'; import { cn } from '@/lib/helper'; @@ -41,6 +42,8 @@ interface SelectInputProps { errorMessage?: string; isAnimated?: boolean; openMenu?: boolean; + delay?: number; + onInputChange?: (search: string) => void; } const animatedComponents = makeAnimated(); @@ -65,7 +68,13 @@ const SelectInput = ({ errorMessage, isAnimated = true, openMenu, + delay = 300, + onInputChange, }: SelectInputProps) => { + const [internalInputValue, setInternalInputValue] = useState(''); + + const [debouncedInputValue] = useDebounce(internalInputValue, delay ?? 300); + const components = useMemo(() => { const base = isAnimated ? animatedComponents : {}; @@ -75,6 +84,14 @@ const SelectInput = ({ }; }, [isAnimated]); + const internalInputChangeHandler = (value: string, meta: InputActionMeta) => { + if (meta.action === 'input-change') setInternalInputValue(value); + if (meta.action === 'menu-close') setInternalInputValue(''); + }; + + useEffect(() => { + onInputChange?.(debouncedInputValue); + }, [debouncedInputValue]); return (
({ onChange={(val) => onChange?.(val as T)} options={options} menuIsOpen={openMenu} + inputValue={internalInputValue} + onInputChange={internalInputChangeHandler} isMulti={isMulti} isDisabled={isDisabled} isLoading={isLoading} From d3977a0951f3808e8fc3820f043ad9d18d85944e Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 14:57:17 +0700 Subject: [PATCH 086/174] feat(FE-41): create Location type --- src/types/api/master-data/location.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/types/api/master-data/location.ts diff --git a/src/types/api/master-data/location.ts b/src/types/api/master-data/location.ts new file mode 100644 index 00000000..91912ab7 --- /dev/null +++ b/src/types/api/master-data/location.ts @@ -0,0 +1,19 @@ +import { BaseMetadata } from '@/types/api/api-general'; +import { BaseArea } from '@/types/api/master-data/area'; + +export type BaseLocation = { + id: number; + name: string; + address: string; + area: BaseArea; +}; + +export type Location = BaseMetadata & BaseLocation; + +export type CreateLocationPayload = { + name: string; + address: string; + area_id: number; +}; + +export type UpdateLocationPayload = CreateLocationPayload; From e7e5456d15ed3a44fbb45dc38acfcf12fa874397 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 14:57:43 +0700 Subject: [PATCH 087/174] feat(FE-41): create LocationApi service --- src/services/api/master-data.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/services/api/master-data.ts b/src/services/api/master-data.ts index 941fa77b..e1748c59 100644 --- a/src/services/api/master-data.ts +++ b/src/services/api/master-data.ts @@ -11,6 +11,11 @@ import { CreateAreaPayload, UpdateAreaPayload, } from '@/types/api/master-data/area'; +import { + CreateLocationPayload, + Location, + UpdateLocationPayload, +} from '@/types/api/master-data/location'; export class MasterDataApi { basePath: string; @@ -102,3 +107,9 @@ export const AreaApi = new MasterDataApi< CreateAreaPayload, UpdateAreaPayload >('/master-data/areas'); + +export const LocationApi = new MasterDataApi< + Location, + CreateLocationPayload, + UpdateLocationPayload +>('/master-data/locations'); From 3c0babb62bbfe1241283beea21b84298b1ae9d01 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 14:57:59 +0700 Subject: [PATCH 088/174] feat(FE-43): create LocationsTable component --- .../master-data/location/LocationsTable.tsx | 290 ++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 src/components/pages/master-data/location/LocationsTable.tsx diff --git a/src/components/pages/master-data/location/LocationsTable.tsx b/src/components/pages/master-data/location/LocationsTable.tsx new file mode 100644 index 00000000..2879d114 --- /dev/null +++ b/src/components/pages/master-data/location/LocationsTable.tsx @@ -0,0 +1,290 @@ +'use client'; + +import { ChangeEventHandler, useEffect, useState } from 'react'; +import useSWR from 'swr'; +import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; +import toast from 'react-hot-toast'; + +import { Icon } from '@iconify/react'; +import Table from '@/components/Table'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import Button from '@/components/Button'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import RowCollapseOptions from '@/components/table/RowCollapseOptions'; + +import { Location } from '@/types/api/master-data/location'; +import { LocationApi } from '@/services/api/master-data'; +import { cn } from '@/lib/helper'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { ROWS_OPTIONS } from '@/config/constant'; + +const RowOptionsMenu = ({ + type = 'dropdown', + props, + deleteClickHandler, +}: { + type: 'dropdown' | 'collapse'; + props: CellContext; + deleteClickHandler: () => void; +}) => { + return ( +
+ + + + + +
+ ); +}; + +const LocationsTable = () => { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { search: '', nameSort: '' }, + paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' }, + }); + + const { + data: locations, + isLoading, + mutate: refreshLocations, + } = useSWR( + `${LocationApi.basePath}${getTableFilterQueryString()}`, + LocationApi.getAllFetcher + ); + + const deleteModal = useModal(); + + const [selectedLocation, setSelectedLocation] = useState< + Location | undefined + >(undefined); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const [sorting, setSorting] = useState([]); + + const locationsColumns: ColumnDef[] = [ + { + header: '#', + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, + }, + { + accessorKey: 'name', + header: 'Nama', + }, + { + accessorKey: 'address', + header: 'Alamat', + }, + { + accessorKey: 'area', + header: 'Area', + cell: (props) => props.row.original.area.name, + }, + { + header: 'Aksi', + cell: (props) => { + const currentPageSize = props.table.getPaginationRowModel().rows.length; + const currentPageRows = props.table.getPaginationRowModel().flatRows; + const currentRowRelativeIndex = + currentPageRows.findIndex((r) => r.id === props.row.id) + 1; + + const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; + + const deleteClickHandler = () => { + setSelectedLocation(props.row.original); + deleteModal.openModal(); + }; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]; + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await LocationApi.delete(selectedLocation?.id as number); + refreshLocations(); + + deleteModal.closeModal(); + toast.success('Successfully delete Location!'); + setIsDeleteLoading(false); + }; + + const searchChangeHandler: ChangeEventHandler = (e) => { + updateFilter('search', e.target.value); + }; + + const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + + setPageSize(newVal.value as number); + }; + + // track sorting + useEffect(() => { + const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name'); + + if (!isNameSorted) { + updateFilter('nameSort', ''); + } else { + updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc'); + } + }, [sorting]); + + return ( + <> +
+
+
+
+ +
+ + +
+ +
+ +
+
+ + + data={isResponseSuccess(locations) ? locations?.data : []} + columns={locationsColumns} + pageSize={tableFilterState.pageSize} + page={isResponseSuccess(locations) ? locations?.meta?.page : 0} + totalItems={ + isResponseSuccess(locations) ? locations?.meta?.total_results : 0 + } + onPageChange={setPage} + isLoading={isLoading} + sorting={sorting} + setSorting={setSorting} + className={{ + containerClassName: cn({ + 'mb-20': + isResponseSuccess(locations) && locations?.data?.length === 0, + }), + tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableClassName: 'font-inter w-full table-auto min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', + bodyRowClassName: 'border-b border-b-gray-200', + bodyColumnClassName: + 'px-6 py-3 last:flex last:flex-row last:justify-end', + }} + /> +
+ + + + ); +}; + +export default LocationsTable; From e53d4e22b2b073a1deea914d6e520e5f880af16d Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 14:58:25 +0700 Subject: [PATCH 089/174] feat(FE-40,41): create LocationForm component --- .../location/form/LocationForm.tsx | 305 ++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 src/components/pages/master-data/location/form/LocationForm.tsx diff --git a/src/components/pages/master-data/location/form/LocationForm.tsx b/src/components/pages/master-data/location/form/LocationForm.tsx new file mode 100644 index 00000000..50a5f664 --- /dev/null +++ b/src/components/pages/master-data/location/form/LocationForm.tsx @@ -0,0 +1,305 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useFormik } from 'formik'; +import { toast } from 'react-hot-toast'; +import useSWR from 'swr'; + +import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; +import TextInput from '@/components/input/TextInput'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; + +import { + LocationFormSchema, + LocationFormValues, + UpdateLocationFormSchema, +} from '@/components/pages/master-data/location/form/LocationForm.schema'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { + Location, + CreateLocationPayload, + UpdateLocationPayload, +} from '@/types/api/master-data/location'; +import { AreaApi, LocationApi } from '@/services/api/master-data'; +import { cn } from '@/lib/helper'; + +interface LocationFormProps { + type?: 'add' | 'edit' | 'detail'; + initialValues?: Location; +} + +const LocationForm = ({ type = 'add', initialValues }: LocationFormProps) => { + const router = useRouter(); + const deleteModal = useModal(); + + const [locationFormErrorMessage, setLocationFormErrorMessage] = useState(''); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const createLocationHandler = useCallback( + async (payload: CreateLocationPayload) => { + const createLocationRes = await LocationApi.create(payload); + + if (isResponseError(createLocationRes)) { + setLocationFormErrorMessage(createLocationRes.message); + return; + } + + toast.success(createLocationRes?.message as string); + router.push('/master-data/location'); + }, + [router] + ); + + const updateLocationHandler = useCallback( + async (locationId: number, payload: UpdateLocationPayload) => { + const updateLocationRes = await LocationApi.update(locationId, payload); + + if (updateLocationRes?.status === 'error') { + setLocationFormErrorMessage(updateLocationRes.message); + return; + } + + toast.success(updateLocationRes?.message as string); + router.refresh(); + router.push('/master-data/location'); + }, + [router] + ); + + const formikInitialValues = useMemo(() => { + return { + name: initialValues?.name ?? '', + address: initialValues?.address ?? '', + areaId: initialValues?.area?.id ?? 0, + area: initialValues?.area + ? { + value: initialValues.area.id, + label: initialValues.area.name, + } + : null, + }; + }, [initialValues]); + + const formik = useFormik({ + initialValues: formikInitialValues, + validationSchema: + type === 'edit' ? UpdateLocationFormSchema : LocationFormSchema, + onSubmit: async (values) => { + setLocationFormErrorMessage(''); + + const locationPayload: CreateLocationPayload = { + name: values.name, + address: values.address, + area_id: values.areaId, + }; + + switch (type) { + case 'add': + await createLocationHandler(locationPayload); + break; + + case 'edit': + await updateLocationHandler( + initialValues?.id as number, + locationPayload + ); + break; + } + }, + }); + + const { setValues: formikSetValues } = formik; + + const [areaSelectInputValue, setAreaSelectInputValue] = useState(''); + + const areasUrl = `${AreaApi.basePath}?${new URLSearchParams({ + search: areaSelectInputValue ?? '', + }).toString()}`; + + const { data: areas, isLoading: isLoadingAreas } = useSWR( + areasUrl, + AreaApi.getAllFetcher + ); + + const areaOptions = isResponseSuccess(areas) + ? areas?.data.map((area) => ({ + value: area.id, + label: area.name, + })) + : []; + + const areaChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldTouched('area', true); + formik.setFieldValue('area', val); + + formik.setFieldTouched('areaId', true); + formik.setFieldValue('areaId', (val as OptionType)?.value); + }; + + const deleteLocationClickHandler = () => { + deleteModal.openModal(); + }; + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await LocationApi.delete(initialValues?.id as number); + + deleteModal.closeModal(); + toast.success('Successfully delete Location!'); + setIsDeleteLoading(false); + router.push('/master-data/location'); + }; + + useEffect(() => { + formikSetValues(formikInitialValues); + }, [formikSetValues, formikInitialValues]); + + return ( + <> +
+
+ + +

+ {type === 'add' && 'Tambah Location'} + {type === 'edit' && 'Edit Location'} + {type === 'detail' && 'Detail Location'} +

+
+ +
+
+ + + + + +
+ +
+ {type !== 'add' && ( +
+ +
+ )} + + {type !== 'detail' && ( +
+ + + +
+ )} +
+ + {locationFormErrorMessage && ( +
+ + {locationFormErrorMessage} +
+ )} +
+
+ + {type !== 'add' && ( + + )} + + ); +}; + +export default LocationForm; From 57e5fafabd26d090acb9531eeb4fd78c406114fc Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 14:58:45 +0700 Subject: [PATCH 090/174] feat(FE-42): create Location form validation schema --- .../location/form/LocationForm.schema.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/components/pages/master-data/location/form/LocationForm.schema.ts diff --git a/src/components/pages/master-data/location/form/LocationForm.schema.ts b/src/components/pages/master-data/location/form/LocationForm.schema.ts new file mode 100644 index 00000000..4aa8e847 --- /dev/null +++ b/src/components/pages/master-data/location/form/LocationForm.schema.ts @@ -0,0 +1,18 @@ +import * as Yup from 'yup'; + +export const LocationFormSchema = Yup.object({ + name: Yup.string().required('Nama wajib diisi!'), + address: Yup.string().required('Alamat wajib diisi!'), + + areaId: Yup.number() + .min(1, 'Area wajib diisi!') + .required('Area wajib diisi!'), + area: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), +}); + +export const UpdateLocationFormSchema = LocationFormSchema; + +export type LocationFormValues = Yup.InferType; From 7e0dd1bdb18a47dec28d4f736b02b9cb29b47c51 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 14:59:03 +0700 Subject: [PATCH 091/174] feat(FE-43): create Master Data Location page --- src/app/master-data/location/page.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/app/master-data/location/page.tsx diff --git a/src/app/master-data/location/page.tsx b/src/app/master-data/location/page.tsx new file mode 100644 index 00000000..338fdbff --- /dev/null +++ b/src/app/master-data/location/page.tsx @@ -0,0 +1,11 @@ +import LocationsTable from '@/components/pages/master-data/location/LocationsTable'; + +const Nonstock = () => { + return ( +
+ +
+ ); +}; + +export default Nonstock; From 288e4b92ff7dc89b9f19e27abfa8e972cbcdac04 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 14:59:16 +0700 Subject: [PATCH 092/174] feat(FE-40,41): create Master Data Add Location page --- src/app/master-data/location/add/page.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/app/master-data/location/add/page.tsx diff --git a/src/app/master-data/location/add/page.tsx b/src/app/master-data/location/add/page.tsx new file mode 100644 index 00000000..56f668fd --- /dev/null +++ b/src/app/master-data/location/add/page.tsx @@ -0,0 +1,11 @@ +import LocationForm from '@/components/pages/master-data/location/form/LocationForm'; + +const AddNonstock = () => { + return ( +
+ +
+ ); +}; + +export default AddNonstock; From 54d2c85677aeba5958de4fa62c10ba2ad1f6a56a Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 14:59:30 +0700 Subject: [PATCH 093/174] feat(FE-40,41): create Master Data Detail Location page --- src/app/master-data/location/detail/page.tsx | 49 ++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/app/master-data/location/detail/page.tsx diff --git a/src/app/master-data/location/detail/page.tsx b/src/app/master-data/location/detail/page.tsx new file mode 100644 index 00000000..bff4d12e --- /dev/null +++ b/src/app/master-data/location/detail/page.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import LocationForm from '@/components/pages/master-data/location/form/LocationForm'; + +import { LocationApi } from '@/services/api/master-data'; +import { isResponseSuccess } from '@/lib/api-helper'; + +const LocationDetail = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const locationId = searchParams.get('locationId'); + + const { data: location, isLoading: isLoadingLocation } = useSWR( + locationId, + (id: number) => LocationApi.getSingle(id) + ); + + if (!locationId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingLocation && !location) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingLocation && ( + + )} + {!isLoadingLocation && isResponseSuccess(location) && ( + + )} +
+ ); +}; + +export default LocationDetail; From 05a67bdc75b916096c04d47e1cfcd0fc6481c18d Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 4 Oct 2025 14:59:44 +0700 Subject: [PATCH 094/174] feat(FE-40,41): create Master Data Edit Location page --- .../master-data/location/detail/edit/page.tsx | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/app/master-data/location/detail/edit/page.tsx diff --git a/src/app/master-data/location/detail/edit/page.tsx b/src/app/master-data/location/detail/edit/page.tsx new file mode 100644 index 00000000..853254d9 --- /dev/null +++ b/src/app/master-data/location/detail/edit/page.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import LocationForm from '@/components/pages/master-data/location/form/LocationForm'; + +import { LocationApi } from '@/services/api/master-data'; +import { isResponseSuccess } from '@/lib/api-helper'; + +const LocationEdit = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const locationId = searchParams.get('locationId'); + + const { data: location, isLoading: isLoadingLocation } = useSWR( + locationId, + (id: number) => LocationApi.getSingle(id) + ); + + if (!locationId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingLocation && !location) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingLocation && ( + + )} + {!isLoadingLocation && isResponseSuccess(location) && ( + + )} +
+ ); +}; + +export default LocationEdit; From 508a530c3a44ff5403b883a9602dcc109b14b0d5 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 12:32:51 +0700 Subject: [PATCH 095/174] chore(FE-43): add address and area sorting --- .../master-data/location/LocationsTable.tsx | 52 +++++++++++++++---- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/src/components/pages/master-data/location/LocationsTable.tsx b/src/components/pages/master-data/location/LocationsTable.tsx index 2879d114..8dc6b0d6 100644 --- a/src/components/pages/master-data/location/LocationsTable.tsx +++ b/src/components/pages/master-data/location/LocationsTable.tsx @@ -1,8 +1,13 @@ 'use client'; -import { ChangeEventHandler, useEffect, useState } from 'react'; +import { ChangeEventHandler, useCallback, useEffect, useState } from 'react'; import useSWR from 'swr'; -import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; +import { + CellContext, + ColumnDef, + ColumnSort, + SortingState, +} from '@tanstack/react-table'; import toast from 'react-hot-toast'; import { Icon } from '@iconify/react'; @@ -88,8 +93,14 @@ const LocationsTable = () => { setPageSize, toQueryString: getTableFilterQueryString, } = useTableFilter({ - initial: { search: '', nameSort: '' }, - paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' }, + initial: { search: '', nameSort: '', addressSort: '', areaSort: '' }, + paramMap: { + page: 'page', + pageSize: 'limit', + nameSort: 'sort_name', + addressSort: 'sort_address', + areaSort: ' sort_area', + }, }); const { @@ -194,15 +205,36 @@ const LocationsTable = () => { setPageSize(newVal.value as number); }; + const updateSortingFilter = useCallback( + ( + sortName: Exclude, + sortFilter: ColumnSort | undefined + ) => { + if (!sortFilter) { + updateFilter(sortName, ''); + } else { + updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc'); + } + }, + [updateFilter] + ); + + console.log({ + sorting, + getTableFilterQueryString: getTableFilterQueryString(), + }); + // track sorting useEffect(() => { - const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name'); + const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name'); + const addressSortFilter = sorting.find( + (sortItem) => sortItem.id === 'address' + ); + const areaSortFilter = sorting.find((sortItem) => sortItem.id === 'area'); - if (!isNameSorted) { - updateFilter('nameSort', ''); - } else { - updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc'); - } + updateSortingFilter('nameSort', nameSortFilter); + updateSortingFilter('addressSort', addressSortFilter); + updateSortingFilter('areaSort', areaSortFilter); }, [sorting]); return ( From f7b0933c0f3bfb435b1ace816adff92a15fc86f8 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 12:59:15 +0700 Subject: [PATCH 096/174] feat(FE-41): create BaseApiService class --- src/services/api/base.ts | 82 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/services/api/base.ts diff --git a/src/services/api/base.ts b/src/services/api/base.ts new file mode 100644 index 00000000..d1ac4729 --- /dev/null +++ b/src/services/api/base.ts @@ -0,0 +1,82 @@ +import axios from 'axios'; +import { httpClient, httpClientFetcher } from '@/services/http/client'; +import { BaseApiResponse } from '@/types/api/api-general'; + +export class BaseApiService { + basePath: string; + + constructor(basePath: string) { + this.basePath = basePath; + } + + async getAllFetcher(endpoint: string): Promise> { + return await httpClientFetcher>(endpoint); + } + + async getSingle(id: number) { + try { + const getSinglePath = `${this.basePath}/${id}`; + const getSingleRes = await httpClient>(getSinglePath); + + return getSingleRes; + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + + return undefined; + } + } + + async create(payload: CreatePayloadGeneric) { + try { + const createRes = await httpClient>(this.basePath, { + method: 'POST', + body: payload, + }); + + return createRes; + } catch (error: unknown) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + + return undefined; + } + } + + async update(id: number, payload: UpdatePayloadGeneric) { + try { + const updatePath = `${this.basePath}/${id}`; + const updateRes = await httpClient>(updatePath, { + method: 'PATCH', + body: payload, + }); + + return updateRes; + } catch (error: unknown) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + + return undefined; + } + } + + async delete(id: number) { + try { + const deletePath = `${this.basePath}/${id}`; + const deleteRes = await httpClient(deletePath, { + method: 'DELETE', + }); + + return deleteRes; + } catch (error) { + if (axios.isAxiosError(error)) { + return error.response?.data; + } + + return undefined; + } + } +} From f32e1ceec4dce9f815541d042ef769f7f146931b Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 12:59:35 +0700 Subject: [PATCH 097/174] chore(FE-41): use BaseApiService class --- src/services/api/master-data.ts | 89 ++------------------------------- 1 file changed, 4 insertions(+), 85 deletions(-) diff --git a/src/services/api/master-data.ts b/src/services/api/master-data.ts index e1748c59..25bceae0 100644 --- a/src/services/api/master-data.ts +++ b/src/services/api/master-data.ts @@ -1,6 +1,4 @@ -import axios from 'axios'; -import { httpClient, httpClientFetcher } from '@/services/http/client'; -import { BaseApiResponse } from '@/types/api/api-general'; +import { BaseApiService } from '@/services/api/base'; import { CreateUomPayload, Uom, @@ -17,98 +15,19 @@ import { UpdateLocationPayload, } from '@/types/api/master-data/location'; -export class MasterDataApi { - basePath: string; - - constructor(basePath: string) { - this.basePath = basePath; - } - - async getAllFetcher(endpoint: string): Promise> { - return await httpClientFetcher>(endpoint); - } - - async getSingle(id: number) { - try { - const getSinglePath = `${this.basePath}/${id}`; - const getSingleRes = await httpClient>(getSinglePath); - - return getSingleRes; - } catch (error) { - if (axios.isAxiosError>(error)) { - return error.response?.data; - } - - return undefined; - } - } - - async create(payload: CreatePayloadGeneric) { - try { - const createRes = await httpClient>(this.basePath, { - method: 'POST', - body: payload, - }); - - return createRes; - } catch (error: unknown) { - if (axios.isAxiosError>(error)) { - return error.response?.data; - } - - return undefined; - } - } - - async update(id: number, payload: UpdatePayloadGeneric) { - try { - const updatePath = `${this.basePath}/${id}`; - const updateRes = await httpClient>(updatePath, { - method: 'PATCH', - body: payload, - }); - - return updateRes; - } catch (error: unknown) { - if (axios.isAxiosError>(error)) { - return error.response?.data; - } - - return undefined; - } - } - - async delete(id: number) { - try { - const deletePath = `${this.basePath}/${id}`; - const deleteRes = await httpClient(deletePath, { - method: 'DELETE', - }); - - return deleteRes; - } catch (error) { - if (axios.isAxiosError(error)) { - return error.response?.data; - } - - return undefined; - } - } -} - -export const UomApi = new MasterDataApi< +export const UomApi = new BaseApiService< Uom, CreateUomPayload, UpdateUomPayload >('/master-data/uoms'); -export const AreaApi = new MasterDataApi< +export const AreaApi = new BaseApiService< Area, CreateAreaPayload, UpdateAreaPayload >('/master-data/areas'); -export const LocationApi = new MasterDataApi< +export const LocationApi = new BaseApiService< Location, CreateLocationPayload, UpdateLocationPayload From 8c84e08f3b242d7e79d4f6b47c4dd5f56c683c34 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 13:00:58 +0700 Subject: [PATCH 098/174] chore(FE-41): use .d.ts extension for types --- src/types/api/master-data/{area.ts => area.d.ts} | 0 src/types/api/master-data/{location.ts => location.d.ts} | 0 src/types/api/master-data/{uom.ts => uom.d.ts} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename src/types/api/master-data/{area.ts => area.d.ts} (100%) rename src/types/api/master-data/{location.ts => location.d.ts} (100%) rename src/types/api/master-data/{uom.ts => uom.d.ts} (100%) diff --git a/src/types/api/master-data/area.ts b/src/types/api/master-data/area.d.ts similarity index 100% rename from src/types/api/master-data/area.ts rename to src/types/api/master-data/area.d.ts diff --git a/src/types/api/master-data/location.ts b/src/types/api/master-data/location.d.ts similarity index 100% rename from src/types/api/master-data/location.ts rename to src/types/api/master-data/location.d.ts diff --git a/src/types/api/master-data/uom.ts b/src/types/api/master-data/uom.d.ts similarity index 100% rename from src/types/api/master-data/uom.ts rename to src/types/api/master-data/uom.d.ts From d5294e9b0b4bef04b30268d994282f07434860cc Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 13:01:30 +0700 Subject: [PATCH 099/174] chore(FE-43): remove unnecessary code --- src/components/pages/master-data/location/LocationsTable.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/components/pages/master-data/location/LocationsTable.tsx b/src/components/pages/master-data/location/LocationsTable.tsx index 8dc6b0d6..ebbb798f 100644 --- a/src/components/pages/master-data/location/LocationsTable.tsx +++ b/src/components/pages/master-data/location/LocationsTable.tsx @@ -219,11 +219,6 @@ const LocationsTable = () => { [updateFilter] ); - console.log({ - sorting, - getTableFilterQueryString: getTableFilterQueryString(), - }); - // track sorting useEffect(() => { const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name'); From 0675d95a2abe533907efb5b5453e07612acbaaf6 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 13:39:16 +0700 Subject: [PATCH 100/174] feat(FE-41): create user type --- src/types/api/user.d.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/types/api/user.d.ts diff --git a/src/types/api/user.d.ts b/src/types/api/user.d.ts new file mode 100644 index 00000000..2190e1a8 --- /dev/null +++ b/src/types/api/user.d.ts @@ -0,0 +1,18 @@ +import { BaseMetadata } from '@/types/api/api-general'; + +export type BaseUser = { + id: number; + id_user: number; + email: string; + name: string; +}; + +export type User = BaseMetadata & BaseUser; + +export type CreateUserPayload = { + id_user: number; + name: string; + email: string; +}; + +export type UpdateUserPayload = CreateUserPayload; From 0e5b718fd7f66537c61b903f83b68ac3028b93ad Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 13:39:25 +0700 Subject: [PATCH 101/174] feat(FE-41): create kandang type --- src/types/api/master-data/kandang.d.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/types/api/master-data/kandang.d.ts diff --git a/src/types/api/master-data/kandang.d.ts b/src/types/api/master-data/kandang.d.ts new file mode 100644 index 00000000..e05006d1 --- /dev/null +++ b/src/types/api/master-data/kandang.d.ts @@ -0,0 +1,20 @@ +import { BaseMetadata } from '@/types/api/api-general'; +import { BaseLocation } from '@/types/api/master-data/location'; +import { BaseUser } from '@/types/api/user'; + +export type BaseKandang = { + id: number; + name: string; + location: BaseLocation; + pic: BaseUser; +}; + +export type Kandang = BaseMetadata & BaseKandang; + +export type CreateKandangPayload = { + name: string; + location_id: number; + pic_id: number; +}; + +export type UpdateKandangPayload = CreateKandangPayload; From 26093034fa44ff96b8b6e1ea34549db616f98f1a Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 13:39:38 +0700 Subject: [PATCH 102/174] feat(FE-41): create user API service --- src/services/api/user.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/services/api/user.ts diff --git a/src/services/api/user.ts b/src/services/api/user.ts new file mode 100644 index 00000000..779cca07 --- /dev/null +++ b/src/services/api/user.ts @@ -0,0 +1,8 @@ +import { BaseApiService } from '@/services/api/base'; +import { CreateUserPayload, UpdateUserPayload, User } from '@/types/api/user'; + +export const UserApi = new BaseApiService< + User, + CreateUserPayload, + UpdateUserPayload +>('/users'); From 6ff19f05fd44ec4fda9972d684545d8ebc9bc91d Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 13:40:09 +0700 Subject: [PATCH 103/174] feat(FE-41): craete KandangApi service --- src/services/api/master-data.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/services/api/master-data.ts b/src/services/api/master-data.ts index 25bceae0..01d1cbc6 100644 --- a/src/services/api/master-data.ts +++ b/src/services/api/master-data.ts @@ -14,6 +14,11 @@ import { Location, UpdateLocationPayload, } from '@/types/api/master-data/location'; +import { + CreateKandangPayload, + Kandang, + UpdateKandangPayload, +} from '@/types/api/master-data/kandang'; export const UomApi = new BaseApiService< Uom, @@ -32,3 +37,9 @@ export const LocationApi = new BaseApiService< CreateLocationPayload, UpdateLocationPayload >('/master-data/locations'); + +export const KandangApi = new BaseApiService< + Kandang, + CreateKandangPayload, + UpdateKandangPayload +>('/master-data/kandangs'); From be844312d3262d174be59936fa431fac0113dd0d Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 13:40:46 +0700 Subject: [PATCH 104/174] feat(FE-43): create KandangsTable component --- .../master-data/kandang/KandangsTable.tsx | 318 ++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 src/components/pages/master-data/kandang/KandangsTable.tsx diff --git a/src/components/pages/master-data/kandang/KandangsTable.tsx b/src/components/pages/master-data/kandang/KandangsTable.tsx new file mode 100644 index 00000000..c3571452 --- /dev/null +++ b/src/components/pages/master-data/kandang/KandangsTable.tsx @@ -0,0 +1,318 @@ +'use client'; + +import { ChangeEventHandler, useCallback, useEffect, useState } from 'react'; +import useSWR from 'swr'; +import { + CellContext, + ColumnDef, + ColumnSort, + SortingState, +} from '@tanstack/react-table'; +import toast from 'react-hot-toast'; + +import { Icon } from '@iconify/react'; +import Table from '@/components/Table'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import Button from '@/components/Button'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import RowCollapseOptions from '@/components/table/RowCollapseOptions'; + +import { Kandang } from '@/types/api/master-data/kandang'; +import { KandangApi } from '@/services/api/master-data'; +import { cn } from '@/lib/helper'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { ROWS_OPTIONS } from '@/config/constant'; + +const RowOptionsMenu = ({ + type = 'dropdown', + props, + deleteClickHandler, +}: { + type: 'dropdown' | 'collapse'; + props: CellContext; + deleteClickHandler: () => void; +}) => { + return ( +
+ + + + + +
+ ); +}; + +const KandangsTable = () => { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { search: '', nameSort: '', locationSort: '', picSort: '' }, + paramMap: { + page: 'page', + pageSize: 'limit', + nameSort: 'sort_name', + locationSort: 'sort_location', + picSort: ' sort_pic', + }, + }); + + const { + data: kandangs, + isLoading, + mutate: refreshKandangs, + } = useSWR( + `${KandangApi.basePath}${getTableFilterQueryString()}`, + KandangApi.getAllFetcher + ); + + const deleteModal = useModal(); + + const [selectedKandang, setSelectedKandang] = useState( + undefined + ); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const [sorting, setSorting] = useState([]); + + const kandangsColumns: ColumnDef[] = [ + { + header: '#', + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, + }, + { + accessorKey: 'name', + header: 'Nama', + }, + { + accessorKey: 'location', + header: 'Lokasi', + cell: (props) => props.row.original.location.name, + }, + { + accessorKey: 'pic', + header: 'PIC', + cell: (props) => props.row.original.pic.name, + }, + { + header: 'Aksi', + cell: (props) => { + const currentPageSize = props.table.getPaginationRowModel().rows.length; + const currentPageRows = props.table.getPaginationRowModel().flatRows; + const currentRowRelativeIndex = + currentPageRows.findIndex((r) => r.id === props.row.id) + 1; + + const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; + + const deleteClickHandler = () => { + setSelectedKandang(props.row.original); + deleteModal.openModal(); + }; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]; + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await KandangApi.delete(selectedKandang?.id as number); + refreshKandangs(); + + deleteModal.closeModal(); + toast.success('Successfully delete Kandang!'); + setIsDeleteLoading(false); + }; + + const searchChangeHandler: ChangeEventHandler = (e) => { + updateFilter('search', e.target.value); + }; + + const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + + setPageSize(newVal.value as number); + }; + + const updateSortingFilter = useCallback( + ( + sortName: Exclude, + sortFilter: ColumnSort | undefined + ) => { + if (!sortFilter) { + updateFilter(sortName, ''); + } else { + updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc'); + } + }, + [updateFilter] + ); + + // track sorting + useEffect(() => { + const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name'); + const locationSortFilter = sorting.find( + (sortItem) => sortItem.id === 'location' + ); + const picSortFilter = sorting.find((sortItem) => sortItem.id === 'pic'); + + updateSortingFilter('nameSort', nameSortFilter); + updateSortingFilter('locationSort', locationSortFilter); + updateSortingFilter('picSort', picSortFilter); + }, [sorting]); + + return ( + <> +
+
+
+
+ +
+ + +
+ +
+ +
+
+ + + data={isResponseSuccess(kandangs) ? kandangs?.data : []} + columns={kandangsColumns} + pageSize={tableFilterState.pageSize} + page={isResponseSuccess(kandangs) ? kandangs?.meta?.page : 0} + totalItems={ + isResponseSuccess(kandangs) ? kandangs?.meta?.total_results : 0 + } + onPageChange={setPage} + isLoading={isLoading} + sorting={sorting} + setSorting={setSorting} + className={{ + containerClassName: cn({ + 'mb-20': + isResponseSuccess(kandangs) && kandangs?.data?.length === 0, + }), + tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableClassName: 'font-inter w-full table-auto min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', + bodyRowClassName: 'border-b border-b-gray-200', + bodyColumnClassName: + 'px-6 py-3 last:flex last:flex-row last:justify-end', + }} + /> +
+ + + + ); +}; + +export default KandangsTable; From 531a257e788a87bc82485163cbd33ff0609c1ed7 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 13:41:08 +0700 Subject: [PATCH 105/174] feat(FE-40,41): create KandangForm component --- .../master-data/kandang/form/KandangForm.tsx | 360 ++++++++++++++++++ 1 file changed, 360 insertions(+) create mode 100644 src/components/pages/master-data/kandang/form/KandangForm.tsx diff --git a/src/components/pages/master-data/kandang/form/KandangForm.tsx b/src/components/pages/master-data/kandang/form/KandangForm.tsx new file mode 100644 index 00000000..f0d68983 --- /dev/null +++ b/src/components/pages/master-data/kandang/form/KandangForm.tsx @@ -0,0 +1,360 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useFormik } from 'formik'; +import { toast } from 'react-hot-toast'; +import useSWR from 'swr'; + +import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; +import TextInput from '@/components/input/TextInput'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; + +import { + KandangFormSchema, + KandangFormValues, + UpdateKandangFormSchema, +} from '@/components/pages/master-data/kandang/form/KandangForm.schema'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { + Kandang, + CreateKandangPayload, + UpdateKandangPayload, +} from '@/types/api/master-data/kandang'; +import { LocationApi, KandangApi } from '@/services/api/master-data'; +import { cn } from '@/lib/helper'; +import { UserApi } from '@/services/api/user'; + +interface KandangFormProps { + type?: 'add' | 'edit' | 'detail'; + initialValues?: Kandang; +} + +const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => { + const router = useRouter(); + const deleteModal = useModal(); + + const [kandangFormErrorMessage, setKandangFormErrorMessage] = useState(''); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const createKandangHandler = useCallback( + async (payload: CreateKandangPayload) => { + const createKandangRes = await KandangApi.create(payload); + + if (isResponseError(createKandangRes)) { + setKandangFormErrorMessage(createKandangRes.message); + return; + } + + toast.success(createKandangRes?.message as string); + router.push('/master-data/kandang'); + }, + [router] + ); + + const updateKandangHandler = useCallback( + async (kandangId: number, payload: UpdateKandangPayload) => { + const updateKandangRes = await KandangApi.update(kandangId, payload); + + if (updateKandangRes?.status === 'error') { + setKandangFormErrorMessage(updateKandangRes.message); + return; + } + + toast.success(updateKandangRes?.message as string); + router.refresh(); + router.push('/master-data/kandang'); + }, + [router] + ); + + const formikInitialValues = useMemo(() => { + return { + name: initialValues?.name ?? '', + locationId: initialValues?.location?.id ?? 0, + location: initialValues?.location + ? { + value: initialValues.location.id, + label: initialValues.location.name, + } + : null, + picId: initialValues?.pic?.id ?? 0, + pic: initialValues?.pic + ? { + value: initialValues.pic.id, + label: initialValues.pic.name, + } + : null, + }; + }, [initialValues]); + + const formik = useFormik({ + initialValues: formikInitialValues, + validationSchema: + type === 'edit' ? UpdateKandangFormSchema : KandangFormSchema, + onSubmit: async (values) => { + setKandangFormErrorMessage(''); + + const kandangPayload: CreateKandangPayload = { + name: values.name, + location_id: values.locationId, + pic_id: values.picId, + }; + + switch (type) { + case 'add': + await createKandangHandler(kandangPayload); + break; + + case 'edit': + await updateKandangHandler( + initialValues?.id as number, + kandangPayload + ); + break; + } + }, + }); + + const { setValues: formikSetValues } = formik; + + // location + const [locationSelectInputValue, setLocationSelectInputValue] = useState(''); + + const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({ + search: locationSelectInputValue ?? '', + }).toString()}`; + + const { data: locations, isLoading: isLoadingLocations } = useSWR( + locationsUrl, + LocationApi.getAllFetcher + ); + + const locationOptions = isResponseSuccess(locations) + ? locations?.data.map((location) => ({ + value: location.id, + label: location.name, + })) + : []; + + const locationChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldTouched('location', true); + formik.setFieldValue('location', val); + + formik.setFieldTouched('locationId', true); + formik.setFieldValue('locationId', (val as OptionType)?.value); + }; + + // PIC + const [picSelectInputValue, setPicSelectInputValue] = useState(''); + + const picsUrl = `${UserApi.basePath}?${new URLSearchParams({ + search: picSelectInputValue ?? '', + }).toString()}`; + + const { data: pics, isLoading: isLoadingPics } = useSWR( + picsUrl, + LocationApi.getAllFetcher + ); + + const picOptions = isResponseSuccess(pics) + ? pics?.data.map((pic) => ({ + value: pic.id, + label: pic.name, + })) + : []; + + const picChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldTouched('pic', true); + formik.setFieldValue('pic', val); + + formik.setFieldTouched('picId', true); + formik.setFieldValue('picId', (val as OptionType)?.value); + }; + + const deleteKandangClickHandler = () => { + deleteModal.openModal(); + }; + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await KandangApi.delete(initialValues?.id as number); + + deleteModal.closeModal(); + toast.success('Successfully delete Kandang!'); + setIsDeleteLoading(false); + router.push('/master-data/kandang'); + }; + + useEffect(() => { + formikSetValues(formikInitialValues); + }, [formikSetValues, formikInitialValues]); + + return ( + <> +
+
+ + +

+ {type === 'add' && 'Tambah Kandang'} + {type === 'edit' && 'Edit Kandang'} + {type === 'detail' && 'Detail Kandang'} +

+
+ +
+
+ + + + + +
+ +
+ {type !== 'add' && ( +
+ + + {type !== 'edit' && ( + + )} +
+ )} + + {type !== 'detail' && ( +
+ + + +
+ )} +
+ + {kandangFormErrorMessage && ( +
+ + {kandangFormErrorMessage} +
+ )} +
+
+ + {type !== 'add' && ( + + )} + + ); +}; + +export default KandangForm; From 6441a38a9df6b3d3c9af885a841ab62353834ba7 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 13:41:27 +0700 Subject: [PATCH 106/174] feat(FE-42): create Kandang form validation schema --- .../kandang/form/KandangForm.schema.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/components/pages/master-data/kandang/form/KandangForm.schema.ts diff --git a/src/components/pages/master-data/kandang/form/KandangForm.schema.ts b/src/components/pages/master-data/kandang/form/KandangForm.schema.ts new file mode 100644 index 00000000..9a0e42a0 --- /dev/null +++ b/src/components/pages/master-data/kandang/form/KandangForm.schema.ts @@ -0,0 +1,23 @@ +import * as Yup from 'yup'; + +export const KandangFormSchema = Yup.object({ + name: Yup.string().required('Nama wajib diisi!'), + + locationId: Yup.number() + .min(1, 'Lokasi wajib diisi!') + .required('Lokasi wajib diisi!'), + location: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + + picId: Yup.number().min(1, 'PIC wajib diisi!').required('PIC wajib diisi!'), + pic: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), +}); + +export const UpdateKandangFormSchema = KandangFormSchema; + +export type KandangFormValues = Yup.InferType; From 952110d7af437f3df90c7bb859d63daeb1222bf0 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 13:41:45 +0700 Subject: [PATCH 107/174] feat(FE-43): create Master Data Kandang page --- src/app/master-data/kandang/page.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/app/master-data/kandang/page.tsx diff --git a/src/app/master-data/kandang/page.tsx b/src/app/master-data/kandang/page.tsx new file mode 100644 index 00000000..293eb0da --- /dev/null +++ b/src/app/master-data/kandang/page.tsx @@ -0,0 +1,11 @@ +import KandangsTable from '@/components/pages/master-data/kandang/KandangsTable'; + +const Nonstock = () => { + return ( +
+ +
+ ); +}; + +export default Nonstock; From f0c291046934174a86f543947d635496ce47e51f Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 13:42:05 +0700 Subject: [PATCH 108/174] feat(FE-40,41): create Master Data Add Kandang page --- src/app/master-data/kandang/add/page.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/app/master-data/kandang/add/page.tsx diff --git a/src/app/master-data/kandang/add/page.tsx b/src/app/master-data/kandang/add/page.tsx new file mode 100644 index 00000000..238799cd --- /dev/null +++ b/src/app/master-data/kandang/add/page.tsx @@ -0,0 +1,11 @@ +import KandangForm from '@/components/pages/master-data/kandang/form/KandangForm'; + +const AddNonstock = () => { + return ( +
+ +
+ ); +}; + +export default AddNonstock; From 64bb87f92faeabd8f70d098b2ef43a9657ba2e55 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 13:42:14 +0700 Subject: [PATCH 109/174] feat(FE-40,41): create Master Data Detail Kandang page --- src/app/master-data/kandang/detail/page.tsx | 49 +++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/app/master-data/kandang/detail/page.tsx diff --git a/src/app/master-data/kandang/detail/page.tsx b/src/app/master-data/kandang/detail/page.tsx new file mode 100644 index 00000000..1b91bad7 --- /dev/null +++ b/src/app/master-data/kandang/detail/page.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import KandangForm from '@/components/pages/master-data/kandang/form/KandangForm'; + +import { KandangApi } from '@/services/api/master-data'; +import { isResponseSuccess } from '@/lib/api-helper'; + +const KandangDetail = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const kandangId = searchParams.get('kandangId'); + + const { data: kandang, isLoading: isLoadingKandang } = useSWR( + kandangId, + (id: number) => KandangApi.getSingle(id) + ); + + if (!kandangId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingKandang && !kandang) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingKandang && ( + + )} + {!isLoadingKandang && isResponseSuccess(kandang) && ( + + )} +
+ ); +}; + +export default KandangDetail; From bfc81da349ce80780fc0b8fe33855978c7f4729a Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 13:42:22 +0700 Subject: [PATCH 110/174] feat(FE-40,41): create Master Data Edit Kandang page --- .../master-data/kandang/detail/edit/page.tsx | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/app/master-data/kandang/detail/edit/page.tsx diff --git a/src/app/master-data/kandang/detail/edit/page.tsx b/src/app/master-data/kandang/detail/edit/page.tsx new file mode 100644 index 00000000..426aabc0 --- /dev/null +++ b/src/app/master-data/kandang/detail/edit/page.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import KandangForm from '@/components/pages/master-data/kandang/form/KandangForm'; + +import { KandangApi } from '@/services/api/master-data'; +import { isResponseSuccess } from '@/lib/api-helper'; + +const KandangEdit = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const kandangId = searchParams.get('kandangId'); + + const { data: kandang, isLoading: isLoadingKandang } = useSWR( + kandangId, + (id: number) => KandangApi.getSingle(id) + ); + + if (!kandangId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingKandang && !kandang) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingKandang && ( + + )} + {!isLoadingKandang && isResponseSuccess(kandang) && ( + + )} +
+ ); +}; + +export default KandangEdit; From acd28e5debcb22c21f0c0ec300455714d7f65a68 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 16:04:46 +0700 Subject: [PATCH 111/174] feat(FE-41): create warehouse api type --- src/types/api/master-data/warehouse.d.ts | 81 ++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/types/api/master-data/warehouse.d.ts diff --git a/src/types/api/master-data/warehouse.d.ts b/src/types/api/master-data/warehouse.d.ts new file mode 100644 index 00000000..41c5b1cd --- /dev/null +++ b/src/types/api/master-data/warehouse.d.ts @@ -0,0 +1,81 @@ +import { BaseMetadata, Override } from '@/types/api/api-general'; +import { BaseArea } from '@/types/api/master-data/area'; +import { BaseLocation } from '@/types/api/master-data/location'; +import { BaseKandang } from '@/types/api/master-data/kandang'; +import { BaseUser } from '@/types/api/master-data/user'; + +export type WarehouseType = 'AREA' | 'LOKASI' | 'KANDANG'; + +export type BaseWarehouse = { + id: number; + name: string; +}; + +export type BaseWarehouseArea = BaseWarehouse & { + type: 'AREA'; + area: BaseArea; +}; + +export type BaseWarehouseLocation = BaseWarehouse & { + type: 'LOKASI'; + area: BaseArea; + location: Override< + BaseLocation, + { + area: BaseArea | null; + } + >; +}; + +export type BaseWarehouseKandang = BaseWarehouse & { + type: 'KANDANG'; + area: BaseArea; + location: Override< + BaseLocation, + { + area: BaseArea | null; + } + >; + kandang: Override< + BaseKandang, + { + location: BaseLocation | null; + pic: BaseUser | null; + } + >; +}; + +export type Warehouse = BaseMetadata & + (BaseWarehouseArea | BaseWarehouseLocation | BaseWarehouseKandang); + +export type BaseCreateWarehousePayload = { + name: string; +}; + +export type BaseCreateWarehouseAreaPayload = BaseCreateWarehousePayload & { + type: 'AREA'; + area_id: number; +}; + +export type BaseCreateWarehouseLocationPayload = Override< + BaseCreateWarehouseAreaPayload, + { + type: 'LOKASI'; + location_id: number; + } +>; + +export type BaseCreateWarehouseKandangPayload = Override< + BaseCreateWarehouseLocationPayload, + { + type: 'KANDANG'; + kandang_id: number; + } +>; + +export type CreateWarehousePayload = + | BaseCreateWarehouseAreaPayload + | BaseCreateWarehouseLocationPayload + | BaseCreateWarehouseKandangPayload; + +export type UpdateWarehousePayload = CreateWarehousePayload; From a136ee11909362510499c800bd87d5049d982735 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 16:04:59 +0700 Subject: [PATCH 112/174] chore(FE-41): create Override type --- src/types/api/api-general.d.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/types/api/api-general.d.ts b/src/types/api/api-general.d.ts index f33b94ee..8a4c4de7 100644 --- a/src/types/api/api-general.d.ts +++ b/src/types/api/api-general.d.ts @@ -50,3 +50,6 @@ export type BaseMetadata = { created_at: string; updated_at: string; }; + +export type Override = Omit & + Overrides; From 19ce3989ba3ad246720e73c1a6ae3f3088b30f53 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 16:05:18 +0700 Subject: [PATCH 113/174] feat(FE-41): create Warehouse API service --- src/services/api/master-data.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/services/api/master-data.ts b/src/services/api/master-data.ts index 01d1cbc6..785a1ca1 100644 --- a/src/services/api/master-data.ts +++ b/src/services/api/master-data.ts @@ -19,6 +19,11 @@ import { Kandang, UpdateKandangPayload, } from '@/types/api/master-data/kandang'; +import { + CreateWarehousePayload, + UpdateWarehousePayload, + Warehouse, +} from '@/types/api/master-data/warehouse'; export const UomApi = new BaseApiService< Uom, @@ -43,3 +48,9 @@ export const KandangApi = new BaseApiService< CreateKandangPayload, UpdateKandangPayload >('/master-data/kandangs'); + +export const WarehouseApi = new BaseApiService< + Warehouse, + CreateWarehousePayload, + UpdateWarehousePayload +>('/master-data/warehouses'); From 1f0c58d264e99530863c81c3bf4260e123468695 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 16:05:33 +0700 Subject: [PATCH 114/174] feat(FE-40): create WAREHOUSE_TYPE_OPTIONS constant --- src/config/constant.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/config/constant.ts b/src/config/constant.ts index 269f5da9..55eed0b3 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -92,3 +92,18 @@ export const ROWS_OPTIONS = [ value: 100, }, ]; + +export const WAREHOUSE_TYPE_OPTIONS = [ + { + label: 'AREA', + value: 'AREA', + }, + { + label: 'LOKASI', + value: 'LOKASI', + }, + { + label: 'KANDANG', + value: 'KANDANG', + }, +]; From 07691bfd9e02c3308d8942d82ee5fb3e4c3c45d6 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 16:05:58 +0700 Subject: [PATCH 115/174] feat(FE-40,41): create WarehouseForm component --- .../warehouse/form/WarehouseForm.tsx | 528 ++++++++++++++++++ 1 file changed, 528 insertions(+) create mode 100644 src/components/pages/master-data/warehouse/form/WarehouseForm.tsx diff --git a/src/components/pages/master-data/warehouse/form/WarehouseForm.tsx b/src/components/pages/master-data/warehouse/form/WarehouseForm.tsx new file mode 100644 index 00000000..90b9e4e0 --- /dev/null +++ b/src/components/pages/master-data/warehouse/form/WarehouseForm.tsx @@ -0,0 +1,528 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useFormik } from 'formik'; +import { toast } from 'react-hot-toast'; +import useSWR from 'swr'; + +import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; +import TextInput from '@/components/input/TextInput'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; + +import { + WarehouseFormSchema, + WarehouseFormValues, + UpdateWarehouseFormSchema, +} from '@/components/pages/master-data/warehouse/form/WarehouseForm.schema'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { + Warehouse, + CreateWarehousePayload, + UpdateWarehousePayload, +} from '@/types/api/master-data/warehouse'; +import { + AreaApi, + KandangApi, + LocationApi, + WarehouseApi, +} from '@/services/api/master-data'; +import { cn } from '@/lib/helper'; +import { WAREHOUSE_TYPE_OPTIONS } from '@/config/constant'; + +interface WarehouseFormProps { + type?: 'add' | 'edit' | 'detail'; + initialValues?: Warehouse; +} + +const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => { + const router = useRouter(); + const deleteModal = useModal(); + + const [warehouseFormErrorMessage, setWarehouseFormErrorMessage] = + useState(''); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const createWarehouseHandler = useCallback( + async (payload: CreateWarehousePayload) => { + const createWarehouseRes = await WarehouseApi.create(payload); + + if (isResponseError(createWarehouseRes)) { + setWarehouseFormErrorMessage(createWarehouseRes.message); + return; + } + + toast.success(createWarehouseRes?.message as string); + router.push('/master-data/warehouse'); + }, + [router] + ); + + const updateWarehouseHandler = useCallback( + async (warehouseId: number, payload: UpdateWarehousePayload) => { + const updateWarehouseRes = await WarehouseApi.update( + warehouseId, + payload + ); + + if (updateWarehouseRes?.status === 'error') { + setWarehouseFormErrorMessage(updateWarehouseRes.message); + return; + } + + toast.success(updateWarehouseRes?.message as string); + router.refresh(); + router.push('/master-data/warehouse'); + }, + [router] + ); + + const formikInitialValues = useMemo(() => { + switch (initialValues?.type) { + case 'AREA': + return { + name: initialValues?.name ?? '', + type: initialValues?.type ?? 'AREA', + areaId: initialValues?.area?.id ?? 0, + area: initialValues?.area + ? { + value: initialValues.area.id, + label: initialValues.area.name, + } + : null, + locationId: 0, + location: null, + kandangId: 0, + kandang: null, + }; + + case 'LOKASI': + return { + name: initialValues?.name ?? '', + type: initialValues?.type ?? 'LOKASI', + areaId: initialValues?.area?.id ?? 0, + area: initialValues?.area + ? { + value: initialValues.area.id, + label: initialValues.area.name, + } + : null, + locationId: initialValues?.location?.id ?? 0, + location: initialValues?.location + ? { + value: initialValues.location.id, + label: initialValues.location.name, + } + : null, + kandangId: 0, + kandang: null, + }; + + case 'KANDANG': + return { + name: initialValues?.name ?? '', + type: initialValues?.type ?? 'KANDANG', + areaId: initialValues?.area?.id ?? 0, + area: initialValues?.area + ? { + value: initialValues.area.id, + label: initialValues.area.name, + } + : null, + locationId: initialValues?.location?.id ?? 0, + location: initialValues?.location + ? { + value: initialValues.location.id, + label: initialValues.location.name, + } + : null, + kandangId: initialValues?.kandang?.id ?? 0, + kandang: initialValues?.kandang + ? { + value: initialValues.kandang.id, + label: initialValues.kandang.name, + } + : null, + }; + + default: + return { + name: '', + type: 'AREA', + areaId: 0, + area: null, + locationId: 0, + location: null, + kandangId: 0, + kandang: null, + }; + } + }, [initialValues]); + + const formik = useFormik({ + initialValues: formikInitialValues, + validationSchema: + type === 'edit' ? UpdateWarehouseFormSchema : WarehouseFormSchema, + onSubmit: async (values) => { + setWarehouseFormErrorMessage(''); + + let warehousePayload: CreateWarehousePayload | undefined = undefined; + + switch (values.type) { + case 'AREA': + warehousePayload = { + name: values.name, + type: values.type, + area_id: values.areaId as number, + }; + break; + + case 'LOKASI': + warehousePayload = { + name: values.name, + type: values.type, + area_id: values.areaId as number, + location_id: values.locationId as number, + }; + break; + + case 'KANDANG': + warehousePayload = { + name: values.name, + type: values.type, + area_id: values.areaId as number, + location_id: values.locationId as number, + kandang_id: values.kandangId as number, + }; + break; + } + + switch (type) { + case 'add': + await createWarehouseHandler(warehousePayload); + break; + + case 'edit': + await updateWarehouseHandler( + initialValues?.id as number, + warehousePayload + ); + break; + } + }, + }); + + const { setValues: formikSetValues } = formik; + + // Area + const [areaSelectInputValue, setAreaSelectInputValue] = useState(''); + + const areasUrl = `${AreaApi.basePath}?${new URLSearchParams({ + search: areaSelectInputValue ?? '', + }).toString()}`; + + const { data: areas, isLoading: isLoadingAreas } = useSWR( + areasUrl, + AreaApi.getAllFetcher + ); + + const areaOptions = isResponseSuccess(areas) + ? areas?.data.map((area) => ({ + value: area.id, + label: area.name, + })) + : []; + + // Location + const [locationSelectInputValue, setLocationSelectInputValue] = useState(''); + + const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({ + search: locationSelectInputValue ?? '', + }).toString()}`; + + const { data: locations, isLoading: isLoadingLocations } = useSWR( + locationsUrl, + LocationApi.getAllFetcher + ); + + const locationOptions = isResponseSuccess(locations) + ? locations?.data.map((location) => ({ + value: location.id, + label: location.name, + })) + : []; + + // Kandang + const [kandangSelectInputValue, setKandangSelectInputValue] = useState(''); + + const kandangsUrl = `${KandangApi.basePath}?${new URLSearchParams({ + search: kandangSelectInputValue ?? '', + }).toString()}`; + + const { data: kandangs, isLoading: isLoadingKandangs } = useSWR( + kandangsUrl, + KandangApi.getAllFetcher + ); + + const kandangOptions = isResponseSuccess(kandangs) + ? kandangs?.data.map((kandang) => ({ + value: kandang.id, + label: kandang.name, + })) + : []; + + const typeChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldTouched('type', true); + formik.setFieldValue('type', (val as OptionType)?.value); + }; + + const areaChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldTouched('area', true); + formik.setFieldValue('area', val); + + formik.setFieldTouched('areaId', true); + formik.setFieldValue('areaId', (val as OptionType)?.value); + }; + + const locationChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldTouched('location', true); + formik.setFieldValue('location', val); + + formik.setFieldTouched('locationId', true); + formik.setFieldValue('locationId', (val as OptionType)?.value); + }; + + const kandangChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldTouched('kandang', true); + formik.setFieldValue('kandang', val); + + formik.setFieldTouched('kandangId', true); + formik.setFieldValue('kandangId', (val as OptionType)?.value); + }; + + const deleteWarehouseClickHandler = () => { + deleteModal.openModal(); + }; + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await WarehouseApi.delete(initialValues?.id as number); + + deleteModal.closeModal(); + toast.success('Successfully delete Warehouse!'); + setIsDeleteLoading(false); + router.push('/master-data/warehouse'); + }; + + useEffect(() => { + formikSetValues(formikInitialValues); + }, [formikSetValues, formikInitialValues]); + + return ( + <> +
+
+ + +

+ {type === 'add' && 'Tambah Warehouse'} + {type === 'edit' && 'Edit Warehouse'} + {type === 'detail' && 'Detail Warehouse'} +

+
+ +
+
+ + + + + + + {(formik.values.type === 'LOKASI' || + formik.values.type === 'KANDANG') && ( + + )} + + {formik.values.type === 'KANDANG' && ( + + )} +
+ +
+ {type !== 'add' && ( +
+ + + {type !== 'edit' && ( + + )} +
+ )} + + {type !== 'detail' && ( +
+ + + +
+ )} +
+ + {warehouseFormErrorMessage && ( +
+ + {warehouseFormErrorMessage} +
+ )} +
+
+ + {type !== 'add' && ( + + )} + + ); +}; + +export default WarehouseForm; From 76cd64de5b3c5fbed5b6ea70fb4aaac6d04c5f03 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 16:06:21 +0700 Subject: [PATCH 116/174] feat(FE-42): create Warehouse form validation schema --- .../warehouse/form/WarehouseForm.schema.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 src/components/pages/master-data/warehouse/form/WarehouseForm.schema.ts diff --git a/src/components/pages/master-data/warehouse/form/WarehouseForm.schema.ts b/src/components/pages/master-data/warehouse/form/WarehouseForm.schema.ts new file mode 100644 index 00000000..36459e8f --- /dev/null +++ b/src/components/pages/master-data/warehouse/form/WarehouseForm.schema.ts @@ -0,0 +1,80 @@ +import * as Yup from 'yup'; +import { WarehouseType } from '@/types/api/master-data/warehouse'; + +const TYPE_VALUES = ['AREA', 'LOKASI', 'KANDANG'] as const; + +export const WarehouseFormSchema = Yup.object({ + name: Yup.string().required('Nama wajib diisi!'), + + type: Yup.string() + .oneOf(TYPE_VALUES) + .required('Tipe wajib diisi!'), + + areaId: Yup.number() + .transform((v, orig) => + orig === '' || orig === 0 || Number.isNaN(v) ? undefined : v + ) + .when('type', { + is: (t: WarehouseType | undefined) => + t === 'AREA' || t === 'LOKASI' || t === 'KANDANG', + then: (s) => s.min(1, 'Area wajib diisi!').required('Area wajib diisi!'), + otherwise: (s) => s.notRequired(), + }), + area: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }) + .nullable() + .when('type', { + is: (t: WarehouseType | undefined) => + t === 'AREA' || t === 'LOKASI' || t === 'KANDANG', + then: (s) => s.notRequired(), + otherwise: (s) => s.notRequired(), + }), + + locationId: Yup.number() + .transform((v, orig) => + orig === '' || orig === 0 || Number.isNaN(v) ? undefined : v + ) + .when('type', { + is: (t: WarehouseType | undefined) => t === 'LOKASI' || t === 'KANDANG', + then: (s) => + s.min(1, 'Lokasi wajib diisi!').required('Lokasi wajib diisi!'), + otherwise: (s) => s.notRequired(), + }), + location: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }) + .nullable() + .when('type', { + is: (t: WarehouseType | undefined) => t === 'LOKASI' || t === 'KANDANG', + then: (s) => s.notRequired(), + otherwise: (s) => s.notRequired(), + }), + + kandangId: Yup.number() + .transform((v, orig) => + orig === '' || orig === 0 || Number.isNaN(v) ? undefined : v + ) + .when('type', { + is: (t: WarehouseType | undefined) => t === 'KANDANG', + then: (s) => + s.min(1, 'Kandang wajib diisi!').required('Kandang wajib diisi!'), + otherwise: (s) => s.notRequired(), + }), + kandang: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }) + .nullable() + .when('type', { + is: (t: WarehouseType | undefined) => t === 'KANDANG', + then: (s) => s.notRequired(), + otherwise: (s) => s.notRequired(), + }), +}); + +export const UpdateWarehouseFormSchema = WarehouseFormSchema; + +export type WarehouseFormValues = Yup.InferType; From c1bc7beb4a3e61da63845840be022628a6689675 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 16:06:36 +0700 Subject: [PATCH 117/174] feat(FE-43): create WarehousesTable component --- .../master-data/warehouse/WarehousesTable.tsx | 357 ++++++++++++++++++ 1 file changed, 357 insertions(+) create mode 100644 src/components/pages/master-data/warehouse/WarehousesTable.tsx diff --git a/src/components/pages/master-data/warehouse/WarehousesTable.tsx b/src/components/pages/master-data/warehouse/WarehousesTable.tsx new file mode 100644 index 00000000..b3eed86b --- /dev/null +++ b/src/components/pages/master-data/warehouse/WarehousesTable.tsx @@ -0,0 +1,357 @@ +'use client'; + +import { ChangeEventHandler, useCallback, useEffect, useState } from 'react'; +import useSWR from 'swr'; +import { + CellContext, + ColumnDef, + ColumnSort, + SortingState, +} from '@tanstack/react-table'; +import toast from 'react-hot-toast'; + +import { Icon } from '@iconify/react'; +import Table from '@/components/Table'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import Button from '@/components/Button'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import RowCollapseOptions from '@/components/table/RowCollapseOptions'; + +import { Warehouse } from '@/types/api/master-data/warehouse'; +import { WarehouseApi } from '@/services/api/master-data'; +import { cn } from '@/lib/helper'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { ROWS_OPTIONS } from '@/config/constant'; + +const RowOptionsMenu = ({ + type = 'dropdown', + props, + deleteClickHandler, +}: { + type: 'dropdown' | 'collapse'; + props: CellContext; + deleteClickHandler: () => void; +}) => { + return ( +
+ + + + + +
+ ); +}; + +const WarehousesTable = () => { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { + search: '', + nameSort: '', + typeSort: '', + areaSort: '', + locationSort: '', + kandangSort: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + nameSort: 'sort_name', + typeSort: 'sort_type', + areaSort: ' sort_area', + locationSort: ' sort_location', + kandangSort: ' sort_kandang', + }, + }); + + const { + data: warehouses, + isLoading, + mutate: refreshWarehouses, + } = useSWR( + `${WarehouseApi.basePath}${getTableFilterQueryString()}`, + WarehouseApi.getAllFetcher + ); + + const deleteModal = useModal(); + + const [selectedWarehouse, setSelectedWarehouse] = useState< + Warehouse | undefined + >(undefined); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const [sorting, setSorting] = useState([]); + + const warehousesColumns: ColumnDef[] = [ + { + header: '#', + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, + }, + { + accessorKey: 'name', + header: 'Nama', + }, + { + accessorKey: 'type', + header: 'Tipe', + }, + { + accessorKey: 'area', + header: 'Area', + cell: (props) => props.row.original.area.name, + }, + { + accessorKey: 'location', + header: 'Lokasi', + cell: (props) => { + if ( + props.row.original.type === 'LOKASI' || + props.row.original.type === 'KANDANG' + ) { + return props.row.original.location.name; + } else { + return '-'; + } + }, + }, + { + accessorKey: 'kandang', + header: 'Kandang', + cell: (props) => { + if (props.row.original.type === 'KANDANG') { + return props.row.original.kandang.name; + } else { + return '-'; + } + }, + }, + { + header: 'Aksi', + cell: (props) => { + const currentPageSize = props.table.getPaginationRowModel().rows.length; + const currentPageRows = props.table.getPaginationRowModel().flatRows; + const currentRowRelativeIndex = + currentPageRows.findIndex((r) => r.id === props.row.id) + 1; + + const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; + + const deleteClickHandler = () => { + setSelectedWarehouse(props.row.original); + deleteModal.openModal(); + }; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]; + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await WarehouseApi.delete(selectedWarehouse?.id as number); + refreshWarehouses(); + + deleteModal.closeModal(); + toast.success('Successfully delete Warehouse!'); + setIsDeleteLoading(false); + }; + + const searchChangeHandler: ChangeEventHandler = (e) => { + updateFilter('search', e.target.value); + }; + + const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + + setPageSize(newVal.value as number); + }; + + const updateSortingFilter = useCallback( + ( + sortName: Exclude, + sortFilter: ColumnSort | undefined + ) => { + if (!sortFilter) { + updateFilter(sortName, ''); + } else { + updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc'); + } + }, + [updateFilter] + ); + + // track sorting + useEffect(() => { + const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name'); + const typeSortFilter = sorting.find((sortItem) => sortItem.id === 'type'); + const areaSortFilter = sorting.find((sortItem) => sortItem.id === 'area'); + const locationSortFilter = sorting.find( + (sortItem) => sortItem.id === 'location' + ); + const kandangSortFilter = sorting.find( + (sortItem) => sortItem.id === 'kandang' + ); + + updateSortingFilter('nameSort', nameSortFilter); + updateSortingFilter('typeSort', typeSortFilter); + updateSortingFilter('areaSort', areaSortFilter); + updateSortingFilter('locationSort', locationSortFilter); + updateSortingFilter('kandangSort', kandangSortFilter); + }, [sorting]); + + return ( + <> +
+
+
+
+ +
+ + +
+ +
+ +
+
+ + + data={isResponseSuccess(warehouses) ? warehouses?.data : []} + columns={warehousesColumns} + pageSize={tableFilterState.pageSize} + page={isResponseSuccess(warehouses) ? warehouses?.meta?.page : 0} + totalItems={ + isResponseSuccess(warehouses) ? warehouses?.meta?.total_results : 0 + } + onPageChange={setPage} + isLoading={isLoading} + sorting={sorting} + setSorting={setSorting} + className={{ + containerClassName: cn({ + 'mb-20': + isResponseSuccess(warehouses) && warehouses?.data?.length === 0, + }), + tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableClassName: 'font-inter w-full table-auto min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', + bodyRowClassName: 'border-b border-b-gray-200', + bodyColumnClassName: + 'px-6 py-3 last:flex last:flex-row last:justify-end', + }} + /> +
+ + + + ); +}; + +export default WarehousesTable; From 70bdfc3b4320eaf88cccac20a8de9d23660f0690 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 16:06:51 +0700 Subject: [PATCH 118/174] feat(FE-43): create Master Data Warehouse page --- src/app/master-data/warehouse/page.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/app/master-data/warehouse/page.tsx diff --git a/src/app/master-data/warehouse/page.tsx b/src/app/master-data/warehouse/page.tsx new file mode 100644 index 00000000..eb5ae416 --- /dev/null +++ b/src/app/master-data/warehouse/page.tsx @@ -0,0 +1,11 @@ +import WarehousesTable from '@/components/pages/master-data/warehouse/WarehousesTable'; + +const Warehouse = () => { + return ( +
+ +
+ ); +}; + +export default Warehouse; From eb10dfe29f0178f1073b19140aa40c40629a6f39 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 16:07:06 +0700 Subject: [PATCH 119/174] feat(FE-40,41): create Master Data Add Warehouse page --- src/app/master-data/warehouse/add/page.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/app/master-data/warehouse/add/page.tsx diff --git a/src/app/master-data/warehouse/add/page.tsx b/src/app/master-data/warehouse/add/page.tsx new file mode 100644 index 00000000..7a8105a1 --- /dev/null +++ b/src/app/master-data/warehouse/add/page.tsx @@ -0,0 +1,11 @@ +import WarehouseForm from '@/components/pages/master-data/warehouse/form/WarehouseForm'; + +const AddNonstock = () => { + return ( +
+ +
+ ); +}; + +export default AddNonstock; From 452139eeedc8155d8e8d8aeed312cf6c50e00eb2 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 16:07:17 +0700 Subject: [PATCH 120/174] feat(FE-40,41): create Master Data Detail Warehouse page --- src/app/master-data/warehouse/detail/page.tsx | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/app/master-data/warehouse/detail/page.tsx diff --git a/src/app/master-data/warehouse/detail/page.tsx b/src/app/master-data/warehouse/detail/page.tsx new file mode 100644 index 00000000..7d6381e3 --- /dev/null +++ b/src/app/master-data/warehouse/detail/page.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import WarehouseForm from '@/components/pages/master-data/warehouse/form/WarehouseForm'; + +import { WarehouseApi } from '@/services/api/master-data'; +import { isResponseSuccess } from '@/lib/api-helper'; + +const WarehouseDetail = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const warehouseId = searchParams.get('warehouseId'); + + const { data: warehouse, isLoading: isLoadingWarehouse } = useSWR( + warehouseId, + (id: number) => WarehouseApi.getSingle(id) + ); + + if (!warehouseId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingWarehouse && !warehouse) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingWarehouse && ( + + )} + {!isLoadingWarehouse && isResponseSuccess(warehouse) && ( + + )} +
+ ); +}; + +export default WarehouseDetail; From 1ae2d13335540808c57dc70c4c12757882ba4ef7 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 16:07:27 +0700 Subject: [PATCH 121/174] feat(FE-40,41): create Master Data Edit Warehouse page --- .../warehouse/detail/edit/page.tsx | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/app/master-data/warehouse/detail/edit/page.tsx diff --git a/src/app/master-data/warehouse/detail/edit/page.tsx b/src/app/master-data/warehouse/detail/edit/page.tsx new file mode 100644 index 00000000..e4151226 --- /dev/null +++ b/src/app/master-data/warehouse/detail/edit/page.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import WarehouseForm from '@/components/pages/master-data/warehouse/form/WarehouseForm'; + +import { WarehouseApi } from '@/services/api/master-data'; +import { isResponseSuccess } from '@/lib/api-helper'; + +const WarehouseEdit = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const warehouseId = searchParams.get('warehouseId'); + + const { data: warehouse, isLoading: isLoadingWarehouse } = useSWR( + warehouseId, + (id: number) => WarehouseApi.getSingle(id) + ); + + if (!warehouseId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingWarehouse && !warehouse) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingWarehouse && ( + + )} + {!isLoadingWarehouse && isResponseSuccess(warehouse) && ( + + )} +
+ ); +}; + +export default WarehouseEdit; From dcebd53c45b1555ec961b2ca1354d0883e701938 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 16:13:04 +0700 Subject: [PATCH 122/174] feat(FE-40): add edit button --- .../pages/master-data/area/form/AreaForm.tsx | 17 +++++++++++++++++ .../master-data/location/form/LocationForm.tsx | 17 +++++++++++++++++ .../pages/master-data/uom/form/UomForm.tsx | 17 +++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/src/components/pages/master-data/area/form/AreaForm.tsx b/src/components/pages/master-data/area/form/AreaForm.tsx index d0a80105..41113b61 100644 --- a/src/components/pages/master-data/area/form/AreaForm.tsx +++ b/src/components/pages/master-data/area/form/AreaForm.tsx @@ -174,6 +174,23 @@ const AreaForm = ({ type = 'add', initialValues }: AreaFormProps) => { /> Delete + + {type !== 'edit' && ( + + )}
)} diff --git a/src/components/pages/master-data/location/form/LocationForm.tsx b/src/components/pages/master-data/location/form/LocationForm.tsx index 50a5f664..c477edf3 100644 --- a/src/components/pages/master-data/location/form/LocationForm.tsx +++ b/src/components/pages/master-data/location/form/LocationForm.tsx @@ -243,6 +243,23 @@ const LocationForm = ({ type = 'add', initialValues }: LocationFormProps) => { /> Delete + + {type !== 'edit' && ( + + )}
)} diff --git a/src/components/pages/master-data/uom/form/UomForm.tsx b/src/components/pages/master-data/uom/form/UomForm.tsx index da99f813..ad14c666 100644 --- a/src/components/pages/master-data/uom/form/UomForm.tsx +++ b/src/components/pages/master-data/uom/form/UomForm.tsx @@ -174,6 +174,23 @@ const UomForm = ({ type = 'add', initialValues }: UomFormProps) => { /> Delete + + {type !== 'edit' && ( + + )}
)} From af60e682ee6c0dec443eb4ca574e1c025e15dddd Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sun, 5 Oct 2025 16:19:37 +0700 Subject: [PATCH 123/174] chore(FE-41): redirect to /404 if response error --- src/app/master-data/area/detail/edit/page.tsx | 4 ++-- src/app/master-data/area/detail/page.tsx | 4 ++-- src/app/master-data/kandang/detail/edit/page.tsx | 4 ++-- src/app/master-data/kandang/detail/page.tsx | 4 ++-- src/app/master-data/location/detail/edit/page.tsx | 4 ++-- src/app/master-data/location/detail/page.tsx | 4 ++-- src/app/master-data/uom/detail/edit/page.tsx | 4 ++-- src/app/master-data/uom/detail/page.tsx | 4 ++-- src/app/master-data/warehouse/detail/edit/page.tsx | 4 ++-- src/app/master-data/warehouse/detail/page.tsx | 4 ++-- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/app/master-data/area/detail/edit/page.tsx b/src/app/master-data/area/detail/edit/page.tsx index a8b5138f..4b29d792 100644 --- a/src/app/master-data/area/detail/edit/page.tsx +++ b/src/app/master-data/area/detail/edit/page.tsx @@ -6,7 +6,7 @@ import useSWR from 'swr'; import AreaForm from '@/components/pages/master-data/area/form/AreaForm'; import { AreaApi } from '@/services/api/master-data'; -import { isResponseSuccess } from '@/lib/api-helper'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; const AreaEdit = () => { const router = useRouter(); @@ -29,7 +29,7 @@ const AreaEdit = () => { ); } - if (!isLoadingArea && !area) { + if (!isLoadingArea && (!area || isResponseError(area))) { router.replace('/404'); return; } diff --git a/src/app/master-data/area/detail/page.tsx b/src/app/master-data/area/detail/page.tsx index 55bfbc7d..c786ac0d 100644 --- a/src/app/master-data/area/detail/page.tsx +++ b/src/app/master-data/area/detail/page.tsx @@ -6,7 +6,7 @@ import useSWR from 'swr'; import AreaForm from '@/components/pages/master-data/area/form/AreaForm'; import { AreaApi } from '@/services/api/master-data'; -import { isResponseSuccess } from '@/lib/api-helper'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; const AreaDetail = () => { const router = useRouter(); @@ -29,7 +29,7 @@ const AreaDetail = () => { ); } - if (!isLoadingArea && !area) { + if (!isLoadingArea && (!area || isResponseError(area))) { router.replace('/404'); return; } diff --git a/src/app/master-data/kandang/detail/edit/page.tsx b/src/app/master-data/kandang/detail/edit/page.tsx index 426aabc0..561d6f1f 100644 --- a/src/app/master-data/kandang/detail/edit/page.tsx +++ b/src/app/master-data/kandang/detail/edit/page.tsx @@ -6,7 +6,7 @@ import useSWR from 'swr'; import KandangForm from '@/components/pages/master-data/kandang/form/KandangForm'; import { KandangApi } from '@/services/api/master-data'; -import { isResponseSuccess } from '@/lib/api-helper'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; const KandangEdit = () => { const router = useRouter(); @@ -29,7 +29,7 @@ const KandangEdit = () => { ); } - if (!isLoadingKandang && !kandang) { + if (!isLoadingKandang && (!kandang || isResponseError(kandang))) { router.replace('/404'); return; } diff --git a/src/app/master-data/kandang/detail/page.tsx b/src/app/master-data/kandang/detail/page.tsx index 1b91bad7..a5b4f0e9 100644 --- a/src/app/master-data/kandang/detail/page.tsx +++ b/src/app/master-data/kandang/detail/page.tsx @@ -6,7 +6,7 @@ import useSWR from 'swr'; import KandangForm from '@/components/pages/master-data/kandang/form/KandangForm'; import { KandangApi } from '@/services/api/master-data'; -import { isResponseSuccess } from '@/lib/api-helper'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; const KandangDetail = () => { const router = useRouter(); @@ -29,7 +29,7 @@ const KandangDetail = () => { ); } - if (!isLoadingKandang && !kandang) { + if (!isLoadingKandang && (!kandang || isResponseError(kandang))) { router.replace('/404'); return; } diff --git a/src/app/master-data/location/detail/edit/page.tsx b/src/app/master-data/location/detail/edit/page.tsx index 853254d9..a97f5672 100644 --- a/src/app/master-data/location/detail/edit/page.tsx +++ b/src/app/master-data/location/detail/edit/page.tsx @@ -6,7 +6,7 @@ import useSWR from 'swr'; import LocationForm from '@/components/pages/master-data/location/form/LocationForm'; import { LocationApi } from '@/services/api/master-data'; -import { isResponseSuccess } from '@/lib/api-helper'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; const LocationEdit = () => { const router = useRouter(); @@ -29,7 +29,7 @@ const LocationEdit = () => { ); } - if (!isLoadingLocation && !location) { + if (!isLoadingLocation && (!location || isResponseError(location))) { router.replace('/404'); return; } diff --git a/src/app/master-data/location/detail/page.tsx b/src/app/master-data/location/detail/page.tsx index bff4d12e..bb0fbe4c 100644 --- a/src/app/master-data/location/detail/page.tsx +++ b/src/app/master-data/location/detail/page.tsx @@ -6,7 +6,7 @@ import useSWR from 'swr'; import LocationForm from '@/components/pages/master-data/location/form/LocationForm'; import { LocationApi } from '@/services/api/master-data'; -import { isResponseSuccess } from '@/lib/api-helper'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; const LocationDetail = () => { const router = useRouter(); @@ -29,7 +29,7 @@ const LocationDetail = () => { ); } - if (!isLoadingLocation && !location) { + if (!isLoadingLocation && (!location || isResponseError(location))) { router.replace('/404'); return; } diff --git a/src/app/master-data/uom/detail/edit/page.tsx b/src/app/master-data/uom/detail/edit/page.tsx index 95566351..48d7c823 100644 --- a/src/app/master-data/uom/detail/edit/page.tsx +++ b/src/app/master-data/uom/detail/edit/page.tsx @@ -6,7 +6,7 @@ import useSWR from 'swr'; import UomForm from '@/components/pages/master-data/uom/form/UomForm'; import { UomApi } from '@/services/api/master-data'; -import { isResponseSuccess } from '@/lib/api-helper'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; const UomEdit = () => { const router = useRouter(); @@ -28,7 +28,7 @@ const UomEdit = () => { ); } - if (!isLoadingUom && !uom) { + if (!isLoadingUom && (!uom || isResponseError(uom))) { router.replace('/404'); return; } diff --git a/src/app/master-data/uom/detail/page.tsx b/src/app/master-data/uom/detail/page.tsx index 59ad0755..b02af02b 100644 --- a/src/app/master-data/uom/detail/page.tsx +++ b/src/app/master-data/uom/detail/page.tsx @@ -6,7 +6,7 @@ import useSWR from 'swr'; import UomForm from '@/components/pages/master-data/uom/form/UomForm'; import { UomApi } from '@/services/api/master-data'; -import { isResponseSuccess } from '@/lib/api-helper'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; const UomDetail = () => { const router = useRouter(); @@ -28,7 +28,7 @@ const UomDetail = () => { ); } - if (!isLoadingUom && !uom) { + if (!isLoadingUom && (!uom || isResponseError(uom))) { router.replace('/404'); return; } diff --git a/src/app/master-data/warehouse/detail/edit/page.tsx b/src/app/master-data/warehouse/detail/edit/page.tsx index e4151226..a6498834 100644 --- a/src/app/master-data/warehouse/detail/edit/page.tsx +++ b/src/app/master-data/warehouse/detail/edit/page.tsx @@ -6,7 +6,7 @@ import useSWR from 'swr'; import WarehouseForm from '@/components/pages/master-data/warehouse/form/WarehouseForm'; import { WarehouseApi } from '@/services/api/master-data'; -import { isResponseSuccess } from '@/lib/api-helper'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; const WarehouseEdit = () => { const router = useRouter(); @@ -29,7 +29,7 @@ const WarehouseEdit = () => { ); } - if (!isLoadingWarehouse && !warehouse) { + if (!isLoadingWarehouse && (!warehouse || isResponseError(warehouse))) { router.replace('/404'); return; } diff --git a/src/app/master-data/warehouse/detail/page.tsx b/src/app/master-data/warehouse/detail/page.tsx index 7d6381e3..5a7c7042 100644 --- a/src/app/master-data/warehouse/detail/page.tsx +++ b/src/app/master-data/warehouse/detail/page.tsx @@ -6,7 +6,7 @@ import useSWR from 'swr'; import WarehouseForm from '@/components/pages/master-data/warehouse/form/WarehouseForm'; import { WarehouseApi } from '@/services/api/master-data'; -import { isResponseSuccess } from '@/lib/api-helper'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; const WarehouseDetail = () => { const router = useRouter(); @@ -29,7 +29,7 @@ const WarehouseDetail = () => { ); } - if (!isLoadingWarehouse && !warehouse) { + if (!isLoadingWarehouse && (!warehouse || isResponseError(warehouse))) { router.replace('/404'); return; } From 4f0e02a93b84e5ac4a1823794431ed8edcbbefba Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 7 Oct 2025 17:23:58 +0700 Subject: [PATCH 124/174] chore: add .idea to .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 5ef6a520..2b4315f8 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# idea +.idea \ No newline at end of file From 3cac49725fefb5b508d404061ff16434b30a39e0 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 7 Oct 2025 18:56:58 +0700 Subject: [PATCH 125/174] feat(FE-42): add Product Category API and form validation schema --- .../form/ProductCategoryForm.schema.ts | 10 ++++++++++ src/services/api/master-data.ts | 11 +++++++++++ src/types/api/master-data/product-category.d.ts | 16 ++++++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 src/components/pages/master-data/product-category/form/ProductCategoryForm.schema.ts create mode 100644 src/types/api/master-data/product-category.d.ts diff --git a/src/components/pages/master-data/product-category/form/ProductCategoryForm.schema.ts b/src/components/pages/master-data/product-category/form/ProductCategoryForm.schema.ts new file mode 100644 index 00000000..102bb812 --- /dev/null +++ b/src/components/pages/master-data/product-category/form/ProductCategoryForm.schema.ts @@ -0,0 +1,10 @@ +import * as Yup from 'yup'; + +export const ProductCategoryFormSchema = Yup.object({ + code: Yup.string().required('Kode wajib diisi!').max(3, 'Kode kategori produk melebihi 3 karakter!'), + name: Yup.string().required('Nama wajib diisi!'), +}); + +export const UpdateProductCategoryFormSchema = ProductCategoryFormSchema; + +export type ProductCategoryFormValues = Yup.InferType; \ No newline at end of file diff --git a/src/services/api/master-data.ts b/src/services/api/master-data.ts index 785a1ca1..53ea26c6 100644 --- a/src/services/api/master-data.ts +++ b/src/services/api/master-data.ts @@ -24,6 +24,11 @@ import { UpdateWarehousePayload, Warehouse, } from '@/types/api/master-data/warehouse'; +import { + CreateProductCategoryPayload, + ProductCategory, + UpdateProductCategoryPayload, +} from '@/types/api/master-data/product-category'; export const UomApi = new BaseApiService< Uom, @@ -54,3 +59,9 @@ export const WarehouseApi = new BaseApiService< CreateWarehousePayload, UpdateWarehousePayload >('/master-data/warehouses'); + +export const ProductCategoryApi = new BaseApiService< + ProductCategory, + CreateProductCategoryPayload, + UpdateProductCategoryPayload +>('/master-data/product-categories'); \ No newline at end of file diff --git a/src/types/api/master-data/product-category.d.ts b/src/types/api/master-data/product-category.d.ts new file mode 100644 index 00000000..3dd6203e --- /dev/null +++ b/src/types/api/master-data/product-category.d.ts @@ -0,0 +1,16 @@ +import { BaseMetadata } from '@/types/api/api-general'; + +export type BaseProductCategory = { + id: number; + code: string; + name: string; +}; + +export type ProductCategory = BaseMetadata & BaseProductCategory; + +export type CreateProductCategoryPayload = { + code: string; + name: string; +}; + +export type UpdateProductCategoryPayload = CreateProductCategoryPayload; From 2b9b8e99202dbb6104ec91ea28abc5a32de5880b Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 7 Oct 2025 18:58:38 +0700 Subject: [PATCH 126/174] feat(FE-41,43): implement Product Category table with CRUD operations --- .../product-category/ProductCategoryTable.tsx | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 src/components/pages/master-data/product-category/ProductCategoryTable.tsx diff --git a/src/components/pages/master-data/product-category/ProductCategoryTable.tsx b/src/components/pages/master-data/product-category/ProductCategoryTable.tsx new file mode 100644 index 00000000..f5226387 --- /dev/null +++ b/src/components/pages/master-data/product-category/ProductCategoryTable.tsx @@ -0,0 +1,262 @@ +'use client'; + +import { ChangeEventHandler, useEffect, useState } from 'react'; +import useSWR from 'swr'; +import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; +import toast from 'react-hot-toast'; + +import { Icon } from '@iconify/react'; +import Table from '@/components/Table'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import Button from '@/components/Button'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import RowCollapseOptions from '@/components/table/RowCollapseOptions'; + +import { ProductCategory } from '@/types/api/master-data/product-category'; +import { ProductCategoryApi } from '@/services/api/master-data'; +import { cn } from '@/lib/helper'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { ROWS_OPTIONS } from '@/config/constant'; + +const RowOptionsMenu = ({ + type = 'dropdown', + props, + deleteClickHandler, +}: { + type: 'dropdown' | 'collapse'; + props: CellContext; + deleteClickHandler: () => void; +}) => { + return ( +
+ + + +
+ ); +}; + +const ProductCategoryTable = () => { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { search: '', nameSort: '' }, + paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' }, + }); + + const { + data: productCategories, + isLoading, + mutate: refreshProductCategories, + } = useSWR( + `${ProductCategoryApi.basePath}${getTableFilterQueryString()}`, + ProductCategoryApi.getAllFetcher + ); + + const deleteModal = useModal(); + + const [selectedProductCategory, setSelectedProductCategory] = useState(undefined); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const [sorting, setSorting] = useState([]); + + const productCategoryColumns: ColumnDef[] = [ + { + header: '#', + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, + }, + { + accessorKey: 'name', + header: 'Nama', + }, + { + header: 'Aksi', + cell: (props) => { + const currentPageSize = props.table.getPaginationRowModel().rows.length; + const currentPageRows = props.table.getPaginationRowModel().flatRows; + const currentRowRelativeIndex = + currentPageRows.findIndex((r) => r.id === props.row.id) + 1; + + const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; + + const deleteClickHandler = () => { + setSelectedProductCategory(props.row.original); + deleteModal.openModal(); + }; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]; + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await ProductCategoryApi.delete(selectedProductCategory?.id as number); + refreshProductCategories(); + + deleteModal.closeModal(); + toast.success('Successfully delete Product Category!'); + setIsDeleteLoading(false); + }; + + const searchChangeHandler: ChangeEventHandler = (e) => { + updateFilter('search', e.target.value); + }; + + const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + setPageSize(newVal.value as number); + }; + + useEffect(() => { + const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name'); + if (!isNameSorted) { + updateFilter('nameSort', ''); + } else { + updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc'); + } + }, [sorting]); + + return ( + <> +
+
+
+
+ +
+ +
+
+ +
+
+ + data={isResponseSuccess(productCategories) ? productCategories?.data : []} + columns={productCategoryColumns} + pageSize={tableFilterState.pageSize} + page={isResponseSuccess(productCategories) ? productCategories?.meta?.page : 0} + totalItems={isResponseSuccess(productCategories) ? productCategories?.meta?.total_results : 0} + onPageChange={setPage} + isLoading={isLoading} + sorting={sorting} + setSorting={setSorting} + className={{ + containerClassName: cn({ + 'mb-20': isResponseSuccess(productCategories) && productCategories?.data?.length === 0, + }), + tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableClassName: 'font-inter w-full table-auto min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', + bodyRowClassName: 'border-b border-b-gray-200', + bodyColumnClassName: + 'px-6 py-3 last:flex last:flex-row last:justify-end', + }} + /> +
+ + + ); +}; + +export default ProductCategoryTable; \ No newline at end of file From 26ec4569375fbf3f5f40dbefcae63269901a7a8e Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 7 Oct 2025 18:59:31 +0700 Subject: [PATCH 127/174] feat(FE-40,41,42): add Product Category detail and edit pages with form handling --- .../master-data/product-category/add/page.tsx | 11 + .../product-category/detail/edit/page.tsx | 47 ++++ .../product-category/detail/page.tsx | 47 ++++ src/app/master-data/product-category/page.tsx | 11 + .../form/ProductCategoryForm.tsx | 266 ++++++++++++++++++ 5 files changed, 382 insertions(+) create mode 100644 src/app/master-data/product-category/add/page.tsx create mode 100644 src/app/master-data/product-category/detail/edit/page.tsx create mode 100644 src/app/master-data/product-category/detail/page.tsx create mode 100644 src/app/master-data/product-category/page.tsx create mode 100644 src/components/pages/master-data/product-category/form/ProductCategoryForm.tsx diff --git a/src/app/master-data/product-category/add/page.tsx b/src/app/master-data/product-category/add/page.tsx new file mode 100644 index 00000000..0993ba7a --- /dev/null +++ b/src/app/master-data/product-category/add/page.tsx @@ -0,0 +1,11 @@ +import ProductCategoryForm from "@/components/pages/master-data/product-category/form/ProductCategoryForm"; + +const AddProductCategory = () => { + return ( +
+ +
+ ); +}; + +export default AddProductCategory; \ No newline at end of file diff --git a/src/app/master-data/product-category/detail/edit/page.tsx b/src/app/master-data/product-category/detail/edit/page.tsx new file mode 100644 index 00000000..6bc10644 --- /dev/null +++ b/src/app/master-data/product-category/detail/edit/page.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import ProductCategoryForm from '@/components/pages/master-data/product-category/form/ProductCategoryForm'; + +import { ProductCategoryApi } from '@/services/api/master-data'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const ProductCategoryEdit = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const productCategoryId = searchParams.get('productCategoryId'); + + const { data: productCategory, isLoading: isLoadingProductCategory } = useSWR( + productCategoryId, + (id: number) => ProductCategoryApi.getSingle(id) + ); + + if (!productCategoryId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingProductCategory && (!productCategory || isResponseError(productCategory))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingProductCategory && } + {!isLoadingProductCategory && isResponseSuccess(productCategory) && ( + + )} +
+ ); +} + +export default ProductCategoryEdit; \ No newline at end of file diff --git a/src/app/master-data/product-category/detail/page.tsx b/src/app/master-data/product-category/detail/page.tsx new file mode 100644 index 00000000..cba06fdb --- /dev/null +++ b/src/app/master-data/product-category/detail/page.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import ProductCategoryForm from '@/components/pages/master-data/product-category/form/ProductCategoryForm'; + +import { ProductCategoryApi } from '@/services/api/master-data'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const ProductCategoryDetail = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const productCategoryId = searchParams.get('productCategoryId'); + + const { data: productCategory, isLoading: isLoadingProductCategory } = useSWR( + productCategoryId, + (id: number) => ProductCategoryApi.getSingle(id) + ); + + if (!productCategoryId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingProductCategory && (!productCategory || isResponseError(productCategory))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingProductCategory && } + {!isLoadingProductCategory && isResponseSuccess(productCategory) && ( + + )} +
+ ); +}; + +export default ProductCategoryDetail; diff --git a/src/app/master-data/product-category/page.tsx b/src/app/master-data/product-category/page.tsx new file mode 100644 index 00000000..5ec6d555 --- /dev/null +++ b/src/app/master-data/product-category/page.tsx @@ -0,0 +1,11 @@ +import ProductCategoryTable from "@/components/pages/master-data/product-category/ProductCategoryTable"; + +const ProductCategory = () => { + return ( +
+ +
+ ); +}; + +export default ProductCategory; \ No newline at end of file diff --git a/src/components/pages/master-data/product-category/form/ProductCategoryForm.tsx b/src/components/pages/master-data/product-category/form/ProductCategoryForm.tsx new file mode 100644 index 00000000..453670f3 --- /dev/null +++ b/src/components/pages/master-data/product-category/form/ProductCategoryForm.tsx @@ -0,0 +1,266 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useFormik } from 'formik'; +import { toast } from 'react-hot-toast'; + +import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; +import TextInput from '@/components/input/TextInput'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; + +import { + ProductCategoryFormSchema, + ProductCategoryFormValues, + UpdateProductCategoryFormSchema, +} from '@/components/pages/master-data/product-category/form/ProductCategoryForm.schema'; +import { isResponseError } from '@/lib/api-helper'; +import { + ProductCategory, + CreateProductCategoryPayload, + UpdateProductCategoryPayload, +} from '@/types/api/master-data/product-category'; +import { ProductCategoryApi } from '@/services/api/master-data'; +import { cn } from '@/lib/helper'; + +interface ProductCategoryFormProps { + type?: 'add' | 'edit' | 'detail'; + initialValues?: ProductCategory; +} + +const ProductCategoryForm = ({ type = 'add', initialValues }: ProductCategoryFormProps) => { + const router = useRouter(); + const deleteModal = useModal(); + + const [formErrorMessage, setFormErrorMessage] = useState(''); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const createProductCategoryHandler = useCallback( + async (payload: CreateProductCategoryPayload) => { + const res = await ProductCategoryApi.create(payload); + + if (isResponseError(res)) { + setFormErrorMessage(res.message); + return; + } + + toast.success(res?.message as string); + router.push('/master-data/product-category'); + }, + [router] + ); + + const updateProductCategoryHandler = useCallback( + async (id: number, payload: UpdateProductCategoryPayload) => { + const res = await ProductCategoryApi.update(id, payload); + + if (res?.status === 'error') { + setFormErrorMessage(res.message); + return; + } + + toast.success(res?.message as string); + router.refresh(); + router.push('/master-data/product-category'); + }, + [router] + ); + + const formikInitialValues = useMemo(() => { + return { + code: initialValues?.code ?? '', + name: initialValues?.name ?? '', + }; + }, [initialValues]); + + const formik = useFormik({ + initialValues: formikInitialValues, + validationSchema: type === 'edit' ? UpdateProductCategoryFormSchema : ProductCategoryFormSchema, + onSubmit: async (values) => { + setFormErrorMessage(''); + + const payload: CreateProductCategoryPayload = { + code: values.code, + name: values.name, + }; + + switch (type) { + case 'add': + await createProductCategoryHandler(payload); + break; + case 'edit': + await updateProductCategoryHandler(initialValues?.id as number, payload); + break; + } + }, + }); + + const { setValues: formikSetValues } = formik; + + const deleteProductCategoryClickHandler = () => { + deleteModal.openModal(); + }; + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await ProductCategoryApi.delete(initialValues?.id as number); + + deleteModal.closeModal(); + toast.success('Successfully delete Product Category!'); + setIsDeleteLoading(false); + router.push('/master-data/product-category'); + }; + + useEffect(() => { + formikSetValues(formikInitialValues); + }, [formikSetValues, formikInitialValues]); + + return ( + <> +
+
+ + +

+ {type === 'add' && 'Tambah Product Category'} + {type === 'edit' && 'Edit Product Category'} + {type === 'detail' && 'Detail Product Category'} +

+
+ +
+
+ + +
+ +
+ {type !== 'add' && ( +
+ + + {type !== 'edit' && ( + + )} +
+ )} + + {type !== 'detail' && ( +
+ + + +
+ )} +
+ + {formErrorMessage && ( +
+ + {formErrorMessage} +
+ )} +
+
+ + {type !== 'add' && ( + + )} + + ); +}; + +export default ProductCategoryForm; \ No newline at end of file From 3241cc98683afd77024c9d420ca3e11b4fadccda Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 7 Oct 2025 21:21:24 +0700 Subject: [PATCH 128/174] feat(FE-42): add Product API and form validation schema with product flags --- .../product/form/ProductForm.schema.ts | 53 +++++++++++++++++++ src/config/constant.ts | 13 +++++ src/services/api/master-data.ts | 13 ++++- src/types/api/master-data/product.d.ts | 37 +++++++++++++ 4 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 src/components/pages/master-data/product/form/ProductForm.schema.ts create mode 100644 src/types/api/master-data/product.d.ts diff --git a/src/components/pages/master-data/product/form/ProductForm.schema.ts b/src/components/pages/master-data/product/form/ProductForm.schema.ts new file mode 100644 index 00000000..eea9abf7 --- /dev/null +++ b/src/components/pages/master-data/product/form/ProductForm.schema.ts @@ -0,0 +1,53 @@ +import * as Yup from 'yup'; + +export const ProductFormSchema = Yup.object({ + name: Yup.string().required('Nama wajib diisi!'), + brand: Yup.string().required('Merek wajib diisi!'), + sku: Yup.string().required('SKU wajib diisi!'), + uom: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + uom_id: Yup.number().required('Satuan wajib diisi!').typeError('Satuan wajib diisi!'), + product_category: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + product_category_id: Yup.number() + .required('Kategori produk wajib diisi!') + .typeError('Kategori produk wajib diisi!'), + product_price: Yup.number() + .required('Harga produk wajib diisi!') + .typeError('Harga produk wajib diisi!') + .min(0, 'Harga produk tidak boleh kurang dari 0!'), + selling_price: Yup.number() + .required('Harga jual wajib diisi!') + .typeError('Harga jual wajib diisi!') + .min(0, 'Harga jual tidak boleh kurang dari 0!'), + tax: Yup.number() + .required('Pajak wajib diisi!') + .typeError('Pajak wajib diisi!') + .min(0, 'Pajak tidak boleh kurang dari 0!') + .max(100, 'Pajak tidak boleh lebih dari 100%!'), + expiry_period: Yup.number() + .required('Periode kadaluarsa wajib diisi!') + .typeError('Periode kadaluarsa wajib diisi!') + .min(0, 'Periode kadaluarsa tidak boleh kurang dari 0!'), + supplier: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + supplier_ids: Yup.array() + .of(Yup.number().typeError('Supplier tidak valid!')) + .min(1, 'Minimal harus ada 1 supplier!') + .required('Supplier wajib diisi!'), + flags: Yup.array() + .of(Yup.string()) + .min(1, 'Minimal harus ada 1 flag!') + .required('Flag wajib diisi!'), +}); + +export const UpdateProductFormSchema = ProductFormSchema; + +export type ProductFormValues = Yup.InferType; + diff --git a/src/config/constant.ts b/src/config/constant.ts index 55eed0b3..1fbef81f 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -107,3 +107,16 @@ export const WAREHOUSE_TYPE_OPTIONS = [ value: 'KANDANG', }, ]; + +export const PRODUCT_FLAG_OPTIONS = [ + { label: 'DOC', value: 'DOC' }, + { label: 'PAKAN', value: 'PAKAN' }, + { label: 'PRE-STARTER', value: 'PRE-STARTER' }, + { label: 'STARTER', value: 'STARTER' }, + { label: 'FINISHER', value: 'FINISHER' }, + { label: 'OVK', value: 'OVK' }, + { label: 'OBAT', value: 'OBAT' }, + { label: 'VITAMIN', value: 'VITAMIN' }, + { label: 'KIMIA', value: 'KIMIA' }, +]; + diff --git a/src/services/api/master-data.ts b/src/services/api/master-data.ts index 53ea26c6..e1a4db17 100644 --- a/src/services/api/master-data.ts +++ b/src/services/api/master-data.ts @@ -29,6 +29,11 @@ import { ProductCategory, UpdateProductCategoryPayload, } from '@/types/api/master-data/product-category'; +import { + CreateProductPayload, + Product, + UpdateProductPayload, +} from '@/types/api/master-data/product'; export const UomApi = new BaseApiService< Uom, @@ -64,4 +69,10 @@ export const ProductCategoryApi = new BaseApiService< ProductCategory, CreateProductCategoryPayload, UpdateProductCategoryPayload ->('/master-data/product-categories'); \ No newline at end of file +>('/master-data/product-categories'); + +export const ProductApi = new BaseApiService< + Product, + CreateProductPayload, + UpdateProductPayload +>('/master-data/products'); diff --git a/src/types/api/master-data/product.d.ts b/src/types/api/master-data/product.d.ts new file mode 100644 index 00000000..d4039750 --- /dev/null +++ b/src/types/api/master-data/product.d.ts @@ -0,0 +1,37 @@ +import { BaseMetadata } from '@/types/api/api-general'; +import { Uom } from '@/types/api/master-data/uom'; +import { ProductCategory } from '@/types/api/master-data/product-category'; +import { Supplier } from '@/types/api/master-data/supplier'; + +export type BaseProduct = { + id: number; + name: string; + brand: string; + sku: string; + product_price: number; + selling_price?: number; + tax?: number; + expiry_period: number; + uom: Uom; + product_category: ProductCategory; + suppliers: Supplier[]; + flags: string[]; +}; + +export type Product = BaseMetadata & BaseProduct; + +export type CreateProductPayload = { + name: string; + brand: string; + sku: string; + uom_id: number; + product_category_id: number; + product_price: number; + selling_price: number; + tax: number; + expiry_period: number; + supplier_ids: number[]; + flags: string[]; +}; + +export type UpdateProductPayload = CreateProductPayload; \ No newline at end of file From e1569c607cc4c125f5a83ef04489a06d26d76e53 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 7 Oct 2025 21:22:05 +0700 Subject: [PATCH 129/174] feat(FE-41,43): add Products table with CRUD operations and search functionality --- .../master-data/product/ProductTable.tsx | 350 ++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 src/components/pages/master-data/product/ProductTable.tsx diff --git a/src/components/pages/master-data/product/ProductTable.tsx b/src/components/pages/master-data/product/ProductTable.tsx new file mode 100644 index 00000000..ab256548 --- /dev/null +++ b/src/components/pages/master-data/product/ProductTable.tsx @@ -0,0 +1,350 @@ +'use client'; + +import { ChangeEventHandler, useCallback, useEffect, useState } from 'react'; +import useSWR from 'swr'; +import { + CellContext, + ColumnDef, + ColumnSort, + SortingState, +} from '@tanstack/react-table'; +import toast from 'react-hot-toast'; + +import { Icon } from '@iconify/react'; +import Table from '@/components/Table'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import Button from '@/components/Button'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import RowCollapseOptions from '@/components/table/RowCollapseOptions'; + +import { Product } from '@/types/api/master-data/product'; +import { ProductApi } from '@/services/api/master-data'; +import { cn } from '@/lib/helper'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { ROWS_OPTIONS } from '@/config/constant'; + +const RowOptionsMenu = ({ + type = 'dropdown', + props, + deleteClickHandler, +}: { + type: 'dropdown' | 'collapse'; + props: CellContext; + deleteClickHandler: () => void; +}) => ( +
+ + + +
+); + +const ProductsTable = () => { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { + search: '', + nameSort: '', + skuSort: '', + brandSort: '', + categorySort: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + nameSort: 'sort_name', + skuSort: 'sort_sku', + brandSort: 'sort_brand', + categorySort: 'sort_category', + }, + }); + + const { + data: products, + isLoading, + mutate: refreshProducts, + } = useSWR( + `${ProductApi.basePath}${getTableFilterQueryString()}`, + ProductApi.getAllFetcher + ); + + const deleteModal = useModal(); + const [selectedProduct, setSelectedProduct] = useState(undefined); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const [sorting, setSorting] = useState([]); + + const productsColumns: ColumnDef[] = [ + { + header: '#', + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, + }, + { + accessorKey: 'name', + header: 'Nama', + }, + { + accessorKey: 'sku', + header: 'SKU', + }, + { + accessorKey: 'brand', + header: 'Merek', + }, + { + accessorKey: 'product_category', + header: 'Kategori', + cell: (props) => props.row.original.product_category?.name ?? '-', + }, + { + accessorKey: 'uom', + header: 'Satuan', + cell: (props) => props.row.original.uom?.name ?? '-', + }, + { + accessorKey: 'product_price', + header: 'Harga Produk', + cell: (props) => props.row.original.product_price?.toLocaleString() ?? '-', + }, + { + accessorKey: 'selling_price', + header: 'Harga Jual', + cell: (props) => props.row.original.selling_price?.toLocaleString() ?? '-', + }, + { + accessorKey: 'tax', + header: 'Pajak (%)', + cell: (props) => props.row.original.tax ?? '-', + }, + { + accessorKey: 'expiry_period', + header: 'Kadaluarsa (hari)', + cell: (props) => props.row.original.expiry_period ?? '-', + }, + { + accessorKey: 'suppliers', + header: 'Supplier', + cell: (props) => + props.row.original.suppliers?.map((s) => s.name).join(', ') || '-', + }, + { + accessorKey: 'flags', + header: 'Flags', + cell: (props) => + props.row.original.flags?.length + ? props.row.original.flags.join(', ') + : '-', + }, + { + header: 'Aksi', + cell: (props) => { + const currentPageSize = props.table.getPaginationRowModel().rows.length; + const currentPageRows = props.table.getPaginationRowModel().flatRows; + const currentRowRelativeIndex = + currentPageRows.findIndex((r) => r.id === props.row.id) + 1; + + const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; + + const deleteClickHandler = () => { + setSelectedProduct(props.row.original); + deleteModal.openModal(); + }; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]; + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + await ProductApi.delete(selectedProduct?.id as number); + refreshProducts(); + deleteModal.closeModal(); + toast.success('Successfully delete Product!'); + setIsDeleteLoading(false); + }; + + const searchChangeHandler: ChangeEventHandler = (e) => { + updateFilter('search', e.target.value); + }; + + const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + setPageSize(newVal.value as number); + }; + + const updateSortingFilter = useCallback( + ( + sortName: Exclude, + sortFilter: ColumnSort | undefined + ) => { + if (!sortFilter) { + updateFilter(sortName, ''); + } else { + updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc'); + } + }, + [updateFilter] + ); + + useEffect(() => { + const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name'); + const skuSortFilter = sorting.find((sortItem) => sortItem.id === 'sku'); + const brandSortFilter = sorting.find((sortItem) => sortItem.id === 'brand'); + const categorySortFilter = sorting.find((sortItem) => sortItem.id === 'product_category'); + + updateSortingFilter('nameSort', nameSortFilter); + updateSortingFilter('skuSort', skuSortFilter); + updateSortingFilter('brandSort', brandSortFilter); + updateSortingFilter('categorySort', categorySortFilter); + }, [sorting]); + + return ( + <> +
+
+
+
+ +
+ +
+
+ +
+
+ + data={isResponseSuccess(products) ? products?.data : []} + columns={productsColumns} + pageSize={tableFilterState.pageSize} + page={isResponseSuccess(products) ? products?.meta?.page : 0} + totalItems={ + isResponseSuccess(products) ? products?.meta?.total_results : 0 + } + onPageChange={setPage} + isLoading={isLoading} + sorting={sorting} + setSorting={setSorting} + className={{ + containerClassName: cn({ + 'mb-20': + isResponseSuccess(products) && products?.data?.length === 0, + }), + tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableClassName: 'font-inter w-full table-auto min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', + bodyRowClassName: 'border-b border-b-gray-200', + bodyColumnClassName: + 'px-6 py-3 last:flex last:flex-row last:justify-end', + }} + /> +
+ + + ); +}; + +export default ProductsTable; \ No newline at end of file From 250c42a04b43c4e3c614792a3795cab063f65a58 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 7 Oct 2025 21:23:12 +0700 Subject: [PATCH 130/174] feat(FE-40,41,42): add Product management pages with form handling and table display --- src/app/master-data/product/add/page.tsx | 11 + .../master-data/product/detail/edit/page.tsx | 45 ++ src/app/master-data/product/detail/page.tsx | 45 ++ src/app/master-data/product/page.tsx | 11 + .../master-data/product/form/ProductForm.tsx | 438 ++++++++++++++++++ 5 files changed, 550 insertions(+) create mode 100644 src/app/master-data/product/add/page.tsx create mode 100644 src/app/master-data/product/detail/edit/page.tsx create mode 100644 src/app/master-data/product/detail/page.tsx create mode 100644 src/app/master-data/product/page.tsx create mode 100644 src/components/pages/master-data/product/form/ProductForm.tsx diff --git a/src/app/master-data/product/add/page.tsx b/src/app/master-data/product/add/page.tsx new file mode 100644 index 00000000..7cc995b6 --- /dev/null +++ b/src/app/master-data/product/add/page.tsx @@ -0,0 +1,11 @@ +import ProductForm from '@/components/pages/master-data/product/form/ProductForm'; + +const AddProduct = () => { + return ( +
+ +
+ ); +}; + +export default AddProduct; \ No newline at end of file diff --git a/src/app/master-data/product/detail/edit/page.tsx b/src/app/master-data/product/detail/edit/page.tsx new file mode 100644 index 00000000..96cfdc42 --- /dev/null +++ b/src/app/master-data/product/detail/edit/page.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import ProductForm from '@/components/pages/master-data/product/form/ProductForm'; +import { ProductApi } from '@/services/api/master-data'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const ProductEdit = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const productId = searchParams.get('productId'); + + const { data: product, isLoading } = useSWR( + productId, + (id: number) => ProductApi.getSingle(id) + ); + + if (!productId) { + router.back(); + return ( +
+ +
+ ); + } + + if (!isLoading && (!product || isResponseError(product))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoading && } + {!isLoading && isResponseSuccess(product) && ( + + )} +
+ ); +}; + +export default ProductEdit; \ No newline at end of file diff --git a/src/app/master-data/product/detail/page.tsx b/src/app/master-data/product/detail/page.tsx new file mode 100644 index 00000000..916a44d0 --- /dev/null +++ b/src/app/master-data/product/detail/page.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import ProductForm from '@/components/pages/master-data/product/form/ProductForm'; +import { ProductApi } from '@/services/api/master-data'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const ProductDetail = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const productId = searchParams.get('productId'); + + const { data: product, isLoading } = useSWR( + productId, + (id: number) => ProductApi.getSingle(id) + ); + + if (!productId) { + router.back(); + return ( +
+ +
+ ); + } + + if (!isLoading && (!product || isResponseError(product))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoading && } + {!isLoading && isResponseSuccess(product) && ( + + )} +
+ ); +}; + +export default ProductDetail; \ No newline at end of file diff --git a/src/app/master-data/product/page.tsx b/src/app/master-data/product/page.tsx new file mode 100644 index 00000000..6014aeb9 --- /dev/null +++ b/src/app/master-data/product/page.tsx @@ -0,0 +1,11 @@ +import ProductsTable from "@/components/pages/master-data/product/ProductTable"; + +const Product = () => { + return ( +
+ +
+ ); +}; + +export default Product; \ No newline at end of file diff --git a/src/components/pages/master-data/product/form/ProductForm.tsx b/src/components/pages/master-data/product/form/ProductForm.tsx new file mode 100644 index 00000000..02afbfc9 --- /dev/null +++ b/src/components/pages/master-data/product/form/ProductForm.tsx @@ -0,0 +1,438 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useFormik } from 'formik'; +import { toast } from 'react-hot-toast'; +import useSWR from 'swr'; + +import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; +import TextInput from '@/components/input/TextInput'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; + +import { + ProductFormSchema, + ProductFormValues, + UpdateProductFormSchema, +} from '@/components/pages/master-data/product/form/ProductForm.schema'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { + Product, + CreateProductPayload, + UpdateProductPayload, +} from '@/types/api/master-data/product'; +import { UomApi, ProductCategoryApi, SupplierApi, ProductApi } from '@/services/api/master-data'; +import { cn } from '@/lib/helper'; +import { PRODUCT_FLAG_OPTIONS } from '@/config/constant'; + +interface ProductFormProps { + type?: 'add' | 'edit' | 'detail'; + initialValues?: Product; +} + +const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => { + const router = useRouter(); + const deleteModal = useModal(); + + const [productFormErrorMessage, setProductFormErrorMessage] = useState(''); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const createProductHandler = useCallback( + async (payload: CreateProductPayload) => { + const res = await ProductApi.create(payload); + if (isResponseError(res)) { + setProductFormErrorMessage(res.message); + return; + } + toast.success(res?.message as string); + router.push('/master-data/product'); + }, + [router] + ); + + const updateProductHandler = useCallback( + async (productId: number, payload: UpdateProductPayload) => { + const res = await ProductApi.update(productId, payload); + if (res?.status === 'error') { + setProductFormErrorMessage(res.message); + return; + } + toast.success(res?.message as string); + router.refresh(); + router.push('/master-data/product'); + }, + [router] + ); + + const formikInitialValues = useMemo(() => ({ + name: initialValues?.name ?? '', + brand: initialValues?.brand ?? '', + sku: initialValues?.sku ?? '', + uom: initialValues?.uom + ? { value: initialValues.uom.id, label: initialValues.uom.name } + : null, + uom_id: initialValues?.uom?.id ?? 0, + product_category: initialValues?.product_category + ? { value: initialValues.product_category.id, label: initialValues.product_category.name } + : null, + product_category_id: initialValues?.product_category?.id ?? 0, + product_price: initialValues?.product_price ?? 0, + selling_price: initialValues?.selling_price ?? 0, + tax: initialValues?.tax ?? 0, + expiry_period: initialValues?.expiry_period ?? 0, + supplier: null, // not used for payload, just for UI + supplier_ids: initialValues?.suppliers?.map(s => s.id) ?? [], + flags: initialValues?.flags ?? [], + }), [initialValues]); + + const formik = useFormik({ + initialValues: formikInitialValues, + validationSchema: type === 'edit' ? UpdateProductFormSchema : ProductFormSchema, + onSubmit: async (values) => { + setProductFormErrorMessage(''); + const payload: CreateProductPayload = { + name: values.name, + brand: values.brand, + sku: values.sku, + uom_id: values.uom_id, + product_category_id: values.product_category_id, + product_price: values.product_price, + selling_price: values.selling_price, + tax: values.tax, + expiry_period: values.expiry_period, + supplier_ids: (values.supplier_ids ?? []).filter((id): id is number => typeof id === 'number'), + flags: (values.flags ?? []).filter((f): f is string => typeof f === 'string'), + }; + switch (type) { + case 'add': + await createProductHandler(payload); + break; + case 'edit': + await updateProductHandler(initialValues?.id as number, payload); + break; + } + }, + }); + + const { setValues: formikSetValues } = formik; + + // UOM + const [uomSelectInputValue, setUomSelectInputValue] = useState(''); + const uomsUrl = `${UomApi.basePath}?${new URLSearchParams({ search: uomSelectInputValue ?? '' }).toString()}`; + const { data: uoms, isLoading: isLoadingUoms } = useSWR(uomsUrl, UomApi.getAllFetcher); + const uomOptions = isResponseSuccess(uoms) + ? uoms?.data.map((uom) => ({ value: uom.id, label: uom.name })) + : []; + const uomChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldTouched('uom', true); + formik.setFieldValue('uom', val); + formik.setFieldTouched('uom_id', true); + formik.setFieldValue('uom_id', (val as OptionType)?.value); + }; + + // Product Category + const [categorySelectInputValue, setCategorySelectInputValue] = useState(''); + const categoriesUrl = `${ProductCategoryApi.basePath}?${new URLSearchParams({ search: categorySelectInputValue ?? '' }).toString()}`; + const { data: categories, isLoading: isLoadingCategories } = useSWR(categoriesUrl, ProductCategoryApi.getAllFetcher); + const categoryOptions = isResponseSuccess(categories) + ? categories?.data.map((cat) => ({ value: cat.id, label: cat.name })) + : []; + const categoryChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldTouched('product_category', true); + formik.setFieldValue('product_category', val); + formik.setFieldTouched('product_category_id', true); + formik.setFieldValue('product_category_id', (val as OptionType)?.value); + }; + + // Supplier (multi select) + const [supplierSelectInputValue, setSupplierSelectInputValue] = useState(''); + const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue ?? '' }).toString()}`; + const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR(suppliersUrl, SupplierApi.getAllFetcher); + const supplierOptions = isResponseSuccess(suppliers) + ? suppliers?.data + .filter((sup) => sup.category === 'SAPRONAK') + .map((sup) => ({ value: sup.id, label: sup.name })) + : []; + const supplierChangeHandler = (val: OptionType | OptionType[] | null) => { + const arr = Array.isArray(val) ? val : val ? [val] : []; + formik.setFieldTouched('supplier_ids', true); + formik.setFieldValue('supplier_ids', arr.map((v) => (v as OptionType).value)); + }; + + const deleteProductClickHandler = () => { + deleteModal.openModal(); + }; + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + await ProductApi.delete(initialValues?.id as number); + deleteModal.closeModal(); + toast.success('Successfully delete Product!'); + setIsDeleteLoading(false); + router.push('/master-data/product'); + }; + + useEffect(() => { + formikSetValues(formikInitialValues); + }, [formikSetValues, formikInitialValues]); + + return ( + <> +
+
+ +

+ {type === 'add' && 'Tambah Produk'} + {type === 'edit' && 'Edit Produk'} + {type === 'detail' && 'Detail Produk'} +

+
+
+
+ + + + + + + + + + formik.values.supplier_ids.includes(opt.value))} + onChange={supplierChangeHandler} + options={supplierOptions} + onInputChange={setSupplierSelectInputValue} + isLoading={isLoadingSuppliers} + isError={formik.touched.supplier_ids && Boolean(formik.errors.supplier_ids)} + errorMessage={formik.errors.supplier_ids as string} + isDisabled={type === 'detail'} + isClearable + /> + formik.values.flags.includes(opt.value))} + onChange={val => { + const arr = Array.isArray(val) ? val : val ? [val] : []; + formik.setFieldValue('flags', arr.map((v) => (v as OptionType).value)); + }} + options={PRODUCT_FLAG_OPTIONS} + isError={formik.touched.flags && Boolean(formik.errors.flags)} + errorMessage={formik.errors.flags as string} + isDisabled={type === 'detail'} + isClearable + /> +
+
+ {type !== 'add' && ( +
+ + {type !== 'edit' && ( + + )} +
+ )} + {type !== 'detail' && ( +
+ + +
+ )} +
+ {productFormErrorMessage && ( +
+ + {productFormErrorMessage} +
+ )} +
+
+ {type !== 'add' && ( + + )} + + ); +}; + +export default ProductForm; \ No newline at end of file From f5952f5a3629d5263bbd1cd34a65870af580cfeb Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 8 Oct 2025 09:12:49 +0700 Subject: [PATCH 131/174] feat(FE-43): add Supplier API and data types for supplier management --- src/services/api/master-data.ts | 11 ++++++++ src/types/api/master-data/supplier.d.ts | 34 +++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 src/types/api/master-data/supplier.d.ts diff --git a/src/services/api/master-data.ts b/src/services/api/master-data.ts index e1a4db17..7429b8ef 100644 --- a/src/services/api/master-data.ts +++ b/src/services/api/master-data.ts @@ -34,6 +34,11 @@ import { Product, UpdateProductPayload, } from '@/types/api/master-data/product'; +import { + CreateSupplierPayload, + Supplier, + UpdateSupplierPayload, +} from '@/types/api/master-data/supplier'; export const UomApi = new BaseApiService< Uom, @@ -76,3 +81,9 @@ export const ProductApi = new BaseApiService< CreateProductPayload, UpdateProductPayload >('/master-data/products'); + +export const SupplierApi = new BaseApiService< + Supplier, + CreateSupplierPayload, + UpdateSupplierPayload +>('/master-data/suppliers'); \ No newline at end of file diff --git a/src/types/api/master-data/supplier.d.ts b/src/types/api/master-data/supplier.d.ts new file mode 100644 index 00000000..f2cfdb11 --- /dev/null +++ b/src/types/api/master-data/supplier.d.ts @@ -0,0 +1,34 @@ +import { BaseMetadata } from '@/types/api/api-general'; + +export type BaseSupplier = { + id: number; + name: string; + alias: string; + category: string; + pic: string; + type: string; + phone: string; + email: string; + address: string; + account_number: string; + balance: number; + due_date: number; +}; + +export type Supplier = BaseMetadata & BaseSupplier; + +export type CreateSupplierPayload = { + name: string; + alias: string; + category: string; + pic: string; + type: string; + phone: string; + email: string; + address: string; + account_number: string; + balance: number; + due_date: number; +}; + +export type UpdateSupplierPayload = CreateSupplierPayload; \ No newline at end of file From 396ebe50014eb20fd0fc3900ff832b348af35ed0 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 8 Oct 2025 09:55:19 +0700 Subject: [PATCH 132/174] feat(FE-43): add 'Code' column to Product Category table --- .../master-data/product-category/ProductCategoryTable.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/pages/master-data/product-category/ProductCategoryTable.tsx b/src/components/pages/master-data/product-category/ProductCategoryTable.tsx index f5226387..f8413ab6 100644 --- a/src/components/pages/master-data/product-category/ProductCategoryTable.tsx +++ b/src/components/pages/master-data/product-category/ProductCategoryTable.tsx @@ -109,6 +109,10 @@ const ProductCategoryTable = () => { props.row.index + 1, }, + { + accessorKey: 'code', + header: 'Code', + }, { accessorKey: 'name', header: 'Nama', From 3e7da624aa3d045b1a76e4740dceb4bc0ee6287a Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 8 Oct 2025 13:47:56 +0700 Subject: [PATCH 133/174] feat: add .prettierrc.json config --- .prettierrc.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .prettierrc.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..250df482 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,15 @@ +{ + "singleQuote": true, + "jsxSingleQuote": true, + "endOfLine": "lf", + "arrowParens": "always", + "bracketSpacing": true, + "embeddedLanguageFormatting": "auto", + "htmlWhitespaceSensitivity": "css", + "printWidth": 80, + "proseWrap": "preserve", + "quoteProps": "as-needed", + "semi": true, + "tabWidth": 2, + "trailingComma": "es5" +} From 8461667ca216f7d1d23ab8878912966b046db214 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 8 Oct 2025 14:56:52 +0700 Subject: [PATCH 134/174] chore(FE-41): delete nonstock api helper function file --- src/services/api/master-data/nonstock.ts | 87 ------------------------ 1 file changed, 87 deletions(-) delete mode 100644 src/services/api/master-data/nonstock.ts diff --git a/src/services/api/master-data/nonstock.ts b/src/services/api/master-data/nonstock.ts deleted file mode 100644 index 7340e37b..00000000 --- a/src/services/api/master-data/nonstock.ts +++ /dev/null @@ -1,87 +0,0 @@ -import axios from 'axios'; -import { httpClient } from '@/services/http/client'; - -import { - CreateNonstockPayload, - DeleteNonstockResponse, - NonstockResponse, - UpdateNonstockPayload, -} from '@/types/api/master-data/nonstock'; - -export const getNonstock = async (nonstockId: number) => { - try { - const getNonstockRes = await httpClient( - `/master-data/nonstocks/${nonstockId}` - ); - - return getNonstockRes; - } catch (error: unknown) { - if (axios.isAxiosError(error)) { - return error.response?.data; - } - - return undefined; - } -}; - -export const createNonstock = async (payload: CreateNonstockPayload) => { - try { - const createNonstockRes = await httpClient( - '/master-data/nonstocks', - { - method: 'POST', - body: payload, - } - ); - - return createNonstockRes; - } catch (error: unknown) { - if (axios.isAxiosError(error)) { - return error.response?.data; - } - - return undefined; - } -}; - -export const updateNonstock = async ( - nonstockId: number, - payload: UpdateNonstockPayload -) => { - try { - const updateNonstockRes = await httpClient( - `/master-data/nonstocks/${nonstockId}`, - { - method: 'PATCH', - body: payload, - } - ); - - return updateNonstockRes; - } catch (error: unknown) { - if (axios.isAxiosError(error)) { - return error.response?.data; - } - - return undefined; - } -}; - -export const deleteNonstock = async (nonstockId: number) => { - try { - const deleteNonstockRes = await httpClient( - `/master-data/nonstocks/${nonstockId}`, - { - method: 'DELETE', - } - ); - - return deleteNonstockRes; - } catch (error) { - if (axios.isAxiosError(error)) { - return error.response?.data; - } - - return undefined; - } -}; From 0e49e29002c4947ffb4edef52438d949219e7124 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 8 Oct 2025 14:58:21 +0700 Subject: [PATCH 135/174] feat(FE-42): create SUPPLIER_FLAG_OPTIONS constant --- src/config/constant.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/config/constant.ts b/src/config/constant.ts index 1fbef81f..ab58df5b 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -120,3 +120,6 @@ export const PRODUCT_FLAG_OPTIONS = [ { label: 'KIMIA', value: 'KIMIA' }, ]; +export const SUPPLIER_FLAG_OPTIONS = [ + { label: 'EKSPEDISI', value: 'EKSPEDISI' }, +]; From 143d640a1e029e097f66ad83fe6a6dc47034aaaf Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 8 Oct 2025 14:58:54 +0700 Subject: [PATCH 136/174] chore(FE-41): refactor nonstock type --- src/types/api/master-data/nonstock.d.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/types/api/master-data/nonstock.d.ts b/src/types/api/master-data/nonstock.d.ts index 682f7852..e4e79d8e 100644 --- a/src/types/api/master-data/nonstock.d.ts +++ b/src/types/api/master-data/nonstock.d.ts @@ -1,18 +1,23 @@ -import { BaseApiResponse } from '@/types/api/api-general'; +import { BaseApiResponse, BaseMetadata, flags } from '@/types/api/api-general'; +import { BaseUom } from '@/types/api/master-data/uom'; +import { BaseSupplier } from '@/types/api/master-data/supplier'; -export type Nonstock = { +export type BaseNonstock = { id: number; name: string; + uom_id: number; + uom: BaseUom; + suppliers: BaseSupplier[]; + flags: flags[]; }; +export type Nonstock = BaseMetadata & BaseNonstock; + export type CreateNonstockPayload = { name: string; + uom_id: number; + supplier_ids: number[]; + flags: flags[]; }; export type UpdateNonstockPayload = CreateNonstockPayload; - -export type NonstockResponse = BaseApiResponse; - -export type NonstocksResponse = BaseApiResponse; - -export type DeleteNonstockResponse = BaseApiResponse; From f3d0e12bcded069380bcdc34c59f17b8b73defdf Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 8 Oct 2025 14:59:14 +0700 Subject: [PATCH 137/174] feat(FE-42): create flags type --- src/types/api/api-general.d.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/types/api/api-general.d.ts b/src/types/api/api-general.d.ts index 8a4c4de7..6a3fc6be 100644 --- a/src/types/api/api-general.d.ts +++ b/src/types/api/api-general.d.ts @@ -53,3 +53,16 @@ export type BaseMetadata = { export type Override = Omit & Overrides; + +export type flags = + | 'PAKAN' + | 'OBAT' + | 'VITAMIN' + | 'KIMIA' + | 'EKSPEDISI' + | 'IS_ACTIVE' + | 'DOC' + | 'PRE-STARTER' + | 'STARTER' + | 'FINISHER' + | 'OVK'; From d24d50474d1ab4590e7ce7607091ad7cc0b23a1e Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 8 Oct 2025 14:59:39 +0700 Subject: [PATCH 138/174] feat(FE-41): create Nonstock API service --- src/services/api/master-data.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/services/api/master-data.ts b/src/services/api/master-data.ts index 7429b8ef..41975bd8 100644 --- a/src/services/api/master-data.ts +++ b/src/services/api/master-data.ts @@ -39,6 +39,11 @@ import { Supplier, UpdateSupplierPayload, } from '@/types/api/master-data/supplier'; +import { + CreateNonstockPayload, + Nonstock, + UpdateNonstockPayload, +} from '@/types/api/master-data/nonstock'; export const UomApi = new BaseApiService< Uom, @@ -86,4 +91,10 @@ export const SupplierApi = new BaseApiService< Supplier, CreateSupplierPayload, UpdateSupplierPayload ->('/master-data/suppliers'); \ No newline at end of file +>('/master-data/suppliers'); + +export const NonstockApi = new BaseApiService< + Nonstock, + CreateNonstockPayload, + UpdateNonstockPayload +>('/master-data/nonstocks'); From 96fea80f6258c38b149c83d2d49c593d88156b4b Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 8 Oct 2025 15:00:06 +0700 Subject: [PATCH 139/174] feat(FE-40,41): create NonstockForm component --- .../nonstock/form/NonstockForm.tsx | 361 ++++++++++++++---- 1 file changed, 287 insertions(+), 74 deletions(-) diff --git a/src/components/pages/master-data/nonstock/form/NonstockForm.tsx b/src/components/pages/master-data/nonstock/form/NonstockForm.tsx index 33dcba54..7a67c9a7 100644 --- a/src/components/pages/master-data/nonstock/form/NonstockForm.tsx +++ b/src/components/pages/master-data/nonstock/form/NonstockForm.tsx @@ -3,26 +3,31 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useFormik } from 'formik'; +import { toast } from 'react-hot-toast'; +import useSWR from 'swr'; import { Icon } from '@iconify/react'; import Button from '@/components/Button'; import TextInput from '@/components/input/TextInput'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; import { NonstockFormSchema, NonstockFormValues, UpdateNonstockFormSchema, } from '@/components/pages/master-data/nonstock/form/NonstockForm.schema'; -import { isResponseError } from '@/lib/api-helper'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { - CreateNonstockPayload, Nonstock, + CreateNonstockPayload, UpdateNonstockPayload, } from '@/types/api/master-data/nonstock'; -import { - createNonstock, - updateNonstock, -} from '@/services/api/master-data/nonstock'; +import { NonstockApi, SupplierApi, UomApi } from '@/services/api/master-data'; +import { cn } from '@/lib/helper'; +import { flags } from '@/types/api/api-general'; +import { SUPPLIER_FLAG_OPTIONS } from '@/config/constant'; interface NonstockFormProps { type?: 'add' | 'edit' | 'detail'; @@ -31,19 +36,21 @@ interface NonstockFormProps { const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => { const router = useRouter(); + const deleteModal = useModal(); const [nonstockFormErrorMessage, setNonstockFormErrorMessage] = useState(''); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); const createNonstockHandler = useCallback( async (payload: CreateNonstockPayload) => { - const createNonstockRes = await createNonstock(payload); + const createNonstockRes = await NonstockApi.create(payload); if (isResponseError(createNonstockRes)) { setNonstockFormErrorMessage(createNonstockRes.message); return; } - alert(createNonstockRes?.message); + toast.success(createNonstockRes?.message as string); router.push('/master-data/nonstock'); }, [router] @@ -51,14 +58,14 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => { const updateNonstockHandler = useCallback( async (nonstockId: number, payload: UpdateNonstockPayload) => { - const updateNonstockRes = await updateNonstock(nonstockId, payload); + const updateNonstockRes = await NonstockApi.update(nonstockId, payload); if (updateNonstockRes?.status === 'error') { setNonstockFormErrorMessage(updateNonstockRes.message); return; } - alert(updateNonstockRes?.message); + toast.success(updateNonstockRes?.message as string); router.refresh(); router.push('/master-data/nonstock'); }, @@ -68,6 +75,22 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => { const formikInitialValues = useMemo(() => { return { name: initialValues?.name ?? '', + uomId: initialValues?.uom_id ?? 0, + uom: initialValues?.uom + ? { + value: initialValues?.uom.id, + label: initialValues?.uom.name, + } + : null, + supplierIds: + initialValues?.suppliers.map((supplier) => supplier.id) ?? [], + suppliers: + initialValues?.suppliers.map((supplier) => ({ + value: supplier.id, + label: supplier.name, + })) ?? [], + + flags: initialValues?.flags ?? [], }; }, [initialValues]); @@ -80,6 +103,9 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => { const nonstockPayload: CreateNonstockPayload = { name: values.name, + uom_id: values.uomId, + supplier_ids: values.supplierIds as number[], + flags: values.flags as flags[], }; switch (type) { @@ -97,81 +123,268 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => { }, }); + const { setValues: formikSetValues } = formik; + + // UOM + const [uomSelectInputValue, setUomSelectInputValue] = useState(''); + + const uomsUrl = `${UomApi.basePath}?${new URLSearchParams({ + search: uomSelectInputValue ?? '', + }).toString()}`; + + const { data: uoms, isLoading: isLoadingUoms } = useSWR( + uomsUrl, + UomApi.getAllFetcher + ); + + const uomOptions = isResponseSuccess(uoms) + ? uoms?.data.map((uom) => ({ + value: uom.id, + label: uom.name, + })) + : []; + + const uomChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldTouched('uom', true); + formik.setFieldValue('uom', val); + + formik.setFieldTouched('uomId', true); + formik.setFieldValue('uomId', (val as OptionType)?.value); + }; + + // supplier + const [supplierSelectInputValue, setSupplierSelectInputValue] = useState(''); + + const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ + search: supplierSelectInputValue ?? '', + }).toString()}`; + + const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR( + suppliersUrl, + SupplierApi.getAllFetcher + ); + + const supplierOptions = isResponseSuccess(suppliers) + ? suppliers?.data + .filter((sup) => sup.category === 'BOP') + .map((supplier) => ({ + value: supplier.id, + label: supplier.name, + })) + : []; + + const supplierChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldTouched('suppliers', true); + formik.setFieldValue('suppliers', val); + + const supplierIds = (val as OptionType[]).map( + (supplier) => supplier.value as number + ); + + formik.setFieldTouched('supplierIds', true); + formik.setFieldValue('supplierIds', supplierIds); + }; + + const deleteNonstockClickHandler = () => { + deleteModal.openModal(); + }; + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await NonstockApi.delete(initialValues?.id as number); + + deleteModal.closeModal(); + toast.success('Successfully delete Nonstock!'); + setIsDeleteLoading(false); + router.push('/master-data/nonstock'); + }; + + const flagsChangeHandler = (val: OptionType | OptionType[] | null) => { + const formattedFlags = (val as OptionType[]).map( + (flag) => flag.value as string + ); + + formik.setFieldValue('flags', formattedFlags); + }; + useEffect(() => { - formik.setValues(formikInitialValues); - }, [formikInitialValues]); + formikSetValues(formikInitialValues); + }, [formikSetValues, formikInitialValues]); return ( -
-
- + +

+ {type === 'add' && 'Tambah Nonstock'} + {type === 'edit' && 'Edit Nonstock'} + {type === 'detail' && 'Detail Nonstock'} +

+
+ +
- - Kembali - +
+ -

- {type === 'add' && 'Tambah Non Stock'} - {type === 'edit' && 'Edit Non Stock'} - {type === 'detail' && 'Detail Non Stock'} -

- + - -
- -
+ - {type !== 'detail' && ( - <> -
- + + formik.values.flags?.includes(opt.value) + )} + onChange={flagsChangeHandler} + options={SUPPLIER_FLAG_OPTIONS} + isError={formik.touched.flags && Boolean(formik.errors.flags)} + errorMessage={formik.errors.flags as string} + isDisabled={type === 'detail'} + isClearable + /> +
- -
+
+ {type !== 'add' && ( +
+ - {nonstockFormErrorMessage && ( -
- - {nonstockFormErrorMessage} + {type !== 'edit' && ( + + )}
)} - - )} - -
+ + {type !== 'detail' && ( +
+ + + +
+ )} +
+ + {nonstockFormErrorMessage && ( +
+ + {nonstockFormErrorMessage} +
+ )} + + + + {type !== 'add' && ( + + )} + ); }; From c53f91ec3f282469a869b4d1557d3e1943da3cac Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 8 Oct 2025 15:00:30 +0700 Subject: [PATCH 140/174] feat(FE-43): create NonstocksTable component --- .../master-data/nonstock/NonstocksTable.tsx | 330 +++++++++++------- 1 file changed, 208 insertions(+), 122 deletions(-) diff --git a/src/components/pages/master-data/nonstock/NonstocksTable.tsx b/src/components/pages/master-data/nonstock/NonstocksTable.tsx index 4a2dbc5b..462b3488 100644 --- a/src/components/pages/master-data/nonstock/NonstocksTable.tsx +++ b/src/components/pages/master-data/nonstock/NonstocksTable.tsx @@ -1,20 +1,31 @@ 'use client'; -import { ChangeEventHandler, useState } from 'react'; +import { ChangeEventHandler, useCallback, useEffect, useState } from 'react'; import useSWR from 'swr'; -import { CellContext, ColumnDef } from '@tanstack/react-table'; +import { + CellContext, + ColumnDef, + ColumnSort, + SortingState, +} from '@tanstack/react-table'; +import toast from 'react-hot-toast'; import { Icon } from '@iconify/react'; import Table from '@/components/Table'; -import TextInput from '@/components/input/TextInput'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import Button from '@/components/Button'; -import Collapse from '@/components/Collapse'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import RowCollapseOptions from '@/components/table/RowCollapseOptions'; -import { httpClientFetcher } from '@/services/http/client'; -import { Nonstock, NonstocksResponse } from '@/types/api/master-data/nonstock'; +import { Nonstock } from '@/types/api/master-data/nonstock'; +import { NonstockApi } from '@/services/api/master-data'; import { cn } from '@/lib/helper'; -import { deleteNonstock } from '@/services/api/master-data/nonstock'; import { isResponseSuccess } from '@/lib/api-helper'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { ROWS_OPTIONS } from '@/config/constant'; const RowOptionsMenu = ({ type = 'dropdown', @@ -23,7 +34,7 @@ const RowOptionsMenu = ({ }: { type: 'dropdown' | 'collapse'; props: CellContext; - deleteClickHandler: () => Promise; + deleteClickHandler: () => void; }) => { return (
; - isLast2Rows: boolean; - deleteClickHandler: () => Promise; -}) => { - return ( -
- - - -
- ); -}; - -const RowCollapseOptions = ({ - props, - deleteClickHandler, -}: { - props: CellContext; - deleteClickHandler: () => Promise; -}) => { - return ( - - - - } - className='w-fit' - titleClassName='p-0! justify-self-end' - > - - - ); -}; - const NonstocksTable = () => { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { search: '', nameSort: '', locationSort: '', picSort: '' }, + paramMap: { + page: 'page', + pageSize: 'limit', + nameSort: 'sort_name', + locationSort: 'sort_location', + picSort: ' sort_pic', + }, + }); + const { data: nonstocks, isLoading, mutate: refreshNonstocks, - } = useSWR('/master-data/nonstocks', httpClientFetcher); + } = useSWR( + `${NonstockApi.basePath}${getTableFilterQueryString()}`, + NonstockApi.getAllFetcher + ); - const [searchValue, setSearchValue] = useState(''); + const deleteModal = useModal(); + + const [selectedNonstock, setSelectedNonstock] = useState< + Nonstock | undefined + >(undefined); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const [sorting, setSorting] = useState([]); const nonstocksColumns: ColumnDef[] = [ { header: '#', - cell: (props) => props.row.index + 1, + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, }, { - header: 'Nama', accessorKey: 'name', + header: 'Nama', + }, + { + accessorKey: 'uom', + header: 'UOM', + cell: (props) => props.row.original.uom.name, + }, + { + accessorKey: 'suppliers', + header: 'Supplier', + cell: (props) => { + const supplierNames = props.row.original.suppliers.map( + (supplier) => supplier.name + ); + + return supplierNames.join(', ') || '-'; + }, + }, + { + accessorKey: 'flags', + header: 'Flag', + cell: (props) => props.row.original.flags?.join(', ') || '-', }, { header: 'Aksi', @@ -157,33 +164,31 @@ const NonstocksTable = () => { const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; - const deleteClickHandler = async () => { - const confirmation = confirm( - 'Apakah anda yakin untuk menghapus non stock ini?' - ); - - if (confirmation) { - await deleteNonstock(props.row.original.id); - refreshNonstocks(); - alert('Nonstock berhasil dihapus!'); - } + const deleteClickHandler = () => { + setSelectedNonstock(props.row.original); + deleteModal.openModal(); }; return ( <> {currentPageSize > 2 && ( - + + + )} {currentPageSize <= 2 && ( - + + + )} ); @@ -191,52 +196,133 @@ const NonstocksTable = () => { }, ]; - const searchChangeHandler: ChangeEventHandler = (e) => { - setSearchValue(e.target.value); + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await NonstockApi.delete(selectedNonstock?.id as number); + refreshNonstocks(); + + deleteModal.closeModal(); + toast.success('Successfully delete Nonstock!'); + setIsDeleteLoading(false); }; + const searchChangeHandler: ChangeEventHandler = (e) => { + updateFilter('search', e.target.value); + }; + + const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + + setPageSize(newVal.value as number); + }; + + const updateSortingFilter = useCallback( + ( + sortName: Exclude, + sortFilter: ColumnSort | undefined + ) => { + if (!sortFilter) { + updateFilter(sortName, ''); + } else { + updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc'); + } + }, + [updateFilter] + ); + + // track sorting + useEffect(() => { + const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name'); + const locationSortFilter = sorting.find( + (sortItem) => sortItem.id === 'location' + ); + const picSortFilter = sorting.find((sortItem) => sortItem.id === 'pic'); + + updateSortingFilter('nameSort', nameSortFilter); + updateSortingFilter('locationSort', locationSortFilter); + updateSortingFilter('picSort', picSortFilter); + }, [sorting]); + return ( -
-
-
- + <> +
+
+
+
+ +
+ + +
+ +
+ +
- + data={isResponseSuccess(nonstocks) ? nonstocks?.data : []} + columns={nonstocksColumns} + pageSize={tableFilterState.pageSize} + page={isResponseSuccess(nonstocks) ? nonstocks?.meta?.page : 0} + totalItems={ + isResponseSuccess(nonstocks) ? nonstocks?.meta?.total_results : 0 + } + onPageChange={setPage} + isLoading={isLoading} + sorting={sorting} + setSorting={setSorting} + className={{ + containerClassName: cn({ + 'mb-20': + isResponseSuccess(nonstocks) && nonstocks?.data?.length === 0, + }), + tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableClassName: 'font-inter w-full table-auto min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', + bodyRowClassName: 'border-b border-b-gray-200', + bodyColumnClassName: + 'px-6 py-3 last:flex last:flex-row last:justify-end', + }} />
- - data={isResponseSuccess(nonstocks) ? nonstocks?.data : []} - columns={nonstocksColumns} - pageSize={10} - fuzzySearchValue={searchValue} - onFuzzySearchValueChange={setSearchValue} - isLoading={isLoading} - className={{ - containerClassName: cn({ - 'mb-20': - isResponseSuccess(nonstocks) && nonstocks?.data?.length === 0, - }), - tableWrapperClassName: 'overflow-x-auto min-h-full!', - tableClassName: 'font-inter w-full table-auto min-h-full!', - headerRowClassName: 'border-b border-b-gray-200', - headerColumnClassName: - 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', - bodyRowClassName: 'border-b border-b-gray-200', - bodyColumnClassName: - 'px-6 py-3 last:flex last:flex-row last:justify-end', + -
+ ); }; From b8548b72c95f668eadc64f16bb80571486e1e7b8 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 8 Oct 2025 15:00:41 +0700 Subject: [PATCH 141/174] feat(FE-40,41): create Nonstock form validation schema --- .../nonstock/form/NonstockForm.schema.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/components/pages/master-data/nonstock/form/NonstockForm.schema.ts b/src/components/pages/master-data/nonstock/form/NonstockForm.schema.ts index 50f69c7d..8039ef76 100644 --- a/src/components/pages/master-data/nonstock/form/NonstockForm.schema.ts +++ b/src/components/pages/master-data/nonstock/form/NonstockForm.schema.ts @@ -2,6 +2,22 @@ import * as Yup from 'yup'; export const NonstockFormSchema = Yup.object({ name: Yup.string().required('Nama wajib diisi!'), + + uomId: Yup.number().min(1, 'UOM wajib diisi!').required('UOM wajib diisi!'), + uom: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + + supplierIds: Yup.array().of(Yup.number().min(0, 'Supplier wajib diisi!')), + suppliers: Yup.array().of( + Yup.object({ + value: Yup.number().min(0).required(), + label: Yup.string().required(), + }) + ), + + flags: Yup.array().of(Yup.string()).notRequired(), }); export const UpdateNonstockFormSchema = NonstockFormSchema; From 780c0bb9d0d0d341be8aa4227ad05250f565e3db Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 8 Oct 2025 15:01:45 +0700 Subject: [PATCH 142/174] chore(FE-41): use Nonstock API service --- src/app/master-data/nonstock/detail/edit/page.tsx | 8 ++++---- src/app/master-data/nonstock/detail/page.tsx | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app/master-data/nonstock/detail/edit/page.tsx b/src/app/master-data/nonstock/detail/edit/page.tsx index a0fbb6b3..3b3db5f5 100644 --- a/src/app/master-data/nonstock/detail/edit/page.tsx +++ b/src/app/master-data/nonstock/detail/edit/page.tsx @@ -5,8 +5,8 @@ import useSWR from 'swr'; import NonstockForm from '@/components/pages/master-data/nonstock/form/NonstockForm'; -import { getNonstock } from '@/services/api/master-data/nonstock'; -import { isResponseSuccess } from '@/lib/api-helper'; +import { NonstockApi } from '@/services/api/master-data'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; const NonstockEdit = () => { const router = useRouter(); @@ -16,7 +16,7 @@ const NonstockEdit = () => { const { data: nonstock, isLoading: isLoadingNonstock } = useSWR( nonstockId, - getNonstock + (id: number) => NonstockApi.getSingle(id) ); if (!nonstockId) { @@ -29,7 +29,7 @@ const NonstockEdit = () => { ); } - if (!isLoadingNonstock && !nonstock) { + if (!isLoadingNonstock && (!nonstock || isResponseError(nonstock))) { router.replace('/404'); return; } diff --git a/src/app/master-data/nonstock/detail/page.tsx b/src/app/master-data/nonstock/detail/page.tsx index 375ec999..798a843e 100644 --- a/src/app/master-data/nonstock/detail/page.tsx +++ b/src/app/master-data/nonstock/detail/page.tsx @@ -5,8 +5,8 @@ import useSWR from 'swr'; import NonstockForm from '@/components/pages/master-data/nonstock/form/NonstockForm'; -import { getNonstock } from '@/services/api/master-data/nonstock'; -import { isResponseSuccess } from '@/lib/api-helper'; +import { NonstockApi } from '@/services/api/master-data'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; const NonstockDetail = () => { const router = useRouter(); @@ -16,7 +16,7 @@ const NonstockDetail = () => { const { data: nonstock, isLoading: isLoadingNonstock } = useSWR( nonstockId, - getNonstock + (id: number) => NonstockApi.getSingle(id) ); if (!nonstockId) { @@ -29,7 +29,7 @@ const NonstockDetail = () => { ); } - if (!isLoadingNonstock && !nonstock) { + if (!isLoadingNonstock && (!nonstock || isResponseError(nonstock))) { router.replace('/404'); return; } From 293f457ecbdfbacba08b724e42b20a2c3eb160b1 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 8 Oct 2025 15:35:00 +0700 Subject: [PATCH 143/174] feat(FE-41): create Bank type --- src/types/api/master-data/bank.d.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/types/api/master-data/bank.d.ts diff --git a/src/types/api/master-data/bank.d.ts b/src/types/api/master-data/bank.d.ts new file mode 100644 index 00000000..0b23b446 --- /dev/null +++ b/src/types/api/master-data/bank.d.ts @@ -0,0 +1,20 @@ +import { BaseMetadata } from '@/types/api/api-general'; + +export type BaseBank = { + id: number; + name: string; + alias: string; + owner?: string; + account_number: string; +}; + +export type Bank = BaseMetadata & BaseBank; + +export type CreateBankPayload = { + name: string; + alias: string; + account_number: string; + owner?: string; +}; + +export type UpdateBankPayload = CreateBankPayload; From 10749f06da103124d5b5df6a5f417c612696e6fb Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 8 Oct 2025 15:35:15 +0700 Subject: [PATCH 144/174] feat(FE-41): create Bank API service --- src/services/api/master-data.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/services/api/master-data.ts b/src/services/api/master-data.ts index 41975bd8..8a57e6e6 100644 --- a/src/services/api/master-data.ts +++ b/src/services/api/master-data.ts @@ -44,6 +44,11 @@ import { Nonstock, UpdateNonstockPayload, } from '@/types/api/master-data/nonstock'; +import { + Bank, + CreateBankPayload, + UpdateBankPayload, +} from '@/types/api/master-data/bank'; export const UomApi = new BaseApiService< Uom, @@ -98,3 +103,9 @@ export const NonstockApi = new BaseApiService< CreateNonstockPayload, UpdateNonstockPayload >('/master-data/nonstocks'); + +export const BankApi = new BaseApiService< + Bank, + CreateBankPayload, + UpdateBankPayload +>('/master-data/banks'); From 8c507aa410f494436daab22fb7e7bac306ff6ac1 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 8 Oct 2025 15:35:34 +0700 Subject: [PATCH 145/174] feat(FE-40,41): create BankForm component --- .../pages/master-data/bank/form/BankForm.tsx | 301 ++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 src/components/pages/master-data/bank/form/BankForm.tsx diff --git a/src/components/pages/master-data/bank/form/BankForm.tsx b/src/components/pages/master-data/bank/form/BankForm.tsx new file mode 100644 index 00000000..442d5c76 --- /dev/null +++ b/src/components/pages/master-data/bank/form/BankForm.tsx @@ -0,0 +1,301 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useFormik } from 'formik'; +import { toast } from 'react-hot-toast'; + +import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; +import TextInput from '@/components/input/TextInput'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; + +import { + BankFormSchema, + BankFormValues, + UpdateBankFormSchema, +} from '@/components/pages/master-data/bank/form/BankForm.schema'; +import { isResponseError } from '@/lib/api-helper'; +import { + CreateBankPayload, + Bank, + UpdateBankPayload, +} from '@/types/api/master-data/bank'; +import { BankApi } from '@/services/api/master-data'; +import { cn } from '@/lib/helper'; + +interface BankFormProps { + type?: 'add' | 'edit' | 'detail'; + initialValues?: Bank; +} + +const BankForm = ({ type = 'add', initialValues }: BankFormProps) => { + const router = useRouter(); + const deleteModal = useModal(); + + const [bankFormErrorMessage, setBankFormErrorMessage] = useState(''); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const createBankHandler = useCallback( + async (payload: CreateBankPayload) => { + const createBankRes = await BankApi.create(payload); + + if (isResponseError(createBankRes)) { + setBankFormErrorMessage(createBankRes.message); + return; + } + + toast.success(createBankRes?.message as string); + router.push('/master-data/bank'); + }, + [router] + ); + + const updateBankHandler = useCallback( + async (bankId: number, payload: UpdateBankPayload) => { + const updateBankRes = await BankApi.update(bankId, payload); + + if (updateBankRes?.status === 'error') { + setBankFormErrorMessage(updateBankRes.message); + return; + } + + toast.success(updateBankRes?.message as string); + router.refresh(); + router.push('/master-data/bank'); + }, + [router] + ); + + const formikInitialValues = useMemo(() => { + return { + name: initialValues?.name ?? '', + alias: initialValues?.alias ?? '', + account_number: initialValues?.account_number ?? '', + owner: initialValues?.owner, + }; + }, [initialValues]); + + const formik = useFormik({ + initialValues: formikInitialValues, + validationSchema: type === 'edit' ? UpdateBankFormSchema : BankFormSchema, + onSubmit: async (values) => { + setBankFormErrorMessage(''); + + const bankPayload: CreateBankPayload = { + name: values.name, + alias: values.alias, + account_number: values.account_number.toString(), + owner: values.owner ? values.owner : '', + }; + + switch (type) { + case 'add': + await createBankHandler(bankPayload); + break; + + case 'edit': + await updateBankHandler(initialValues?.id as number, bankPayload); + break; + } + }, + }); + + const { setValues: formikSetValues } = formik; + + const deleteBankClickHandler = () => { + deleteModal.openModal(); + }; + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await BankApi.delete(initialValues?.id as number); + + deleteModal.closeModal(); + toast.success('Successfully delete Bank!'); + setIsDeleteLoading(false); + router.push('/master-data/bank'); + }; + + useEffect(() => { + formikSetValues(formikInitialValues); + }, [formikSetValues, formikInitialValues]); + + return ( + <> +
+
+ + +

+ {type === 'add' && 'Tambah Bank'} + {type === 'edit' && 'Edit Bank'} + {type === 'detail' && 'Detail Bank'} +

+
+ +
+
+ + + + + + + +
+ +
+ {type !== 'add' && ( +
+ + + {type !== 'edit' && ( + + )} +
+ )} + + {type !== 'detail' && ( +
+ + + +
+ )} +
+ + {bankFormErrorMessage && ( +
+ + {bankFormErrorMessage} +
+ )} +
+
+ + {type !== 'add' && ( + + )} + + ); +}; + +export default BankForm; From 16a15fce66c6369099c1d4a4bc6be7976b496de1 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 8 Oct 2025 15:35:56 +0700 Subject: [PATCH 146/174] feat(FE-42): create Bank form validation schema --- .../pages/master-data/bank/form/BankForm.schema.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/components/pages/master-data/bank/form/BankForm.schema.ts diff --git a/src/components/pages/master-data/bank/form/BankForm.schema.ts b/src/components/pages/master-data/bank/form/BankForm.schema.ts new file mode 100644 index 00000000..0bf48c76 --- /dev/null +++ b/src/components/pages/master-data/bank/form/BankForm.schema.ts @@ -0,0 +1,14 @@ +import * as Yup from 'yup'; + +export const BankFormSchema = Yup.object({ + name: Yup.string().required('Nama wajib diisi!'), + alias: Yup.string() + .max(5, 'Maksimal 5 karakter!') + .required('Alias wajib diisi!'), + account_number: Yup.string().required('Rekening wajib diisi!'), + owner: Yup.string(), +}); + +export const UpdateBankFormSchema = BankFormSchema; + +export type BankFormValues = Yup.InferType; From ca42570a4086e349b21a50e0a4b991d08d2e11f1 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 8 Oct 2025 15:36:13 +0700 Subject: [PATCH 147/174] feat(FE-43): create BanksTable component --- .../pages/master-data/bank/BanksTable.tsx | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 src/components/pages/master-data/bank/BanksTable.tsx diff --git a/src/components/pages/master-data/bank/BanksTable.tsx b/src/components/pages/master-data/bank/BanksTable.tsx new file mode 100644 index 00000000..0d084491 --- /dev/null +++ b/src/components/pages/master-data/bank/BanksTable.tsx @@ -0,0 +1,289 @@ +'use client'; + +import { ChangeEventHandler, useEffect, useState } from 'react'; +import useSWR from 'swr'; +import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; +import toast from 'react-hot-toast'; + +import { Icon } from '@iconify/react'; +import Table from '@/components/Table'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import Button from '@/components/Button'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import RowCollapseOptions from '@/components/table/RowCollapseOptions'; + +import { Bank } from '@/types/api/master-data/bank'; +import { BankApi } from '@/services/api/master-data'; +import { cn } from '@/lib/helper'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { ROWS_OPTIONS } from '@/config/constant'; + +const RowOptionsMenu = ({ + type = 'dropdown', + props, + deleteClickHandler, +}: { + type: 'dropdown' | 'collapse'; + props: CellContext; + deleteClickHandler: () => void; +}) => { + return ( +
+ + + + + +
+ ); +}; + +const BanksTable = () => { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { search: '', nameSort: '' }, + paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' }, + }); + + const { + data: banks, + isLoading, + mutate: refreshBanks, + } = useSWR( + `${BankApi.basePath}${getTableFilterQueryString()}`, + BankApi.getAllFetcher + ); + + const deleteModal = useModal(); + + const [selectedBank, setSelectedBank] = useState(undefined); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const [sorting, setSorting] = useState([]); + + const banksColumns: ColumnDef[] = [ + { + header: '#', + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, + }, + { + accessorKey: 'name', + header: 'Nama', + }, + { + accessorKey: 'alias', + header: 'Alias', + }, + { + accessorKey: 'account_number', + header: 'No. Rekening', + }, + { + accessorKey: 'owner', + header: 'Pemilik', + cell: (props) => (props.getValue() ? props.getValue() : '-'), + }, + { + header: 'Aksi', + cell: (props) => { + const currentPageSize = props.table.getPaginationRowModel().rows.length; + const currentPageRows = props.table.getPaginationRowModel().flatRows; + const currentRowRelativeIndex = + currentPageRows.findIndex((r) => r.id === props.row.id) + 1; + + const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; + + const deleteClickHandler = () => { + setSelectedBank(props.row.original); + deleteModal.openModal(); + }; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]; + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await BankApi.delete(selectedBank?.id as number); + refreshBanks(); + + deleteModal.closeModal(); + toast.success('Successfully delete Bank!'); + setIsDeleteLoading(false); + }; + + const searchChangeHandler: ChangeEventHandler = (e) => { + updateFilter('search', e.target.value); + }; + + const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + + setPageSize(newVal.value as number); + }; + + // track sorting + useEffect(() => { + const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name'); + + if (!isNameSorted) { + updateFilter('nameSort', ''); + } else { + updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc'); + } + }, [sorting]); + + return ( + <> +
+
+
+
+ +
+ + +
+ +
+ +
+
+ + + data={isResponseSuccess(banks) ? banks?.data : []} + columns={banksColumns} + pageSize={tableFilterState.pageSize} + page={isResponseSuccess(banks) ? banks?.meta?.page : 0} + totalItems={isResponseSuccess(banks) ? banks?.meta?.total_results : 0} + onPageChange={setPage} + isLoading={isLoading} + sorting={sorting} + setSorting={setSorting} + className={{ + containerClassName: cn({ + 'mb-20': isResponseSuccess(banks) && banks?.data?.length === 0, + }), + tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableClassName: 'font-inter w-full table-auto min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', + bodyRowClassName: 'border-b border-b-gray-200', + bodyColumnClassName: + 'px-6 py-3 last:flex last:flex-row last:justify-end', + }} + /> +
+ + + + ); +}; + +export default BanksTable; From b9015ed673f4c160a5dd2cafd485d9dcc212134f Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 8 Oct 2025 15:37:01 +0700 Subject: [PATCH 148/174] feat(FE-43): create Master Data Bank page --- src/app/master-data/bank/page.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/app/master-data/bank/page.tsx diff --git a/src/app/master-data/bank/page.tsx b/src/app/master-data/bank/page.tsx new file mode 100644 index 00000000..3f913c55 --- /dev/null +++ b/src/app/master-data/bank/page.tsx @@ -0,0 +1,11 @@ +import BanksTable from '@/components/pages/master-data/bank/BanksTable'; + +const Bank = () => { + return ( +
+ +
+ ); +}; + +export default Bank; From 0d5e8383fda172d7d3852501d8d2c2db952fc68a Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 8 Oct 2025 15:37:26 +0700 Subject: [PATCH 149/174] feat(FE-40,41): create Master Data Add Bank page --- src/app/master-data/bank/add/page.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/app/master-data/bank/add/page.tsx diff --git a/src/app/master-data/bank/add/page.tsx b/src/app/master-data/bank/add/page.tsx new file mode 100644 index 00000000..0bb6e532 --- /dev/null +++ b/src/app/master-data/bank/add/page.tsx @@ -0,0 +1,11 @@ +import BankForm from '@/components/pages/master-data/bank/form/BankForm'; + +const AddBank = () => { + return ( +
+ +
+ ); +}; + +export default AddBank; From 372f1698ca45c2f123d613836ec91e2e165c3440 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 8 Oct 2025 15:38:01 +0700 Subject: [PATCH 150/174] feat(FE-40,41): create Master Data Detail Bank page --- src/app/master-data/bank/detail/page.tsx | 47 ++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/app/master-data/bank/detail/page.tsx diff --git a/src/app/master-data/bank/detail/page.tsx b/src/app/master-data/bank/detail/page.tsx new file mode 100644 index 00000000..bd1661d8 --- /dev/null +++ b/src/app/master-data/bank/detail/page.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import BankForm from '@/components/pages/master-data/bank/form/BankForm'; + +import { BankApi } from '@/services/api/master-data'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const BankDetail = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const bankId = searchParams.get('bankId'); + + const { data: bank, isLoading: isLoadingBank } = useSWR( + bankId, + (id: number) => BankApi.getSingle(id) + ); + + if (!bankId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingBank && (!bank || isResponseError(bank))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingBank && } + {!isLoadingBank && isResponseSuccess(bank) && ( + + )} +
+ ); +}; + +export default BankDetail; From 1d7f10050756a7bfee025a8cc7430a5eff21f7b8 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 8 Oct 2025 15:38:10 +0700 Subject: [PATCH 151/174] feat(FE-40,41): create Master Data Edit Bank page --- src/app/master-data/bank/detail/edit/page.tsx | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/app/master-data/bank/detail/edit/page.tsx diff --git a/src/app/master-data/bank/detail/edit/page.tsx b/src/app/master-data/bank/detail/edit/page.tsx new file mode 100644 index 00000000..a0939af9 --- /dev/null +++ b/src/app/master-data/bank/detail/edit/page.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import BankForm from '@/components/pages/master-data/bank/form/BankForm'; + +import { BankApi } from '@/services/api/master-data'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const BankEdit = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const bankId = searchParams.get('bankId'); + + const { data: bank, isLoading: isLoadingBank } = useSWR( + bankId, + (id: number) => BankApi.getSingle(id) + ); + + if (!bankId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingBank && (!bank || isResponseError(bank))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingBank && } + {!isLoadingBank && isResponseSuccess(bank) && ( + + )} +
+ ); +}; + +export default BankEdit; From 21b93963230ae90e5a8b489e465951152aead512 Mon Sep 17 00:00:00 2001 From: sweetpotet Date: Wed, 8 Oct 2025 16:40:30 +0700 Subject: [PATCH 152/174] feat(FE-33): create customers forms --- src/app/master-data/customer/add/page.tsx | 11 + .../master-data/customer/detail/edit/page.tsx | 0 src/app/master-data/customer/detail/page.tsx | 0 src/app/master-data/customer/page.tsx | 11 + src/components/helper/RequireAuth.tsx | 160 +++++++- src/components/input/TextArea.tsx | 124 ++++++ .../master-data/customer/CustomersTable.tsx | 245 ++++++++++++ .../customer/form/CustomerForm.schema.ts | 40 ++ .../customer/form/CustomerForm.tsx | 377 ++++++++++++++++++ src/config/constant.ts | 11 + src/services/api/master-data.ts | 11 + src/types/api/master-data/customer.d.ts | 27 ++ 12 files changed, 1008 insertions(+), 9 deletions(-) create mode 100644 src/app/master-data/customer/add/page.tsx create mode 100644 src/app/master-data/customer/detail/edit/page.tsx create mode 100644 src/app/master-data/customer/detail/page.tsx create mode 100644 src/app/master-data/customer/page.tsx create mode 100644 src/components/input/TextArea.tsx create mode 100644 src/components/pages/master-data/customer/CustomersTable.tsx create mode 100644 src/components/pages/master-data/customer/form/CustomerForm.schema.ts create mode 100644 src/components/pages/master-data/customer/form/CustomerForm.tsx create mode 100644 src/types/api/master-data/customer.d.ts diff --git a/src/app/master-data/customer/add/page.tsx b/src/app/master-data/customer/add/page.tsx new file mode 100644 index 00000000..274b7d90 --- /dev/null +++ b/src/app/master-data/customer/add/page.tsx @@ -0,0 +1,11 @@ +import CustomerForm from "@/components/pages/master-data/customer/form/CustomerForm"; + +const AddNonstock = () => { + return ( +
+ +
+ ); +} + +export default AddNonstock; \ No newline at end of file diff --git a/src/app/master-data/customer/detail/edit/page.tsx b/src/app/master-data/customer/detail/edit/page.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/app/master-data/customer/detail/page.tsx b/src/app/master-data/customer/detail/page.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/app/master-data/customer/page.tsx b/src/app/master-data/customer/page.tsx new file mode 100644 index 00000000..56281702 --- /dev/null +++ b/src/app/master-data/customer/page.tsx @@ -0,0 +1,11 @@ +import CustomersTable from "@/components/pages/master-data/customer/CustomersTable"; + +const Nonstock = () => { + return ( +
+ +
+ ) +}; + +export default Nonstock; \ No newline at end of file diff --git a/src/components/helper/RequireAuth.tsx b/src/components/helper/RequireAuth.tsx index 9bc199f9..1d9d86b4 100644 --- a/src/components/helper/RequireAuth.tsx +++ b/src/components/helper/RequireAuth.tsx @@ -9,6 +9,145 @@ import { httpClientFetcher, SWRHttpKey } from '@/services/http/client'; import { isResponseSuccess } from '@/lib/api-helper'; import { GetMeResponse } from '@/types/api/api-general'; +// TODO: delete this later, DONT HARDCODE USER DATA +const DUMMY_USER = { + id: 1, + email: 'admin@mbugroup.id', + npk: '0001', + name: 'Super Admin', + image: null, + created_at: '2025-09-30T03:24:20.899229Z', + updated_at: '2025-09-30T03:24:20.899229Z', + roles: [ + { + id: 1, + key: 'mbu.super_admin', + name: 'MBU Administrator', + client: { + id: 1, + name: 'PT Mitra Berlian Unggas', + alias: 'MBU', + }, + permissions: [ + { + id: 1, + name: 'mbu:purchase:read', + action: 'read', + client: { + id: 1, + name: 'PT Mitra Berlian Unggas', + alias: 'MBU', + }, + }, + { + id: 2, + name: 'mbu:purchase:create', + action: 'create', + client: { + id: 1, + name: 'PT Mitra Berlian Unggas', + alias: 'MBU', + }, + }, + { + id: 3, + name: 'mbu:purchase:approve', + action: 'approve', + client: { + id: 1, + name: 'PT Mitra Berlian Unggas', + alias: 'MBU', + }, + }, + ], + }, + { + id: 2, + key: 'lti.super_admin', + name: 'LTI Administrator', + client: { + id: 2, + name: 'PT Lumbung Telur Indonesia', + alias: 'LTI', + }, + permissions: [ + { + id: 4, + name: 'lti:purchase:read', + action: 'read', + client: { + id: 2, + name: 'PT Lumbung Telur Indonesia', + alias: 'LTI', + }, + }, + { + id: 5, + name: 'lti:purchase:create', + action: 'create', + client: { + id: 2, + name: 'PT Lumbung Telur Indonesia', + alias: 'LTI', + }, + }, + { + id: 6, + name: 'lti:purchase:approve', + action: 'approve', + client: { + id: 2, + name: 'PT Lumbung Telur Indonesia', + alias: 'LTI', + }, + }, + ], + }, + { + id: 3, + key: 'manbu.super_admin', + name: 'MANBU Administrator', + client: { + id: 3, + name: 'PT Mandiri Berlian Unggas', + alias: 'MANBU', + }, + permissions: [ + { + id: 7, + name: 'manbu:purchase:read', + action: 'read', + client: { + id: 3, + name: 'PT Mandiri Berlian Unggas', + alias: 'MANBU', + }, + }, + { + id: 8, + name: 'manbu:purchase:create', + action: 'create', + client: { + id: 3, + name: 'PT Mandiri Berlian Unggas', + alias: 'MANBU', + }, + }, + { + id: 9, + name: 'manbu:purchase:approve', + action: 'approve', + client: { + id: 3, + name: 'PT Mandiri Berlian Unggas', + alias: 'MANBU', + }, + }, + ], + }, + ], +}; + interface RequireAuthProps { children?: ReactNode; } @@ -37,19 +176,22 @@ const RequireAuth = ({ children }: RequireAuthProps) => { if (isResponseSuccess(userResponse)) { setUser(userResponse.data); } else { - router.replace(process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string); + // router.replace(process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string); + // TODO: remove this later, DONT HARDCODE USER DATA + setUser(DUMMY_USER); } }, [userResponse, setIsLoadingUser, setUser]); - if (isLoadingUserResponse && !userResponse) { - return ( -
- -
- ); - } + // TODO: uncomment this later + // if (isLoadingUserResponse && !userResponse) { + // return ( + //
+ // + //
+ // ); + // } return <>{children}; }; -export default RequireAuth; +export default RequireAuth; \ No newline at end of file diff --git a/src/components/input/TextArea.tsx b/src/components/input/TextArea.tsx new file mode 100644 index 00000000..b4a6c9f5 --- /dev/null +++ b/src/components/input/TextArea.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { + ChangeEventHandler, + FocusEventHandler, + ReactNode, +} from 'react'; + +import { cn } from '@/lib/helper'; + +export interface TextAreaProps { + 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; + onBlur?: FocusEventHandler; + cols?: number; +} + +const TextArea = ({ + label, + bottomLabel, + name, + value, + placeholder, + className, + isError, + isValid, + errorMessage, + startAdornment, + endAdornment, + disabled = false, + required = false, + onChange, + onBlur, + readOnly = false, + isLoading = false, + cols = 3 +}: TextAreaProps) => { + return ( +
+ {label && ( + + )} + {startAdornment && startAdornment} + +