Merge branch 'dev/randy' into 'feat/FE/US-75-chick-in-doc'

[FEAT/FE][US#75/TASK#238-239-240] Refactor Chick In DOC

See merge request mbugroup/lti-web-client!48
This commit is contained in:
Rivaldi A N S
2025-11-13 04:53:00 +00:00
184 changed files with 18322 additions and 1195 deletions
+2
View File
@@ -0,0 +1,2 @@
npm run lint
npm run build
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
echo "VERCEL_GIT_COMMIT_REF: $VERCEL_GIT_COMMIT_REF"
if [[ "$VERCEL_GIT_COMMIT_REF" == "master" || "$VERCEL_GIT_COMMIT_REF" == "development" ]]; then
echo "✅ - Build can proceed"
exit 1
else
echo "🛑 - Build cancelled"
exit 0
fi
+9 -9
View File
@@ -1,6 +1,6 @@
import { dirname } from "path"; import { dirname } from 'path';
import { fileURLToPath } from "url"; import { fileURLToPath } from 'url';
import { FlatCompat } from "@eslint/eslintrc"; import { FlatCompat } from '@eslint/eslintrc';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@@ -10,14 +10,14 @@ const compat = new FlatCompat({
}); });
const eslintConfig = [ const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"), ...compat.extends('next/core-web-vitals', 'next/typescript'),
{ {
ignores: [ ignores: [
"node_modules/**", 'node_modules/**',
".next/**", '.next/**',
"out/**", 'out/**',
"build/**", 'build/**',
"next-env.d.ts", 'next-env.d.ts',
], ],
}, },
]; ];
+549 -537
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -6,7 +6,9 @@
"dev": "eslint && next dev --turbopack", "dev": "eslint && next dev --turbopack",
"build": "next build --turbopack", "build": "next build --turbopack",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"prepare": "husky",
"format": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/match-sorter-utils": "^8.19.4",
@@ -14,11 +16,14 @@
"axios": "^1.12.2", "axios": "^1.12.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"formik": "^2.4.6", "formik": "^2.4.6",
"inputmask": "^5.0.9",
"moment": "^2.30.1", "moment": "^2.30.1",
"next": "15.5.3", "next": "15.5.3",
"react": "19.1.0", "react": "19.1.0",
"react-day-picker": "^9.11.1",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-number-format": "^5.4.4",
"react-select": "^5.10.2", "react-select": "^5.10.2",
"swr": "^2.3.6", "swr": "^2.3.6",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
@@ -30,12 +35,15 @@
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/inputmask": "^5.0.7",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"daisyui": "^5.1.12", "daisyui": "^5.1.12",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.5.3", "eslint-config-next": "15.5.3",
"husky": "^9.1.7",
"prettier": "^3.6.2",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
} }
+1 -1
View File
@@ -1,5 +1,5 @@
const config = { const config = {
plugins: ["@tailwindcss/postcss"], plugins: ['@tailwindcss/postcss'],
}; };
export default config; export default config;
+41
View File
@@ -1,5 +1,41 @@
@import 'tailwindcss'; @import 'tailwindcss';
@plugin "daisyui"; @plugin "daisyui";
@import '../styles/daisyui.css';
@plugin "daisyui/theme" {
name: 'lti';
default: false;
prefersdark: false;
color-scheme: 'light';
--color-base-100: oklch(98% 0.001 106.423);
--color-base-200: oklch(97% 0.001 106.424);
--color-base-300: oklch(92% 0.003 48.717);
--color-base-content: oklch(22.389% 0.031 278.072);
--color-primary: oklch(60% 0.126 221.723);
--color-primary-content: oklch(100% 0 0);
--color-secondary: oklch(52% 0.105 223.128);
--color-secondary-content: oklch(100% 0 0);
--color-accent: oklch(45% 0.085 224.283);
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(39% 0.07 227.392);
--color-neutral-content: oklch(100% 0 0);
--color-info: oklch(58% 0.158 241.966);
--color-info-content: oklch(100% 0 0);
--color-success: oklch(62% 0.194 149.214);
--color-success-content: oklch(100% 0 0);
--color-warning: oklch(85% 0.199 91.936);
--color-warning-content: oklch(0% 0 0);
--color-error: oklch(57% 0.245 27.325);
--color-error-content: oklch(100% 0 0);
--radius-selector: 0rem;
--radius-field: 0.25rem;
--radius-box: 0.25rem;
--size-selector: 0.21875rem;
--size-field: 0.1875rem;
--border: 1px;
--depth: 0;
--noise: 0;
}
:root { :root {
--color-primary: #1f74bf; --color-primary: #1f74bf;
@@ -12,3 +48,8 @@
html { html {
scrollbar-gutter: initial; scrollbar-gutter: initial;
} }
.react-select__menu-portal {
position: relative;
z-index: 99999 !important;
}
+5 -5
View File
@@ -1,11 +1,11 @@
import InventoryAdjustmentForm from "@/components/pages/inventory/adjustment/form/InventoryAdjustmentForm"; import InventoryAdjustmentForm from '@/components/pages/inventory/adjustment/form/InventoryAdjustmentForm';
const CreateInventoryAdjustment = () => { const CreateInventoryAdjustment = () => {
return ( return (
<section className="w-full p-4 flex flex-row justify-center"> <section className='w-full p-4 flex flex-row justify-center'>
<InventoryAdjustmentForm/> <InventoryAdjustmentForm />
</section> </section>
); );
} };
export default CreateInventoryAdjustment; export default CreateInventoryAdjustment;
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+8 -7
View File
@@ -7,11 +7,12 @@ import type { InventoryAdjustment } from '@/types/api/inventory/adjustment';
const DetailInventoryAdjustment = () => { const DetailInventoryAdjustment = () => {
const router = useRouter(); const router = useRouter();
const [inventoryAdjustment, setInventoryAdjustment] = useState<InventoryAdjustment | null>(null); const [inventoryAdjustment, setInventoryAdjustment] =
useState<InventoryAdjustment | null>(null);
// Ambil data dari router state // Ambil data dari router state
useEffect(() => { useEffect(() => {
console.log("Router State"); console.log('Router State');
console.log(window.history.state); console.log(window.history.state);
const state = window.history.state?.usr as const state = window.history.state?.usr as
| { inventoryAdjustment?: InventoryAdjustment } | { inventoryAdjustment?: InventoryAdjustment }
@@ -24,20 +25,20 @@ const DetailInventoryAdjustment = () => {
}, [router]); }, [router]);
const finalData = inventoryAdjustment; const finalData = inventoryAdjustment;
console.log("Final Data"); console.log('Final Data');
console.log(finalData); console.log(finalData);
if (!finalData) { if (!finalData) {
return ( return (
<div className="w-full flex flex-row justify-center items-center p-4"> <div className='w-full flex flex-row justify-center items-center p-4'>
<span className="loading loading-spinner loading-xl" /> <span className='loading loading-spinner loading-xl' />
</div> </div>
); );
} }
return ( return (
<section className="w-full p-4 flex flex-row justify-center"> <section className='w-full p-4 flex flex-row justify-center'>
<InventoryAdjustmentForm initialValues={finalData} /> <InventoryAdjustmentForm initialValues={finalData} />
</section> </section>
); );
+11
View File
@@ -0,0 +1,11 @@
import MovementForm from '@/components/pages/inventory/movement/form/MovementForm';
const AddMovement = () => {
return (
<div className='w-full p-4 flex flex-row justify-center'>
<MovementForm />
</div>
);
};
export default AddMovement;
@@ -0,0 +1,48 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import MovementForm from '@/components/pages/inventory/movement/form/MovementForm';
import { MovementApi } from '@/services/api/inventory';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const MovementEdit = () => {
const router = useRouter();
const searchParams = useSearchParams();
const movementId = searchParams.get('movementId');
const { data: movement, isLoading: isLoadingMovement } = useSWR(
movementId,
(id: number) => MovementApi.getSingle(id)
);
if (!movementId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoadingMovement && (!movement || isResponseError(movement))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingMovement && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingMovement && isResponseSuccess(movement) && (
<MovementForm type='edit' initialValues={movement.data} />
)}
</div>
);
};
export default MovementEdit;
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
@@ -0,0 +1,48 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import MovementForm from '@/components/pages/inventory/movement/form/MovementForm';
import { MovementApi } from '@/services/api/inventory';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const MovementDetail = () => {
const router = useRouter();
const searchParams = useSearchParams();
const movementId = searchParams.get('movementId');
const { data: movement, isLoading: isLoadingMovement } = useSWR(
movementId,
(id: number) => MovementApi.getSingle(id)
);
if (!movementId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoadingMovement && (!movement || isResponseError(movement))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingMovement && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingMovement && isResponseSuccess(movement) && (
<MovementForm type='detail' initialValues={movement.data} />
)}
</div>
);
};
export default MovementDetail;
+11
View File
@@ -0,0 +1,11 @@
import MovementTable from '@/components/pages/inventory/movement/MovementTable';
const Movement = () => {
return (
<section className='w-full p-4'>
<MovementTable />
</section>
);
};
export default Movement;
+1 -1
View File
@@ -28,7 +28,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang='en'> <html lang='en' data-theme='lti'>
<body className={`${inter.variable} antialiased font-inter`}> <body className={`${inter.variable} antialiased font-inter`}>
<RequireAuth> <RequireAuth>
<MainDrawer>{children}</MainDrawer> <MainDrawer>{children}</MainDrawer>
@@ -0,0 +1,11 @@
import SalesForm from '@/components/pages/marketing/sales-orders/form/SalesForm';
const AddSalesOrder = () => {
return (
<div className='size-full p-4'>
<SalesForm />
</div>
);
};
export default AddSalesOrder;
@@ -0,0 +1,42 @@
'use client';
import SalesForm from '@/components/pages/marketing/sales-orders/form/SalesForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { MarketingApi } from '@/services/api/marketing/marketing';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
const EditSalesOrder = () => {
const router = useRouter();
const searchParams = useSearchParams();
const soId = searchParams.get('salesOrderId');
const { data: marketing, isLoading: isLoading } = useSWR(soId, (id: number) =>
MarketingApi.getSingle(id)
);
if (!soId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoading && (!marketing || isResponseError(marketing))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4'>
{isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading && isResponseSuccess(marketing) && (
<SalesForm formType='edit' initialValues={marketing.data} />
)}
</div>
);
};
export default EditSalesOrder;
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
@@ -0,0 +1,44 @@
'use client';
import SalesOrderDetail from '@/components/pages/marketing/sales-orders/detail/SalesOrderDetail';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { MarketingApi } from '@/services/api/marketing/marketing';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
const DetailSalesOrder = () => {
const router = useRouter();
const searchParams = useSearchParams();
const soId = searchParams.get('salesOrderId');
const { data: marketing, isLoading: isLoading } = useSWR(soId, (id: number) =>
MarketingApi.getSingle(id)
);
if (!soId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoading && (!marketing || isResponseError(marketing))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4'>
{isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading && isResponseSuccess(marketing) && (
<SalesOrderDetail initialValues={marketing.data} />
)}
</div>
);
};
export default DetailSalesOrder;
+10
View File
@@ -0,0 +1,10 @@
import SalesOrderTable from '@/components/pages/marketing/sales-orders/SalesOrderTable';
const SalesOrder = () => {
return (
<div className='w-full p-4'>
<SalesOrderTable />
</div>
);
};
export default SalesOrder;
+5 -5
View File
@@ -1,11 +1,11 @@
import CustomerForm from "@/components/pages/master-data/customer/form/CustomerForm"; import CustomerForm from '@/components/pages/master-data/customer/form/CustomerForm';
const AddCustomer = () => { const AddCustomer = () => {
return ( return (
<section className="w-full p-4 flex flex-row justify-center"> <section className='w-full p-4 flex flex-row justify-center'>
<CustomerForm/> <CustomerForm />
</section> </section>
); );
} };
export default AddCustomer; export default AddCustomer;
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+17 -15
View File
@@ -1,45 +1,47 @@
'use client' 'use client';
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from "swr"; import useSWR from 'swr';
import { CustomerApi } from '@/services/api/master-data'; import { CustomerApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from "@/lib/api-helper"; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import CustomerForm from "@/components/pages/master-data/customer/form/CustomerForm"; import CustomerForm from '@/components/pages/master-data/customer/form/CustomerForm';
const CustomerDetail = () => { const CustomerDetail = () => {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const costumerId = searchParams.get("customerId"); const costumerId = searchParams.get('customerId');
const { data: costumer, isLoading: isLoadingCostumer } = useSWR( const { data: costumer, isLoading: isLoadingCostumer } = useSWR(
costumerId, costumerId,
(id: number) => CustomerApi.getSingle(id) (id: number) => CustomerApi.getSingle(id)
); );
if(!costumerId){ if (!costumerId) {
router.back(); router.back();
return ( return (
<div className="w-full flex flex-row justify-center items-center p-4"> <div className='w-full flex flex-row justify-center items-center p-4'>
<span className="loading loading-spinner loading-xl" /> <span className='loading loading-spinner loading-xl' />
</div> </div>
); );
} }
if(!isLoadingCostumer && (!costumer || isResponseError(costumer))){ if (!isLoadingCostumer && (!costumer || isResponseError(costumer))) {
router.replace("/404"); router.replace('/404');
return; return;
} }
return ( return (
<div className="w-full p-4 flex flex-row justify-center"> <div className='w-full p-4 flex flex-row justify-center'>
{isLoadingCostumer && <span className="loading loading-spinner loading-xl" />} {isLoadingCostumer && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingCostumer && isResponseSuccess(costumer) && ( {!isLoadingCostumer && isResponseSuccess(costumer) && (
<CustomerForm formType="detail" initialValues={costumer.data} /> <CustomerForm formType='detail' initialValues={costumer.data} />
)} )}
</div> </div>
) );
}; };
export default CustomerDetail; export default CustomerDetail;
+4 -4
View File
@@ -1,11 +1,11 @@
import CustomersTable from "@/components/pages/master-data/customer/CustomersTable"; import CustomersTable from '@/components/pages/master-data/customer/CustomersTable';
const Customer = () => { const Customer = () => {
return ( return (
<section className="w-full p-4"> <section className='w-full p-4'>
<CustomersTable /> <CustomersTable />
</section> </section>
) );
}; };
export default Customer; export default Customer;
+11
View File
@@ -0,0 +1,11 @@
import FlockForm from '@/components/pages/master-data/flock/form/FlockForm';
const AddFlock = () => {
return (
<section className='w-full p-4 flex flex-row justify-center'>
<FlockForm />
</section>
);
};
export default AddFlock;
@@ -0,0 +1,49 @@
'use client';
import FlockForm from '@/components/pages/master-data/flock/form/FlockForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { FlockApi } from '@/services/api/master-data';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
const FlockEdit = () => {
const router = useRouter();
const searchParams = useSearchParams();
// Get Query Params
const flockId = searchParams.get('flockId');
// Fetch Data
const { data: flock, isLoading: isLoadingFlock } = useSWR(
flockId,
(id: number) => FlockApi.getSingle(id)
);
if (!flockId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoadingFlock && (!flock || isResponseError(flock))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingFlock && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingFlock && isResponseSuccess(flock) && (
<FlockForm formType='edit' initialValues={flock.data} />
)}
</div>
);
};
export default FlockEdit;
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+49
View File
@@ -0,0 +1,49 @@
'use client';
import FlockForm from '@/components/pages/master-data/flock/form/FlockForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { FlockApi } from '@/services/api/master-data';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
const FlockDetail = () => {
const router = useRouter();
const searchParams = useSearchParams();
// Get Query Params
const flockId = searchParams.get('flockId');
// Fetch Data
const { data: flock, isLoading: isLoadingFlock } = useSWR(
flockId,
(id: number) => FlockApi.getSingle(id)
);
if (!flockId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoadingFlock && (!flock || isResponseError(flock))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingFlock && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingFlock && isResponseSuccess(flock) && (
<FlockForm formType='detail' initialValues={flock.data} />
)}
</div>
);
};
export default FlockDetail;
+11
View File
@@ -0,0 +1,11 @@
import FlockTable from '@/components/pages/master-data/flock/FlocksTable';
const Flock = () => {
return (
<section className='w-full p-4'>
<FlockTable />
</section>
);
};
export default Flock;
@@ -1,11 +1,11 @@
import ProductCategoryForm from "@/components/pages/master-data/product-category/form/ProductCategoryForm"; import ProductCategoryForm from '@/components/pages/master-data/product-category/form/ProductCategoryForm';
const AddProductCategory = () => { const AddProductCategory = () => {
return ( return (
<div className="w-full p-4 flex flex-row justify-center"> <div className='w-full p-4 flex flex-row justify-center'>
<ProductCategoryForm /> <ProductCategoryForm />
</div> </div>
); );
}; };
export default AddProductCategory; export default AddProductCategory;
@@ -9,39 +9,44 @@ import { ProductCategoryApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const ProductCategoryEdit = () => { const ProductCategoryEdit = () => {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const productCategoryId = searchParams.get('productCategoryId'); const productCategoryId = searchParams.get('productCategoryId');
const { data: productCategory, isLoading: isLoadingProductCategory } = useSWR( const { data: productCategory, isLoading: isLoadingProductCategory } = useSWR(
productCategoryId, productCategoryId,
(id: number) => ProductCategoryApi.getSingle(id) (id: number) => ProductCategoryApi.getSingle(id)
); );
if (!productCategoryId) { if (!productCategoryId) {
router.back(); router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoadingProductCategory && (!productCategory || isResponseError(productCategory))) {
router.replace('/404');
return;
}
return ( return (
<div className='w-full p-4 flex flex-row justify-center'> <div className='w-full flex flex-row justify-center items-center p-4'>
{isLoadingProductCategory && <span className='loading loading-spinner loading-xl' />} <span className='loading loading-spinner loading-xl' />
{!isLoadingProductCategory && isResponseSuccess(productCategory) && ( </div>
<ProductCategoryForm type='edit' initialValues={productCategory.data} />
)}
</div>
); );
} }
export default ProductCategoryEdit; if (
!isLoadingProductCategory &&
(!productCategory || isResponseError(productCategory))
) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingProductCategory && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingProductCategory && isResponseSuccess(productCategory) && (
<ProductCategoryForm type='edit' initialValues={productCategory.data} />
)}
</div>
);
};
export default ProductCategoryEdit;
@@ -29,16 +29,24 @@ const ProductCategoryDetail = () => {
); );
} }
if (!isLoadingProductCategory && (!productCategory || isResponseError(productCategory))) { if (
!isLoadingProductCategory &&
(!productCategory || isResponseError(productCategory))
) {
router.replace('/404'); router.replace('/404');
return; return;
} }
return ( return (
<div className='w-full p-4 flex flex-row justify-center'> <div className='w-full p-4 flex flex-row justify-center'>
{isLoadingProductCategory && <span className='loading loading-spinner loading-xl' />} {isLoadingProductCategory && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingProductCategory && isResponseSuccess(productCategory) && ( {!isLoadingProductCategory && isResponseSuccess(productCategory) && (
<ProductCategoryForm type='detail' initialValues={productCategory.data} /> <ProductCategoryForm
type='detail'
initialValues={productCategory.data}
/>
)} )}
</div> </div>
); );
@@ -1,11 +1,11 @@
import ProductCategoryTable from "@/components/pages/master-data/product-category/ProductCategoryTable"; import ProductCategoryTable from '@/components/pages/master-data/product-category/ProductCategoryTable';
const ProductCategory = () => { const ProductCategory = () => {
return ( return (
<section className="w-full p-4"> <section className='w-full p-4'>
<ProductCategoryTable /> <ProductCategoryTable />
</section> </section>
); );
}; };
export default ProductCategory; export default ProductCategory;
+2 -2
View File
@@ -2,10 +2,10 @@ import ProductForm from '@/components/pages/master-data/product/form/ProductForm
const AddProduct = () => { const AddProduct = () => {
return ( return (
<div className="w-full p-4 flex flex-row justify-center"> <div className='w-full p-4 flex flex-row justify-center'>
<ProductForm /> <ProductForm />
</div> </div>
); );
}; };
export default AddProduct; export default AddProduct;
@@ -13,9 +13,8 @@ const ProductEdit = () => {
const productId = searchParams.get('productId'); const productId = searchParams.get('productId');
const { data: product, isLoading } = useSWR( const { data: product, isLoading } = useSWR(productId, (id: number) =>
productId, ProductApi.getSingle(id)
(id: number) => ProductApi.getSingle(id)
); );
if (!productId) { if (!productId) {
@@ -42,4 +41,4 @@ const ProductEdit = () => {
); );
}; };
export default ProductEdit; export default ProductEdit;
+3 -4
View File
@@ -13,9 +13,8 @@ const ProductDetail = () => {
const productId = searchParams.get('productId'); const productId = searchParams.get('productId');
const { data: product, isLoading } = useSWR( const { data: product, isLoading } = useSWR(productId, (id: number) =>
productId, ProductApi.getSingle(id)
(id: number) => ProductApi.getSingle(id)
); );
if (!productId) { if (!productId) {
@@ -42,4 +41,4 @@ const ProductDetail = () => {
); );
}; };
export default ProductDetail; export default ProductDetail;
+4 -4
View File
@@ -1,11 +1,11 @@
import ProductsTable from "@/components/pages/master-data/product/ProductTable"; import ProductsTable from '@/components/pages/master-data/product/ProductTable';
const Product = () => { const Product = () => {
return ( return (
<section className="w-full p-4"> <section className='w-full p-4'>
<ProductsTable /> <ProductsTable />
</section> </section>
); );
}; };
export default Product; export default Product;
+1 -1
View File
@@ -8,4 +8,4 @@ const AddSupplier = () => {
); );
}; };
export default AddSupplier; export default AddSupplier;
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+1 -1
View File
@@ -46,4 +46,4 @@ const SupplierDetail = () => {
); );
}; };
export default SupplierDetail; export default SupplierDetail;
+1 -1
View File
@@ -1,4 +1,4 @@
import SuppliersTable from "@/components/pages/master-data/supplier/SupplierTable"; import SuppliersTable from '@/components/pages/master-data/supplier/SupplierTable';
const Supplier = () => { const Supplier = () => {
return ( return (
@@ -0,0 +1,13 @@
'use client';
import ProjectFlockForm from '@/components/pages/production/project-flock/form/ProjectFlockForm';
const AddProjectFlock = () => {
return (
<section className='w-full p-4 flex flex-row justify-center'>
<ProjectFlockForm formType='add' />
</section>
);
};
export default AddProjectFlock;
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
@@ -0,0 +1,60 @@
'use client';
import ChickinForm from '@/components/pages/production/chickin/form/ChickinForm';
import { isResponseSuccess } from '@/lib/api-helper';
import { ProjectFlockKandangApi } from '@/services/api/production';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
export default function AddChickinKandang() {
const searchParams = useSearchParams();
const projectFlockKandangId = searchParams.get('projectFlockKandangId');
const projectFlockId = searchParams.get('projectFlockId');
const router = useRouter();
const {
data: projectFlockKandang,
isLoading: isLoading,
mutate: refreshProjectFlockKandang,
} = useSWR(
`get-single-project-flock-kandang/${projectFlockKandangId}`,
async () =>
ProjectFlockKandangApi.getSingle(
parseInt(projectFlockKandangId as string)
)
);
if (!projectFlockKandangId) {
router.push(`/production/chickin/add?projectFlockId=${projectFlockId}`);
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoading && !projectFlockKandang) {
router.replace('/404');
return;
}
const handleAfterSubmit = () => {
refreshProjectFlockKandang();
};
return (
<>
<section className='w-full p-4'>
{isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading &&
isResponseSuccess(projectFlockKandang) &&
projectFlockId && (
<ChickinForm
initialValues={projectFlockKandang.data}
afterSubmit={handleAfterSubmit}
/>
)}
</section>
</>
);
}
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
@@ -0,0 +1,24 @@
'use client';
import { FormHeader } from '@/components/helper/form/FormHeader';
import ProjectFlockChickinDetail from '@/components/pages/production/project-flock/chickin/ProjectFlockChickinDetail';
import { useSearchParams } from 'next/navigation';
const AddChickin = () => {
const searchParams = useSearchParams();
const projectFlockId = searchParams.get('projectFlockId');
return (
<>
<section className='w-full p-4'>
<FormHeader
title='Daftar Kandang Project Flock'
backUrl='/production/project-flock'
/>
<ProjectFlockChickinDetail projectFlockId={Number(projectFlockId)} />
</section>
</>
);
};
export default AddChickin;
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
@@ -0,0 +1,343 @@
'use client';
import Button from '@/components/Button';
import Card from '@/components/Card';
import Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ChickinForm from '@/components/pages/production/chickin/form/ChickinForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { ChickinApi } from '@/services/api/production/chickin';
import { BaseApiResponse } from '@/types/api/api-general';
import {
Chickin,
ChickinApprovalPayload,
} from '@/types/api/production/chickin';
import { Icon } from '@iconify/react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
import toast from 'react-hot-toast';
import useSWR from 'swr';
/**
* TODO: Refactor code - pindahin detail ke reuseable component
* setelah implement approval and reject
*/
const DetailChickin = () => {
const router = useRouter();
const searchParams = useSearchParams();
const chickinId = searchParams.get('chickinId');
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const confirmModal = useModal();
const deleteModal = useModal();
const chickinModal = useModal();
const {
data: chickin,
isLoading,
mutate: refreshChickin,
} = useSWR(chickinId, (id: number) => ChickinApi.getSingle(id));
const [isApprovedDisabled, setIsApprovedDisabled] = useState(
// chickin.data?.approval.step_number == 1 ? false : true
true
);
const [isRejectedDisabled, setIsRejectedDisabled] =
useState(!isApprovedDisabled);
const [approvalAction, setApprovalAction] = useState<'APPROVED' | 'REJECTED'>(
!isApprovedDisabled ? 'APPROVED' : 'REJECTED'
);
if (!chickinId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoading && (!chickin || isResponseError(chickin))) {
router.replace('/404');
return;
}
if (!isResponseSuccess(chickin)) {
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
const confirmationModalClickHandler = async ({
action = 'APPROVED',
}: {
action: 'APPROVED' | 'REJECTED';
}) => {
if (chickin?.data.id === undefined) return;
setIsApproveLoading(true);
const approveChickinRes = await ChickinApi.customRequest<
BaseApiResponse<Chickin>,
ChickinApprovalPayload
>(`/approvals`, {
method: 'POST',
payload: {
action: action,
approvable_ids: [chickin.data.id],
},
});
if (isResponseSuccess(approveChickinRes)) {
if (refreshChickin) {
await refreshChickin();
}
toast.success(approveChickinRes.message as string);
}
if (isResponseError(approveChickinRes)) {
toast.error(approveChickinRes?.message as string);
}
confirmModal.closeModal();
setIsApproveLoading(false);
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
const deleteProjectFlockRes = await ChickinApi.delete(
chickin.data?.id as number
);
if (isResponseSuccess(deleteProjectFlockRes)) {
toast.success(deleteProjectFlockRes?.message as string);
router.push('/production/chickin');
}
if (isResponseError(deleteProjectFlockRes)) {
toast.error(deleteProjectFlockRes?.message as string);
}
deleteModal.closeModal();
setIsDeleteLoading(false);
};
return (
<>
<div className='w-full p-4 flex flex-col justify-center gap-4'>
{isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading && isResponseSuccess(chickin) && (
<>
{/* <div className='w-full flex flex-col sm:flex-row gap-2'>
<Button
variant='outline'
color='success'
onClick={(() => {
if (chickin?.data.id) {
setApprovalAction('APPROVED');
confirmModal.openModal();
}
})}
disabled={!chickin?.data.id || isApprovedDisabled}
className='w-full sm:w-fit'
>
<Icon icon='material-symbols:check' width={24} height={24} />
Approve
</Button>
<Button
variant='outline'
color='error'
onClick={() => {
if (chickin?.data.id) {
setApprovalAction('REJECTED');
confirmModal.openModal();
}
}}
disabled={!chickin?.data.id || isRejectedDisabled}
className='w-full sm:w-fit'
>
<Icon icon='mdi:times' width={24} height={24} />
Reject
</Button>
</div> */}
<Card
title='Informasi Umum'
variant='bordered'
className={{
wrapper: 'w-full',
}}
>
<div className='grid grid-cols-2 gap-4 mt-4'>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Flock</div>
<div className='text-sm'>
{
chickin?.data?.project_flock_kandang?.project_flock?.flock
?.name
}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Area</div>
<div className='text-sm'>
{
chickin.data.project_flock_kandang?.project_flock.area
.name
}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Kategori</div>
<div className='text-sm'>
{chickin.data.project_flock_kandang?.project_flock.category}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Lokasi</div>
<div className='text-sm'>
{
chickin.data.project_flock_kandang?.project_flock.location
.name
}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Periode</div>
<div className='text-sm'>
{chickin.data.project_flock_kandang?.project_flock.period}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Kandang</div>
<div className='text-sm'>
{chickin.data.project_flock_kandang?.kandang.name}
</div>
</div>
</div>
</Card>
<Card
title='Detail Chickin'
variant='bordered'
className={{
wrapper: 'w-full',
}}
>
<div className='grid grid-cols-2 gap-4 mt-4'>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Flock Kandang</div>
<div className='text-sm'>
{
chickin?.data?.project_flock_kandang?.project_flock?.flock
?.name
}{' '}
- {chickin.data.project_flock_kandang?.kandang.name}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Tanggal Chickin</div>
<div className='text-sm'>
{chickin.data.chick_in_date
? new Date(chickin.data.chick_in_date).toLocaleDateString(
'id-ID'
)
: '-'}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Jumlah (Ekor)</div>
<div className='text-sm'>
{chickin.data.quantity?.toLocaleString('id-ID')}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Catatan</div>
<div className='text-sm'>{chickin.data.note}</div>
</div>
</div>
</Card>
<div className='w-full flex flex-col sm:flex-row gap-2'>
<Button
color='error'
onClick={() => {
deleteModal.openModal();
}}
>
<Icon icon='mdi:times' width={24} height={24} />
Delete
</Button>
<Button
color='warning'
onClick={() => {
chickinModal.openModal();
}}
>
<Icon icon='mdi:pencil-outline' width={24} height={24} />
Edit
</Button>
</div>
</>
)}
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Project Flock ini (${chickin?.data?.project_flock_kandang?.project_flock.flock?.name} - ${chickin?.data.project_flock_kandang?.kandang.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
<Modal ref={chickinModal.ref}>
<div className='flex flex-row justify-between items-center'>
<h1 className='text-xl font-semibold text-center mb-6'>
Chickin Kandang -{' '}
{chickin?.data?.project_flock_kandang &&
chickin?.data?.project_flock_kandang.kandang?.name}
</h1>
<Button
color='error'
variant='link'
onClick={chickinModal.closeModal}
>
<Icon
className='text-black'
icon='uil:times'
width={24}
height={24}
/>
</Button>
</div>
</Modal>
<ConfirmationModal
ref={confirmModal.ref}
type={approvalAction == 'APPROVED' ? 'success' : 'error'}
text={`Apakah anda yakin ingin ${
approvalAction == 'APPROVED' ? 'approve' : 'reject'
} chickin berikut? (${
chickin?.data?.project_flock_kandang?.project_flock?.flock?.name
} - ${chickin?.data.project_flock_kandang?.kandang.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: approvalAction == 'APPROVED' ? 'success' : 'error',
isLoading: isApproveLoading,
onClick: () => {
confirmationModalClickHandler({
action: approvalAction,
});
},
}}
/>
</>
);
};
export default DetailChickin;
@@ -0,0 +1,10 @@
import ChickinTable from '@/components/pages/production/chickin/ChickinTable';
const Chickin = () => {
return (
<section className='w-full p-4'>
<ChickinTable />
</section>
);
};
export default Chickin;
@@ -0,0 +1,55 @@
'use client';
import ProjectFlockForm from '@/components/pages/production/project-flock/form/ProjectFlockForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
const ProjectFlockEdit = () => {
const router = useRouter();
const searchParams = useSearchParams();
const projectFlockId = searchParams.get('projectFlockId');
const {
data: projectFlock,
isLoading: isLoadingProjectFlock,
mutate: refreshProjectFlocks,
} = useSWR(projectFlockId, (id: number) => ProjectFlockApi.getSingle(id));
if (!projectFlockId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (
!isLoadingProjectFlock &&
(!projectFlock || isResponseError(projectFlock))
) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-col justify-center'>
{isLoadingProjectFlock && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingProjectFlock && isResponseSuccess(projectFlock) && (
<>
{JSON.stringify(projectFlock.data)}
<ProjectFlockForm formType='edit' initialValues={projectFlock.data} />
</>
)}
</div>
);
};
export default ProjectFlockEdit;
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
@@ -0,0 +1,55 @@
'use client';
import ProjectFlockForm from '@/components/pages/production/project-flock/form/ProjectFlockForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
const ProjectFlockDetail = () => {
const router = useRouter();
const searchParams = useSearchParams();
const projectFlockId = searchParams.get('projectFlockId');
const {
data: projectFlock,
isLoading: isLoadingProjectFlock,
mutate: refreshProjectFlock,
} = useSWR(projectFlockId, (id: number) => ProjectFlockApi.getSingle(id));
if (!projectFlockId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (
!isLoadingProjectFlock &&
(!projectFlock || isResponseError(projectFlock))
) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-col justify-center'>
{isLoadingProjectFlock && (
<span className='loading loading-spinner loading-xl' />
)}
{isResponseSuccess(projectFlock) && (
<ProjectFlockForm
formType='detail'
initialValues={projectFlock.data}
refreshProjectFlocks={refreshProjectFlock}
/>
)}
</div>
);
};
export default ProjectFlockDetail;
+11
View File
@@ -0,0 +1,11 @@
import ProjectFlockTable from '@/components/pages/production/project-flock/ProjectFlockTable';
const ProjectFlock = () => {
return (
<section className='w-full p-4'>
<ProjectFlockTable />
</section>
);
};
export default ProjectFlock;
+11
View File
@@ -0,0 +1,11 @@
import RecordingForm from '@/components/pages/production/recording/form/RecordingForm';
const AddRecording = () => {
return (
<div className='w-full p-4 flex flex-row justify-center'>
<RecordingForm />
</div>
);
};
export default AddRecording;
@@ -0,0 +1,47 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import RecordingForm from '@/components/pages/production/recording/form/RecordingForm';
import { RecordingApi } from '@/services/api/production';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const RecordingEdit = () => {
const router = useRouter();
const searchParams = useSearchParams();
const recordingId = searchParams.get('recordingId');
const { data: recording, isLoading: isLoadingRecording } = useSWR(
recordingId,
(id: number) => RecordingApi.getSingle(id) // Gunakan RecordingApi
);
if (!recordingId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoadingRecording && (!recording || isResponseError(recording))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingRecording && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingRecording && isResponseSuccess(recording) && (
<RecordingForm type='edit' initialValues={recording.data} />
)}
</div>
);
};
export default RecordingEdit;
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
@@ -0,0 +1,47 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import RecordingForm from '@/components/pages/production/recording/form/RecordingForm';
import { RecordingApi } from '@/services/api/production';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const RecordingDetail = () => {
const router = useRouter();
const searchParams = useSearchParams();
const recordingId = searchParams.get('recordingId');
const { data: recording, isLoading: isLoadingRecording } = useSWR(
recordingId,
(id: number) => RecordingApi.getSingle(id)
);
if (!recordingId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoadingRecording && (!recording || isResponseError(recording))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingRecording && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingRecording && isResponseSuccess(recording) && (
<RecordingForm type='detail' initialValues={recording.data} />
)}
</div>
);
};
export default RecordingDetail;
+11
View File
@@ -0,0 +1,11 @@
import RecordingTable from '@/components/pages/production/recording/RecordingTable';
const Recording = () => {
return (
<section className='w-full p-4'>
<RecordingTable />
</section>
);
};
export default Recording;
@@ -0,0 +1,11 @@
import TransferToLayingForm from '@/components/pages/production/transfer-to-laying/form/TransferToLayingForm';
const AddTransferToLaying = () => {
return (
<div className='w-full p-4 flex flex-row justify-center'>
<TransferToLayingForm />
</div>
);
};
export default AddTransferToLaying;
@@ -0,0 +1,150 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import TransferToLayingForm from '@/components/pages/production/transfer-to-laying/form/TransferToLayingForm';
import { TransferToLayingApi } from '@/services/api/production/transfer-to-laying';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { TransferToLaying } from '@/types/api/production/transfer-to-laying';
// TODO: delete dummy data
const DUMMY_TRANSFER_TO_LAYING_EDIT: TransferToLaying = {
id: 1,
transfer_date: '2025-10-14',
flock_source: {
id: 1,
name: 'Flock asal test',
},
flock_destination: {
id: 2,
name: 'Flock tujuan destination',
},
quantity: 10,
kandangs: [
{
kandang: {
id: 1,
capacity: 1000,
name: 'Kandang test',
status: 'ACTIVE',
location: {
id: 1,
name: 'test location',
address: 'test address 1',
area: { id: 1, name: 'test area 1' },
},
pic: {
id: 1,
id_user: 2,
email: 'test@gmail.com',
name: 'test',
},
created_user: {
id: 1,
id_user: 2,
email: 'test@gmail.com',
name: 'test',
},
created_at: '14-10-2025',
updated_at: '14-10-2025',
},
quantity: 8,
},
{
kandang: {
id: 1,
name: 'Kandang test 2',
capacity: 3000,
status: 'ACTIVE',
location: {
id: 1,
name: 'test location',
address: 'test address 1',
area: { id: 1, name: 'test area 1' },
},
pic: {
id: 1,
id_user: 2,
email: 'test@gmail.com',
name: 'test',
},
created_user: {
id: 1,
id_user: 2,
email: 'test@gmail.com',
name: 'test',
},
created_at: '14-10-2025',
updated_at: '14-10-2025',
},
quantity: 2,
},
],
reason: 'Test alasan',
created_user: {
id: 1,
id_user: 2,
email: 'test@gmail.com',
name: 'test',
},
created_at: '14-10-2025',
updated_at: '14-10-2025',
};
const TransferToLayingEdit = () => {
const router = useRouter();
const searchParams = useSearchParams();
const transferToLayingId = searchParams.get('transferToLayingId');
const { data: transferToLaying, isLoading: isLoadingTransferToLaying } =
useSWR(transferToLayingId, (id: number) =>
TransferToLayingApi.getSingle(id)
);
if (!transferToLayingId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
// TODO: remove dummy data and integrate with real API
if (
!isLoadingTransferToLaying &&
(!transferToLaying ||
(isResponseError(transferToLaying) && !DUMMY_TRANSFER_TO_LAYING_EDIT))
) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingTransferToLaying && (
<span className='loading loading-spinner loading-xl' />
)}
{/* {!isLoadingTransferToLaying && isResponseSuccess(transferToLaying) && (
<TransferToLayingForm
type='detail'
initialValues={transferToLaying.data}
/>
)} */}
{/* TODO: remove this dummy data and integrate to real API */}
<TransferToLayingForm
type='edit'
initialValues={DUMMY_TRANSFER_TO_LAYING_EDIT}
/>
</div>
);
};
export default TransferToLayingEdit;
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
@@ -0,0 +1,150 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import TransferToLayingForm from '@/components/pages/production/transfer-to-laying/form/TransferToLayingForm';
import { TransferToLayingApi } from '@/services/api/production/transfer-to-laying';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { TransferToLaying } from '@/types/api/production/transfer-to-laying';
// TODO: delete dummy data
const DUMMY_TRANSFER_TO_LAYING_DETAIL: TransferToLaying = {
id: 1,
transfer_date: '2025-10-14',
flock_source: {
id: 1,
name: 'Flock asal test',
},
flock_destination: {
id: 2,
name: 'Flock tujuan destination',
},
quantity: 10,
kandangs: [
{
kandang: {
id: 1,
capacity: 1000,
name: 'Kandang test',
status: 'ACTIVE',
location: {
id: 1,
name: 'test location',
address: 'test address 1',
area: { id: 1, name: 'test area 1' },
},
pic: {
id: 1,
id_user: 2,
email: 'test@gmail.com',
name: 'test',
},
created_user: {
id: 1,
id_user: 2,
email: 'test@gmail.com',
name: 'test',
},
created_at: '14-10-2025',
updated_at: '14-10-2025',
},
quantity: 8,
},
{
kandang: {
id: 1,
capacity: 3000,
name: 'Kandang test 2',
status: 'ACTIVE',
location: {
id: 1,
name: 'test location',
address: 'test address 1',
area: { id: 1, name: 'test area 1' },
},
pic: {
id: 1,
id_user: 2,
email: 'test@gmail.com',
name: 'test',
},
created_user: {
id: 1,
id_user: 2,
email: 'test@gmail.com',
name: 'test',
},
created_at: '14-10-2025',
updated_at: '14-10-2025',
},
quantity: 2,
},
],
reason: 'Test alasan',
created_user: {
id: 1,
id_user: 2,
email: 'test@gmail.com',
name: 'test',
},
created_at: '14-10-2025',
updated_at: '14-10-2025',
};
const TransferToLayingDetail = () => {
const router = useRouter();
const searchParams = useSearchParams();
const transferToLayingId = searchParams.get('transferToLayingId');
const { data: transferToLaying, isLoading: isLoadingTransferToLaying } =
useSWR(transferToLayingId, (id: number) =>
TransferToLayingApi.getSingle(id)
);
if (!transferToLayingId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
// TODO: remove dummy data and integrate with real API
if (
!isLoadingTransferToLaying &&
(!transferToLaying ||
(isResponseError(transferToLaying) && !DUMMY_TRANSFER_TO_LAYING_DETAIL))
) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingTransferToLaying && (
<span className='loading loading-spinner loading-xl' />
)}
{/* {!isLoadingTransferToLaying && isResponseSuccess(transferToLaying) && (
<TransferToLayingForm
type='detail'
initialValues={transferToLaying.data}
/>
)} */}
{/* TODO: remove this dummy data and integrate to real API */}
<TransferToLayingForm
type='detail'
initialValues={DUMMY_TRANSFER_TO_LAYING_DETAIL}
/>
</div>
);
};
export default TransferToLayingDetail;
@@ -0,0 +1,11 @@
import TransferToLayingsTable from '@/components/pages/production/transfer-to-laying/TransferToLayingsTable';
const TransferToLaying = () => {
return (
<section className='w-full p-4'>
<TransferToLayingsTable />
</section>
);
};
export default TransferToLaying;
+80
View File
@@ -0,0 +1,80 @@
'use client';
import { HTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/helper';
export interface BadgeProps
extends Omit<HTMLAttributes<HTMLSpanElement>, 'className'> {
children?: ReactNode;
className?: {
badge?: string;
};
variant?: 'default' | 'outline' | 'ghost' | 'soft' | 'dash';
color?:
| 'neutral'
| 'primary'
| 'secondary'
| 'accent'
| 'info'
| 'success'
| 'warning'
| 'error';
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
}
const Badge = ({
children,
className,
variant = 'default',
color,
size = 'md',
...props
}: BadgeProps) => {
const getBadgeClasses = () => {
const baseClasses = 'badge';
const variantClasses = {
default: '',
outline: 'badge-outline',
ghost: 'badge-ghost',
soft: 'badge-soft',
dash: 'badge-dash',
};
const colorClasses = {
neutral: 'badge-neutral',
primary: 'badge-primary',
secondary: 'badge-secondary',
accent: 'badge-accent',
info: 'badge-info',
success: 'badge-success',
warning: 'badge-warning',
error: 'badge-error',
};
const sizeClasses = {
xs: 'badge-xs',
sm: 'badge-sm',
md: 'badge-md',
lg: 'badge-lg',
xl: 'badge-xl',
};
return cn(
baseClasses,
variantClasses[variant],
color && colorClasses[color],
sizeClasses[size],
className?.badge
);
};
return (
<span className={getBadgeClasses()} {...props}>
{children}
</span>
);
};
export default Badge;
+7 -3
View File
@@ -1,7 +1,5 @@
import react from 'react'; import react from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { Color } from '@/types/theme'; import { Color } from '@/types/theme';
@@ -10,6 +8,8 @@ interface ButtonProps extends react.ComponentProps<'button'> {
color?: Color; color?: Color;
href?: string; href?: string;
isLoading?: boolean; isLoading?: boolean;
target?: string;
rel?: string;
} }
const Button = ({ const Button = ({
@@ -22,6 +22,8 @@ const Button = ({
className, className,
disabled, disabled,
onClick, onClick,
target,
rel,
...props ...props
}: ButtonProps) => { }: ButtonProps) => {
const btnBaseClassName = cn( const btnBaseClassName = cn(
@@ -43,7 +45,7 @@ const Button = ({
'btn-warning': color === 'warning', 'btn-warning': color === 'warning',
'btn-error': color === 'error', 'btn-error': color === 'error',
}, },
'h-fit justify-center items-center gap-2 rounded-xl p-2 text-base transition-all' 'h-fit justify-center items-center gap-2 rounded p-2 text-base transition-all'
); );
return ( return (
@@ -68,6 +70,8 @@ const Button = ({
{href && ( {href && (
<Link <Link
href={disabled ? '#' : href} href={disabled ? '#' : href}
target={target}
rel={rel}
aria-disabled={disabled} aria-disabled={disabled}
className={cn( className={cn(
btnBaseClassName, btnBaseClassName,
+148
View File
@@ -0,0 +1,148 @@
'use client';
import { HTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/helper';
export interface CardProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'className'> {
title?: string;
subtitle?: string;
image?: string;
imageAlt?: string;
actions?: ReactNode;
footer?: ReactNode;
className?: {
wrapper?: string;
image?: string;
body?: string;
title?: string;
subtitle?: string;
actions?: string;
footer?: string;
};
variant?: 'default' | 'compact' | 'bordered' | 'shadow' | 'image-full';
size?: 'sm' | 'md' | 'lg';
}
const Card = ({
title,
subtitle,
image,
imageAlt,
actions,
footer,
className,
variant = 'default',
size = 'md',
children,
...props
}: CardProps) => {
const getCardClasses = () => {
const baseClasses = 'card bg-base-100';
const variantClasses = {
default: '',
compact: 'card-compact',
bordered: 'border border-base-300',
shadow: 'shadow-xl',
'image-full': 'card-side card-compact shadow-xl',
};
const sizeClasses = {
sm: 'w-64',
md: 'w-96',
lg: 'w-[28rem]',
};
return cn(
baseClasses,
variantClasses[variant],
variant !== 'image-full' ? sizeClasses[size] : '',
className?.wrapper
);
};
const getImageClasses = () => {
if (variant === 'image-full') {
return cn('w-32 h-32 object-cover', className?.image);
}
return cn('h-48 object-cover', className?.image);
};
const getBodyClasses = () => {
const baseClasses = 'card-body';
if (variant === 'compact' || variant === 'image-full') {
return cn(baseClasses, 'p-4', className?.body);
}
return cn(baseClasses, 'p-6', className?.body);
};
const getTitleClasses = () => {
const sizeClasses = {
sm: 'text-lg',
md: 'text-xl',
lg: 'text-2xl',
};
return cn('card-title font-bold', sizeClasses[size], className?.title);
};
const getSubtitleClasses = () => {
return cn('text-base-content/70 text-sm mt-1', className?.subtitle);
};
const getActionsClasses = () => {
return cn('card-actions justify-end mt-4', className?.actions);
};
const getFooterClasses = () => {
return cn('border-t border-base-300 mt-4 pt-4', className?.footer);
};
if (variant === 'image-full' && image) {
return (
<div className={getCardClasses()} {...props}>
<figure>
<img
src={image}
alt={imageAlt || title || 'Card image'}
className={getImageClasses()}
/>
</figure>
<div className={getBodyClasses()}>
{title && <h2 className={getTitleClasses()}>{title}</h2>}
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
{children}
{actions && <div className={getActionsClasses()}>{actions}</div>}
</div>
{footer && <div className={getFooterClasses()}>{footer}</div>}
</div>
);
}
return (
<div className={getCardClasses()} {...props}>
{image && (
<figure>
<img
src={image}
alt={imageAlt || title || 'Card image'}
className={getImageClasses()}
/>
</figure>
)}
<div className={getBodyClasses()}>
{title && <h2 className={getTitleClasses()}>{title}</h2>}
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
{children}
{actions && <div className={getActionsClasses()}>{actions}</div>}
</div>
{footer && <div className={getFooterClasses()}>{footer}</div>}
</div>
);
};
export default Card;
+1 -1
View File
@@ -68,7 +68,7 @@ export const Collapse = ({
'collapse', 'collapse',
variant === 'arrow' && 'collapse-arrow', variant === 'arrow' && 'collapse-arrow',
variant === 'plus' && 'collapse-plus', variant === 'plus' && 'collapse-plus',
bordered && 'border base-content/20 border-opacity-20 rounded-box', bordered && 'border base-content/20 border-opacity-20 rounded',
disabled && 'opacity-60 pointer-events-none', disabled && 'opacity-60 pointer-events-none',
!open && 'w-fit', !open && 'w-fit',
className className
+23 -1
View File
@@ -10,6 +10,7 @@ import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem'; import MenuItem from '@/components/menu/MenuItem';
import Navbar from '@/components/Navbar'; import Navbar from '@/components/Navbar';
import Collapse from '@/components/Collapse'; import Collapse from '@/components/Collapse';
import Button from '@/components/Button';
import { useUiStore } from '@/stores/ui/ui.store'; import { useUiStore } from '@/stores/ui/ui.store';
import { MAIN_DRAWER_LINKS } from '@/config/constant'; import { MAIN_DRAWER_LINKS } from '@/config/constant';
@@ -155,9 +156,15 @@ const MainDrawerMenu = () => {
}; };
const MainDrawerContent = () => { const MainDrawerContent = () => {
const { setMainDrawerOpen } = useUiStore();
const closeMainDrawerHandler = () => {
setMainDrawerOpen(false);
};
return ( return (
<div className='w-full p-4 flex flex-col gap-4'> <div className='w-full p-4 flex flex-col gap-4'>
<div className='flex items-center gap-4'> <div className='flex flex-row items-center gap-4'>
<Image <Image
src='/assets/img/lti-logo.png' src='/assets/img/lti-logo.png'
alt='MBU Logo' alt='MBU Logo'
@@ -167,6 +174,21 @@ const MainDrawerContent = () => {
/> />
<h1 className='text-xl font-bold'>LTI ERP</h1> <h1 className='text-xl font-bold'>LTI ERP</h1>
<div className='grow flex flex-row justify-end sm:hidden'>
<Button
variant='soft'
color='error'
onClick={closeMainDrawerHandler}
className='rounded-full'
>
<Icon
icon='material-symbols:close-rounded'
width={24}
height={24}
/>
</Button>
</div>
</div> </div>
<MainDrawerMenu /> <MainDrawerMenu />
+37 -23
View File
@@ -1,6 +1,13 @@
'use client'; 'use client';
import { ReactNode, RefObject, useCallback, useRef, useState } from 'react'; import {
ReactNode,
RefObject,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
export const useModal = () => { export const useModal = () => {
@@ -8,31 +15,34 @@ export const useModal = () => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const openModal = useCallback(() => { const openModal = useCallback(() => {
if (!ref.current) return;
ref.current.show();
setOpen(true); setOpen(true);
ref.current?.showModal();
}, []); }, []);
const closeModal = useCallback(() => { const closeModal = useCallback(() => {
if (!ref.current) return;
ref.current.close();
setOpen(false); setOpen(false);
ref.current?.close();
}, []); }, []);
const toggle = useCallback(() => { const toggle = useCallback(() => {
if (open) { open ? closeModal() : openModal();
closeModal();
} else {
openModal();
}
}, [open, closeModal, openModal]); }, [open, closeModal, openModal]);
if (ref.current) { useEffect(() => {
ref.current.addEventListener('close', () => { const dialog = ref.current;
closeModal(); if (!dialog) return;
});
}
return { ref, open, setOpen, openModal, closeModal, toggle } as const; const handleClose = () => setOpen(false);
dialog.addEventListener('close', handleClose);
return () => {
dialog.removeEventListener('close', handleClose);
};
}, []);
return { ref, open, openModal, closeModal, toggle } as const;
}; };
interface ModalProps { interface ModalProps {
@@ -46,15 +56,19 @@ interface ModalProps {
} }
const Modal = ({ ref, children, closeOnBackdrop, className }: ModalProps) => { const Modal = ({ ref, children, closeOnBackdrop, className }: ModalProps) => {
return ( const handleBackdropClick = (e: React.MouseEvent<HTMLDialogElement>) => {
<dialog ref={ref} className={cn('modal', className?.modal)}> if (closeOnBackdrop && e.target === ref.current) {
<div className={cn('modal-box', className?.modalBox)}>{children}</div> ref.current?.close();
}
};
{closeOnBackdrop && ( return (
<form method='dialog' className='modal-backdrop'> <dialog
<button>close</button> ref={ref}
</form> className={cn('modal', className?.modal)}
)} onClick={handleBackdropClick}
>
<div className={cn('modal-box', className?.modalBox)}>{children}</div>
</dialog> </dialog>
); );
}; };
+10 -10
View File
@@ -185,17 +185,17 @@ const Pagination = ({
currentPage <= 2 currentPage <= 2
? currentPage + 2 ? currentPage + 2
: currentPage === totalPages - 2 : currentPage === totalPages - 2
? 3 ? 3
: currentPage >= totalPages - 1 : currentPage >= totalPages - 1
? 4 ? 4
: 1 : 1
} }
endPage={ endPage={
currentPage <= 2 || currentPage >= totalPages - 1 currentPage <= 2 || currentPage >= totalPages - 1
? totalPages - 3 ? totalPages - 3
: currentPage === totalPages - 2 : currentPage === totalPages - 2
? totalPages - 4 ? totalPages - 4
: 2 : 2
} }
onPageItemClick={pageChangeHandler} onPageItemClick={pageChangeHandler}
/> />
@@ -242,15 +242,15 @@ const Pagination = ({
currentPage <= 3 currentPage <= 3
? currentPage + 2 ? currentPage + 2
: currentPage >= 4 : currentPage >= 4
? currentPage + 2 ? currentPage + 2
: 1 : 1
} }
endPage={ endPage={
currentPage <= 3 currentPage <= 3
? totalPages - 2 ? totalPages - 2
: currentPage >= 4 : currentPage >= 4
? totalPages - 1 ? totalPages - 1
: 0 : 0
} }
onPageItemClick={pageChangeHandler} onPageItemClick={pageChangeHandler}
/> />
+31
View File
@@ -0,0 +1,31 @@
import { ReactNode } from 'react';
import { cn } from '@/lib/helper';
interface PillBadgeProps {
content: ReactNode;
color?: 'yellow' | 'blue' | 'green' | 'red' | 'purple' | 'gray';
className?: string;
}
const PillBadge = ({ content, color = 'gray', className }: PillBadgeProps) => {
return (
<div
className={cn(
'w-fit min-w-max px-2 py-0.5 flex justify-center items-center gap-1 rounded-full border border-gray-200 bg-gray-50 text-gray-500 drop-shadow-xs capitalize',
{
'border-yellow-200 bg-yellow-50 text-yellow-500': color === 'yellow',
'border-blue-200 bg-blue-50 text-blue-500': color === 'blue',
'border-green-200 bg-green-50 text-green-500': color === 'green',
'border-red-200 bg-red-50 text-red-500': color === 'red',
'border-purple-200 bg-purple-50 text-purple-500': color === 'purple',
'border-neutral-200 bg-neutral-50 text-neutral-500': color === 'gray',
},
className
)}
>
{content}
</div>
);
};
export default PillBadge;
+13
View File
@@ -48,6 +48,8 @@ export interface TableProps<TData extends object> {
sorting?: SortingState; sorting?: SortingState;
setSorting?: OnChangeFn<SortingState>; setSorting?: OnChangeFn<SortingState>;
manualSorting?: boolean; manualSorting?: boolean;
rowSelection?: Record<string, boolean>;
setRowSelection?: OnChangeFn<Record<string, boolean>>;
} }
const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}]; const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}];
@@ -86,6 +88,8 @@ const Table = <TData extends object>({
sorting, sorting,
setSorting, setSorting,
manualSorting = false, manualSorting = false,
rowSelection,
setRowSelection,
}: TableProps<TData>) => { }: TableProps<TData>) => {
const isServerSideTable = const isServerSideTable =
totalItems !== undefined && totalItems !== undefined &&
@@ -137,6 +141,15 @@ const Table = <TData extends object>({
}; };
} }
if (rowSelection && setRowSelection) {
tableOptions.onRowSelectionChange = setRowSelection;
tableOptions.state = {
...tableOptions.state,
rowSelection,
};
tableOptions.getRowId = (row) => (row as { id: string }).id;
}
const table = useReactTable(tableOptions); const table = useReactTable(tableOptions);
const { setPageSize } = table; const { setPageSize } = table;
+129
View File
@@ -0,0 +1,129 @@
import { HTMLAttributes, ReactNode, useEffect, useState } from 'react';
import { cn } from '@/lib/helper';
export interface TabItem {
id: string;
label: ReactNode;
content?: ReactNode;
disabled?: boolean;
}
export interface TabsProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'className'> {
tabs: TabItem[];
variant?: 'bordered' | 'lifted' | 'boxed';
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
placement?: 'top' | 'bottom';
/** Tab yang aktif secara default (uncontrolled mode) */
defaultActiveId?: string;
/** Tab yang aktif (controlled mode, dikontrol parent) */
activeTabId?: string;
className?:
| string
| {
wrapper?: string;
tab?: string;
content?: string;
};
onTabChange?: (tabId: string) => void;
}
const Tabs = ({
tabs,
variant,
size = 'md',
placement = 'top',
defaultActiveId,
activeTabId: controlledActiveId,
className,
onTabChange,
...props
}: TabsProps) => {
// State internal hanya dipakai kalau `activeTabId` (controlled) tidak diset
const [uncontrolledActiveId, setUncontrolledActiveId] = useState(
defaultActiveId || tabs[0]?.id || ''
);
const isControlled = controlledActiveId !== undefined;
const activeTabId = isControlled ? controlledActiveId : uncontrolledActiveId;
const handleTabChange = (tabId: string) => {
if (tabId === activeTabId) return;
if (!isControlled) setUncontrolledActiveId(tabId);
onTabChange?.(tabId);
};
const { wrapper: wrapperClassName, tab: tabClassName } =
typeof className === 'object'
? className
: { wrapper: className, tab: undefined };
const getTabsClasses = () => {
const variantClasses: Record<string, string> = {
bordered: 'tabs-bordered',
lifted: 'tabs-lift',
boxed: 'tabs-box',
};
const sizeClasses: Record<string, string> = {
xs: 'tabs-xs',
sm: 'tabs-sm',
md: '',
lg: 'tabs-lg',
xl: 'tabs-xl',
};
const placementClasses: Record<string, string> = {
top: '',
bottom: 'tabs-bottom',
};
return cn(
'tabs',
variant && variantClasses[variant],
sizeClasses[size],
placementClasses[placement],
wrapperClassName
);
};
const getTabClasses = (isActive: boolean, isDisabled?: boolean) =>
cn(
'tab',
{
'tab-active': isActive,
'tab-disabled': isDisabled,
},
tabClassName
);
const activeContent = tabs.find((tab) => tab.id === activeTabId)?.content;
return (
<div
{...props}
className={cn(
'w-full',
typeof className === 'string' ? className : undefined
)}
>
<div role='tablist' className={getTabsClasses()}>
{tabs.map(({ id, label, disabled }) => (
<button
key={id}
role='tab'
className={getTabClasses(id === activeTabId, disabled)}
onClick={() => !disabled && handleTabChange(id)}
disabled={disabled}
>
{label}
</button>
))}
</div>
{activeContent && <div className='mt-4'>{activeContent}</div>}
</div>
);
};
export default Tabs;
+60
View File
@@ -0,0 +1,60 @@
import { ReactNode } from 'react';
import { cn } from '@/lib/helper';
import { Color } from '@/types/theme';
interface TooltipProps {
children?: ReactNode;
content?: ReactNode;
className?: {
wrapper?: string;
content?: string;
};
open?: boolean;
color?: Color;
position?: 'top' | 'bottom' | 'left' | 'right';
}
const Tooltip = ({
children,
content,
className,
open,
color,
position,
}: TooltipProps) => {
const tooltipBaseClassName = cn('tooltip', {
'tooltip-open': typeof open === 'boolean' && open,
'tooltip-top': position === 'top',
'tooltip-bottom': position === 'bottom',
'tooltip-left': position === 'left',
'tooltip-right': position === 'right',
'tooltip-primary': color === 'primary',
'tooltip-secondary': color === 'secondary',
'tooltip-accent': color === 'accent',
'tooltip-neutral': color === 'neutral',
'tooltip-info': color === 'info',
'tooltip-success': color === 'success',
'tooltip-warning': color === 'warning',
'tooltip-error': color === 'error',
});
return (
<div className={cn(tooltipBaseClassName, className?.wrapper)}>
<div
className={cn(
'tooltip-content',
'max-w-60 sm:max-w-xs',
className?.content
)}
>
{content}
</div>
{children}
</div>
);
};
export default Tooltip;
+2 -2
View File
@@ -158,7 +158,7 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
const { data: userResponse, isLoading: isLoadingUserResponse } = const { data: userResponse, isLoading: isLoadingUserResponse } =
useSWRImmutable<GetMeResponse & { ok?: boolean }, unknown, SWRHttpKey>( useSWRImmutable<GetMeResponse & { ok?: boolean }, unknown, SWRHttpKey>(
'/auth/get-me', '/auth/sso/userinfo',
httpClientFetcher, httpClientFetcher,
{ {
shouldRetryOnError: false, shouldRetryOnError: false,
@@ -194,4 +194,4 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
return <>{children}</>; return <>{children}</>;
}; };
export default RequireAuth; export default RequireAuth;
@@ -0,0 +1,87 @@
import { Icon } from '@iconify/react';
import { FormikContextType } from 'formik';
import Button from '@/components/Button';
import { cn } from '@/lib/helper';
interface FormActionsProps<T> {
type: 'add' | 'edit' | 'detail';
formik: FormikContextType<T>;
editUrl?: string;
onDelete?: () => void;
disableSubmit?: boolean;
}
export const FormActions = <T,>({
type,
formik,
editUrl,
onDelete,
disableSubmit = false,
}: FormActionsProps<T>) => {
return (
<div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && onDelete && (
<div className='flex flex-row justify-start gap-2'>
<Button
type='button'
color='error'
onClick={onDelete}
className='px-4'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
className='justify-start text-sm'
/>
Delete
</Button>
{type !== 'edit' && editUrl && (
<Button
type='button'
color='warning'
href={editUrl}
className='px-4'
>
<Icon
icon='material-symbols:edit-outline'
width={24}
height={24}
className='justify-start text-sm'
/>
Edit
</Button>
)}
</div>
)}
{type !== 'detail' && (
<div
className={cn('flex flex-row justify-end gap-2', {
'w-full': type === 'add',
})}
>
<Button
type='reset'
color='warning'
className='px-4'
onClick={() => {
formik.handleReset();
formik.validateForm();
}}
>
Reset
</Button>
<Button
type='submit'
color='primary'
className='px-4'
isLoading={formik.isSubmitting}
disabled={disableSubmit || !formik.isValid || formik.isSubmitting}
>
Submit
</Button>
</div>
)}
</div>
);
};
+37
View File
@@ -0,0 +1,37 @@
import Button from '@/components/Button';
import { Icon } from '@iconify/react';
interface FormHeaderProps {
type?: 'add' | 'edit' | 'detail';
title: string;
backUrl?: string;
onBackClick?: () => void;
}
export const FormHeader = ({
type,
title,
backUrl,
onBackClick,
}: FormHeaderProps) => {
return (
<header className='flex flex-col gap-4'>
<Button
type='button'
href={!onBackClick ? backUrl : undefined}
onClick={onBackClick}
variant='link'
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
Kembali
</Button>
<h1 className='text-2xl font-bold text-center'>
{type === 'add' && `Tambah ${title}`}
{type === 'edit' && `Edit ${title}`}
{type === 'detail' && `Detail ${title}`}
{!type && title}
</h1>
</header>
);
};
+89
View File
@@ -0,0 +1,89 @@
'use client';
import { HTMLProps, useEffect, useRef } from 'react';
import { cn } from '@/lib/helper';
interface CheckboxInputProps extends HTMLProps<HTMLInputElement> {
name: string;
label?: string;
indeterminate?: boolean;
classNames?: {
wrapper?: string;
inputWrapper?: string;
checkbox?: string;
label?: string;
};
isError?: boolean;
isValid?: boolean;
errorMessage?: string;
}
const CheckboxInput = ({
indeterminate,
name,
label,
className,
classNames,
isValid,
isError,
errorMessage,
...rest
}: CheckboxInputProps) => {
const ref = useRef<HTMLInputElement>(null!);
useEffect(() => {
if (typeof indeterminate === 'boolean') {
ref.current.indeterminate = !rest.checked && indeterminate;
}
}, [ref, indeterminate]);
return (
<div
className={cn('flex flex-col items-center gap-1', classNames?.wrapper)}
>
<div
className={cn(
'flex flex-row justify-center items-center gap-2',
classNames?.inputWrapper
)}
>
<input
type='checkbox'
ref={ref}
id={name}
name={name}
className={cn(
'checkbox cursor-pointer',
{
'border-error': isError,
'border-success': isValid,
},
className,
classNames?.checkbox
)}
{...rest}
/>
{label && (
<label
htmlFor={name}
className={cn(
'text-inherit',
{
'text-error': isError,
'text-success': isValid,
},
classNames?.label
)}
>
{label}
</label>
)}
</div>
{errorMessage && <span className='text-error'>{errorMessage}</span>}
</div>
);
};
export default CheckboxInput;
+330
View File
@@ -0,0 +1,330 @@
'use client';
import {
ChangeEventHandler,
FocusEventHandler,
useEffect,
useState,
} from 'react';
import { cn, formatDate } from '@/lib/helper';
import Modal, { useModal } from '../Modal';
import { DateRange, DayPicker, Matcher } from 'react-day-picker';
import 'react-day-picker/dist/style.css';
import Button from '../Button';
import { Icon } from '@iconify/react';
export interface DateInputProps {
label?: string;
bottomLabel?: string;
name: string;
value?: string | { from?: string; to?: string };
placeholder?: string;
min?: string;
max?: string;
className?: {
wrapper?: string;
label?: string;
inputWrapper?: string;
input?: string;
};
isError?: boolean;
isValid?: boolean;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
isLoading?: boolean;
isRange?: boolean;
errorMessage?: string;
onChange?: ChangeEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>;
}
const DateInput = ({
label,
bottomLabel,
name,
value,
placeholder = 'dd/mm/yyyy',
min,
max,
className,
isError: externalError,
isValid: externalValid,
errorMessage: externalErrorMessage,
disabled = false,
required = false,
onChange,
onBlur,
readOnly = false,
isLoading = false,
isRange = false,
}: DateInputProps) => {
const [internalError, setInternalError] = useState<string | null>(null);
const [selected, setSelected] = useState<Date | undefined>();
const [selectedRange, setSelectedRange] = useState<{
from?: Date;
to?: Date;
}>({});
const [displayValue, setDisplayValue] = useState<string>('');
const minDate = min
? new Date(min.split('/').reverse().join('-'))
: undefined;
const maxDate = max
? new Date(max.split('/').reverse().join('-'))
: undefined;
const calendarModal = useModal();
// --- Sync value props ---
useEffect(() => {
if (!value) return;
if (isRange && typeof value === 'object') {
const from = value.from ? new Date(value.from) : undefined;
const to = value.to ? new Date(value.to) : undefined;
setSelectedRange({ from, to });
setDisplayValue(
`${from ? formatDate(from, 'DD/MM/YYYY') : ''} ${
to ? '- ' + formatDate(to, 'DD/MM/YYYY') : ''
}`
);
} else if (typeof value === 'string') {
const iso = value.includes('/')
? value.split('/').reverse().join('-')
: value;
const date = new Date(iso);
setSelected(date);
setDisplayValue(formatDate(iso, 'DD/MM/YYYY'));
}
}, [value, isRange]);
const handleClick = (e: React.MouseEvent<HTMLInputElement>) => {
e.preventDefault();
if (!disabled && !readOnly) calendarModal.openModal();
};
const handleBlur: FocusEventHandler<HTMLInputElement> = (e) => {
onBlur?.(e);
};
const handleSelectSingle = (selectedDate?: Date) => {
if (!selectedDate) return;
if (minDate && selectedDate < minDate) {
setInternalError(`Tanggal tidak boleh sebelum ${min}`);
return;
}
if (maxDate && selectedDate > maxDate) {
setInternalError(`Tanggal tidak boleh setelah ${max}`);
return;
}
setInternalError(null);
setSelected(selectedDate);
const formattedDisplay = formatDate(selectedDate, 'DD/MM/YYYY');
const formattedISO = formatDate(selectedDate, 'YYYY-MM-DD');
setDisplayValue(formattedDisplay);
const syntheticEvent = {
target: { name, value: formattedISO },
} as unknown as React.ChangeEvent<HTMLInputElement>;
onChange?.(syntheticEvent);
calendarModal.closeModal();
};
const handleSelectRange = (range?: { from?: Date; to?: Date }) => {
if (!range) return;
setSelectedRange(range);
const fromStr = range.from ? formatDate(range.from, 'DD/MM/YYYY') : '';
const toStr = range.to ? formatDate(range.to, 'DD/MM/YYYY') : '';
setDisplayValue(`${fromStr}${toStr ? ' - ' + toStr : ''}`);
// Jika kedua tanggal sudah terpilih
if (range.from && range.to) {
if (minDate && range.from < minDate) {
setInternalError(`Tanggal mulai tidak boleh sebelum ${min}`);
return;
}
if (maxDate && range.to > maxDate) {
setInternalError(`Tanggal akhir tidak boleh setelah ${max}`);
return;
}
setInternalError(null);
const syntheticEvent = {
target: {
name,
value: {
from: formatDate(range.from, 'YYYY-MM-DD'),
to: formatDate(range.to, 'YYYY-MM-DD'),
},
},
} as unknown as React.ChangeEvent<HTMLInputElement>;
onChange?.(syntheticEvent);
}
};
const handleResetDate = () => {
setSelected(undefined);
setSelectedRange({});
setDisplayValue('');
const syntheticEvent = {
target: { name, value: isRange ? { from: '', to: '' } : '' },
} as unknown as React.ChangeEvent<HTMLInputElement>;
onChange?.(syntheticEvent);
calendarModal.closeModal();
};
const handleSaveDate = () => {
if (internalError) return;
calendarModal.closeModal();
};
const finalIsError = externalError || !!internalError;
const finalErrorMessage = internalError || externalErrorMessage;
return (
<div
className={cn(
'w-full flex flex-col gap-2 text-start',
className?.wrapper
)}
>
{label && (
<label
htmlFor={name}
className={cn(
'w-full text-sm font-normal leading-5',
{ 'text-error': finalIsError },
className?.label
)}
>
{label}
{required && (
<span className='text-error' title='required'>
*
</span>
)}
</label>
)}
<div
className={cn(
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded transition-all duration-200 flex items-center border',
{
'border-error': finalIsError,
'border-success': externalValid && !finalIsError,
},
className?.inputWrapper
)}
>
<input
type='text'
id={name}
name={name}
placeholder={isRange ? 'dd/mm/yyyy - dd/mm/yyyy' : placeholder}
value={displayValue}
onBlur={handleBlur}
onClick={handleClick}
disabled={disabled}
readOnly // ✅ tidak bisa diketik manual
className={cn(
'grow bg-transparent cursor-pointer focus:outline-none',
className?.input
)}
/>
{isLoading && (
<div className='flex flex-row gap-2'>
<span className='loading loading-spinner' />
</div>
)}
<Icon
icon='uil:calendar'
width={24}
height={24}
className='cursor-pointer text-dark'
onClick={(e) =>
handleClick(e as unknown as React.MouseEvent<HTMLInputElement>)
}
/>
</div>
{!finalIsError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)}
{finalIsError && finalErrorMessage && (
<p className='w-full text-sm text-error'>{finalErrorMessage}</p>
)}
<Modal
ref={calendarModal.ref}
className={{
modal: 'rounded',
modalBox: `w-fit min-h-${isRange ? '124' : '110'} flex flex-col`,
}}
closeOnBackdrop
>
{isRange ? (
<DayPicker
required={required}
mode='range'
captionLayout='dropdown-years'
navLayout='around'
reverseYears
defaultMonth={selectedRange.from ?? new Date()}
startMonth={minDate ?? new Date(1999, 1)}
endMonth={maxDate ?? new Date(new Date().getFullYear() + 5, 11)}
selected={selectedRange as DateRange}
onSelect={handleSelectRange}
footer={<div className='text-center mt-3'>{displayValue}</div>}
disabled={
[
minDate ? { before: minDate } : undefined,
maxDate ? { after: maxDate } : undefined,
].filter(Boolean) as Matcher[]
}
/>
) : (
<DayPicker
required={required}
mode='single'
captionLayout='dropdown-years'
navLayout='around'
reverseYears
defaultMonth={selected ?? new Date()}
startMonth={minDate ?? new Date(1999, 1)}
endMonth={maxDate ?? new Date(new Date().getFullYear() + 5, 11)}
selected={selected}
onSelect={handleSelectSingle}
disabled={
[
minDate ? { before: minDate } : undefined,
maxDate ? { after: maxDate } : undefined,
].filter(Boolean) as Matcher[]
}
/>
)}
<div className='mt-auto flex flex-col gap-2'>
{isRange && (
<small className='text-secondary'>
Tekan dua kali untuk memilih tanggal awal
</small>
)}
<div className='flex h-full justify-end items-end gap-2'>
<Button type='button' color='warning' onClick={handleResetDate}>
Reset
</Button>
{isRange && (
<Button type='button' onClick={handleSaveDate}>
Simpan
</Button>
)}
</div>
</div>
</Modal>
</div>
);
};
export default DateInput;
+1 -4
View File
@@ -69,10 +69,7 @@ const FileInput = ({
onChange={onChange} onChange={onChange}
onBlur={onBlur} onBlur={onBlur}
disabled={disabled} disabled={disabled}
className={cn( className={cn('grow file-input w-full h-12 rounded', className?.input)}
'grow file-input w-full h-12 rounded-lg!',
className?.input
)}
readOnly={readOnly} readOnly={readOnly}
/> />
+59
View File
@@ -0,0 +1,59 @@
'use client';
import { ChangeEvent, ReactNode } from 'react';
import { NumericFormat, OnValueChange } from 'react-number-format';
import TextInput, { TextInputProps } from '@/components/input/TextInput';
interface NumberInputProps extends Omit<TextInputProps, 'type'> {
thousandSeparator?: string;
decimalSeparator?: string;
decimalScale?: number;
allowNegative?: boolean;
prefix?: string;
suffix?: string;
fixedDecimalScale?: boolean;
inputPrefix?: ReactNode;
inputSuffix?: ReactNode;
}
const NumberInput = ({
thousandSeparator = ',',
decimalSeparator = '.',
decimalScale = 5,
allowNegative = true,
onChange,
inputPrefix,
inputSuffix,
...restProps
}: NumberInputProps) => {
const valueChangeHandler: OnValueChange = (
numberFormatValues,
sourceInfo
) => {
const newChangeEvent = sourceInfo.event as
| ChangeEvent<HTMLInputElement>
| undefined;
if (newChangeEvent) {
newChangeEvent.target.value = numberFormatValues.value;
onChange?.(newChangeEvent);
}
};
return (
<NumericFormat
thousandSeparator={thousandSeparator}
decimalSeparator={decimalSeparator}
customInput={TextInput}
onValueChange={valueChangeHandler}
decimalScale={decimalScale}
allowNegative={allowNegative}
startAdornment={inputPrefix}
endAdornment={inputSuffix}
{...restProps}
/>
);
};
export default NumberInput;
+90
View File
@@ -0,0 +1,90 @@
'use client';
import { ChangeEvent } from 'react';
import {
PatternFormat,
NumberFormatBase,
NumberFormatBaseProps,
OnValueChange,
} from 'react-number-format';
import TextInput, { TextInputProps } from '@/components/input/TextInput';
interface PatternInputProps extends Omit<TextInputProps, 'type'> {
/**
* Format pattern, contoh: "##/##/####", "(###) ###-####", "####-####-####"
*/
format: string;
/** Mask karakter kosong, misal "_" */
mask?: string;
/** Menampilkan mask walau value kosong */
allowEmptyFormatting?: boolean;
/** Placeholder karakter format, default: "#" */
patternChar?: string;
/** Jika true, izinkan huruf (A-Z) selain angka */
inputVehicleNumber?: boolean;
type?: 'text' | 'password' | 'tel';
}
/**
* PatternInput tetap backward-compatible dengan Storybook
* tapi bisa menerima huruf jika `allowCharacters={true}`
*/
const PatternInput = ({
type = 'text',
format,
mask = '_',
allowEmptyFormatting = false,
patternChar = '#',
inputVehicleNumber = false,
onChange,
...restProps
}: PatternInputProps) => {
const handleValueChange: OnValueChange = (values, { event }) => {
const newEvent = event as ChangeEvent<HTMLInputElement> | undefined;
if (newEvent) {
newEvent.target.value = values.value.toUpperCase();
onChange?.(newEvent);
}
};
if (inputVehicleNumber) {
return (
<NumberFormatBase
{...restProps}
type={type}
customInput={TextInput}
format={(value) => {
const clean = value.replace(/[^a-z0-9]/gi, '').toUpperCase();
const match = clean.match(/^([A-Z]{0,2})(\d{0,4})([A-Z]{0,3})$/);
if (!match) return clean;
const [, prefix, number, suffix] = match;
return [prefix, number, suffix].filter(Boolean).join(' ');
}}
removeFormatting={(val) => val.replace(/\s+/g, '')}
isValidInputCharacter={(char) => /^[a-z0-9]$/i.test(char)}
getCaretBoundary={(val) =>
Array(val.length + 1)
.fill(true)
.map(Boolean)
}
onValueChange={handleValueChange}
/>
);
}
return (
<PatternFormat
{...restProps}
type={type}
format={format}
mask={mask}
allowEmptyFormatting={allowEmptyFormatting}
patternChar={patternChar}
customInput={TextInput}
onValueChange={handleValueChange}
/>
);
};
export default PatternInput;
+122 -38
View File
@@ -1,23 +1,23 @@
'use client'; 'use client';
import { import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react';
ComponentType,
ReactNode,
useEffect,
useMemo,
useState,
} from 'react';
import Select, { import Select, {
OptionProps, OptionProps,
GroupBase, GroupBase,
InputActionMeta, InputActionMeta,
MultiValue, MultiValue,
SingleValue, SingleValue,
components as ReactSelectComponents,
ControlProps,
} from 'react-select'; } from 'react-select';
import CreatableSelect from 'react-select/creatable'; import CreatableSelect from 'react-select/creatable';
import makeAnimated from 'react-select/animated'; import makeAnimated from 'react-select/animated';
import { useDebounce } from 'use-debounce'; import { useDebounce } from 'use-debounce';
import { cn } from '@/lib/helper'; import { cn, getByPath } from '@/lib/helper';
import useSWR from 'swr';
import { httpClientFetcher } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import { isResponseSuccess } from '@/lib/api-helper';
export interface OptionType { export interface OptionType {
value: string | number; value: string | number;
@@ -54,6 +54,8 @@ interface SelectInputBaseProps<T = OptionType> {
openMenu?: boolean; openMenu?: boolean;
delay?: number; delay?: number;
onInputChange?: (search: string) => void; onInputChange?: (search: string) => void;
startAdornment?: ReactNode;
menuPortalTarget?: HTMLElement | null;
} }
interface SelectInputProps<T = OptionType> extends SelectInputBaseProps<T> { interface SelectInputProps<T = OptionType> extends SelectInputBaseProps<T> {
@@ -64,6 +66,33 @@ interface SelectInputProps<T = OptionType> extends SelectInputBaseProps<T> {
const animatedComponents = makeAnimated(); const animatedComponents = makeAnimated();
const CustomControl = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>,
>(
props: ControlProps<Option, IsMulti, Group>
) => {
const { children } = props;
const customProps = props.selectProps as unknown as {
shouldShowAdornment?: boolean;
startAdornment?: ReactNode;
};
const shouldShowAdornment = customProps.shouldShowAdornment ?? false;
const startAdornment = customProps.startAdornment;
return (
<ReactSelectComponents.Control {...props}>
<div className='flex-1 px-4! py-1.5 gap-1 flex items-center'>
{shouldShowAdornment && startAdornment}
{children}
</div>
</ReactSelectComponents.Control>
);
};
const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => { const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
const { const {
label, label,
@@ -88,20 +117,27 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
delay = 300, delay = 300,
createables = false, createables = false,
onInputChange, onInputChange,
startAdornment,
menuPortalTarget,
} = props; } = props;
const [internalInputValue, setInternalInputValue] = useState(''); const [internalInputValue, setInternalInputValue] = useState('');
const [debouncedInputValue] = useDebounce(internalInputValue, delay); const [debouncedInputValue] = useDebounce(internalInputValue, delay);
const shouldShowAdornment = startAdornment && !internalInputValue;
const components = useMemo(() => { const components = useMemo(() => {
const base = isAnimated ? animatedComponents : {}; const base = isAnimated ? animatedComponents : {};
return { ...base, IndicatorSeparator: () => null }; const customComponents = { ...base, IndicatorSeparator: () => null };
}, [isAnimated]);
const internalInputChangeHandler = ( if (startAdornment) {
val: string, customComponents.Control = CustomControl;
meta: InputActionMeta }
) => {
return customComponents;
}, [isAnimated, startAdornment]);
const internalInputChangeHandler = (val: string, meta: InputActionMeta) => {
if (meta.action === 'input-change') setInternalInputValue(val); if (meta.action === 'input-change') setInternalInputValue(val);
if (meta.action === 'menu-close') setInternalInputValue(''); if (meta.action === 'menu-close') setInternalInputValue('');
}; };
@@ -113,9 +149,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
const SelectComponent = createables ? CreatableSelect : Select; const SelectComponent = createables ? CreatableSelect : Select;
/** 🎯 handleChange tanpa any */ /** 🎯 handleChange tanpa any */
const handleChange = ( const handleChange = (val: MultiValue<T> | SingleValue<T>): void => {
val: MultiValue<T> | SingleValue<T>
): void => {
if (!val) { if (!val) {
onChange?.(null); onChange?.(null);
return; return;
@@ -145,21 +179,22 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
> >
{label} {label}
{required && ( {required && (
<span className="tooltip tooltip-error" data-tip="required"> <span className='tooltip tooltip-error' data-tip='required'>
<span className="text-error"> *</span> <span className='text-error'> *</span>
</span> </span>
)} )}
</span> </span>
)} )}
<SelectComponent<T, boolean, GroupBase<T>> <SelectComponent<T, boolean, GroupBase<T>>
instanceId="select" instanceId='select'
value={value ?? (isMulti ? [] : null)} value={value ?? (isMulti ? [] : null)}
onChange={handleChange} onChange={onChange ? handleChange : undefined}
options={options} options={options}
menuIsOpen={openMenu} menuIsOpen={openMenu}
inputValue={internalInputValue} inputValue={internalInputValue}
onInputChange={internalInputChangeHandler} onInputChange={internalInputChangeHandler}
onMenuClose={() => setInternalInputValue('')}
isMulti={isMulti} isMulti={isMulti}
isDisabled={isDisabled} isDisabled={isDisabled}
isLoading={isLoading} isLoading={isLoading}
@@ -169,17 +204,19 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
placeholder={placeholder} placeholder={placeholder}
className={cn('w-full', className?.select)} className={cn('w-full', className?.select)}
classNames={{ classNames={{
control: ({ isFocused, isDisabled }) => ...(!startAdornment && {
cn( control: ({ isFocused, isDisabled }) =>
'w-full min-h-12! rounded-lg! border bg-white transition-shadow cursor-pointer!', cn(
{ 'w-full min-h-12! rounded border bg-white transition-shadow cursor-pointer!',
'border-red-500! ring-2 ring-red-200': isError, {
'border-indigo-500 ring-2 ring-indigo-200': isFocused, 'border-red-500! ring-2 ring-red-200': isError,
'border-gray-300': !isError && !isFocused, 'border-indigo-500 ring-2 ring-indigo-200': isFocused,
'bg-gray-100 text-gray-400 cursor-not-allowed': isDisabled, 'border-gray-300': !isError && !isFocused,
} 'bg-gray-100 text-gray-400 cursor-not-allowed': isDisabled,
), }
valueContainer: () => cn('flex-1 px-4! py-2! gap-1'), ),
valueContainer: () => cn('flex-1 px-4! py-2! gap-1'),
}),
placeholder: () => placeholder: () =>
cn({ 'text-gray-400': !isError, 'text-red-300!': isError }), cn({ 'text-gray-400': !isError, 'text-red-300!': isError }),
singleValue: () => singleValue: () =>
@@ -187,13 +224,13 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
input: () => cn('text-gray-900'), input: () => cn('text-gray-900'),
indicatorsContainer: () => cn('flex items-center gap-1 pr-2'), indicatorsContainer: () => cn('flex items-center gap-1 pr-2'),
dropdownIndicator: ({ isFocused }) => dropdownIndicator: ({ isFocused }) =>
cn('p-1 rounded-md hover:bg-gray-100', { cn('p-1 rounded hover:bg-gray-100', {
'text-gray-900': isFocused, 'text-gray-900': isFocused,
'text-gray-500': !isFocused, 'text-gray-500': !isFocused,
'text-error!': isError, 'text-error!': isError,
}), }),
menu: () => menu: () =>
cn('border border-gray-200 rounded-lg bg-white shadow-lg!'), cn('border border-gray-200 rounded! bg-base-100 shadow-lg!'),
menuList: () => cn('p-2! max-h-60 overflow-auto'), menuList: () => cn('p-2! max-h-60 overflow-auto'),
option: ({ isFocused, isSelected }) => option: ({ isFocused, isSelected }) =>
cn('mt-1 px-3 py-2 rounded-md cursor-pointer!', { cn('mt-1 px-3 py-2 rounded-md cursor-pointer!', {
@@ -204,7 +241,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
multiValue: ({ getValue, index }) => { multiValue: ({ getValue, index }) => {
const selectedValues = getValue() as T[]; const selectedValues = getValue() as T[];
return cn( return cn(
'bg-indigo-50 rounded-md py-0.5 pl-2 pr-1 flex items-center gap-1!', 'bg-indigo-50 rounded py-0.5 pl-2 pr-1 flex items-center gap-1!',
selectedValues[index]?.className selectedValues[index]?.className
); );
}, },
@@ -217,20 +254,67 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
...components, ...components,
...(optionComponent ? { Option: optionComponent } : {}), ...(optionComponent ? { Option: optionComponent } : {}),
}} }}
{...(startAdornment && {
shouldShowAdornment,
startAdornment,
})}
menuPortalTarget={ menuPortalTarget={
typeof document !== 'undefined' ? document.body : undefined typeof document !== 'undefined'
? (menuPortalTarget ?? document.body)
: undefined
} }
styles={{ styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({ ...base, zIndex: 9999 }),
}} }}
/> />
{isError && <p className="w-full text-sm text-error">{errorMessage}</p>} {isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
{!isError && bottomLabel && ( {!isError && bottomLabel && (
<p className="w-full text-sm opacity-60">{bottomLabel}</p> <p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)} )}
</div> </div>
); );
}; };
const useSelect = <T,>(
basePath: string,
valueKey: keyof T | string,
labelKey: keyof T | string,
searchKey: string = 'search',
params?: { [key: string]: string }
) => {
const [inputValue, setInputValue] = useState('');
const optionsUrlParams = useMemo(() => {
return new URLSearchParams({
[searchKey]: inputValue ?? '',
...params,
}).toString();
}, [inputValue, searchKey, params]);
const optionsUrl = `${basePath}?${optionsUrlParams}`;
const { data, isLoading } = useSWR(optionsUrl, async (url) => {
return await httpClientFetcher<BaseApiResponse<T[]>>(url);
});
const options = isResponseSuccess(data)
? data.data.map((item) => {
return {
value: getByPath<T, number>(item, valueKey as string),
label: getByPath<T, string>(item, labelKey as string),
};
})
: [];
return {
inputValue,
setInputValue,
options,
isLoadingOptions: isLoading,
rawData: data,
};
};
export { useSelect };
export default SelectInput; export default SelectInput;
+29 -33
View File
@@ -1,10 +1,6 @@
'use client'; 'use client';
import { import { ChangeEventHandler, FocusEventHandler, ReactNode } from 'react';
ChangeEventHandler,
FocusEventHandler,
ReactNode,
} from 'react';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
@@ -52,7 +48,7 @@ const TextArea = ({
onBlur, onBlur,
readOnly = false, readOnly = false,
isLoading = false, isLoading = false,
rows = 3 rows = 3,
}: TextAreaProps) => { }: TextAreaProps) => {
return ( return (
<div <div
@@ -83,35 +79,35 @@ const TextArea = ({
)} )}
</label> </label>
)} )}
{startAdornment && startAdornment} {startAdornment && startAdornment}
<textarea <textarea
className={cn( className={cn(
'input h-auto px-4 py-2 text-base font-normal leading-6 w-full rounded-lg! outline-none! transition-all', 'input h-auto px-4 py-2 text-base font-normal leading-6 w-full rounded outline-none! transition-all bg-white',
{ {
'border-error': isError, 'border-error': isError,
'border-success!': isValid, 'border-success!': isValid,
}, },
className?.inputWrapper className?.inputWrapper
)}
id={name}
name={name}
placeholder={placeholder}
value={value}
rows={rows}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
readOnly={readOnly}
/>
{(isLoading || endAdornment) && (
<div className='flex flex-row gap-2'>
{isLoading && <span className='loading loading-spinner' />}
{endAdornment && endAdornment}
</div>
)} )}
id={name}
name={name}
placeholder={placeholder}
value={value}
rows={rows}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
readOnly={readOnly}
/>
{(isLoading || endAdornment) && (
<div className='flex flex-row gap-2'>
{isLoading && <span className='loading loading-spinner' />}
{endAdornment && endAdornment}
</div>
)}
{!isError && bottomLabel && ( {!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p> <p className='w-full text-sm opacity-60'>{bottomLabel}</p>
+1 -1
View File
@@ -87,7 +87,7 @@ const TextInput = ({
<div <div
className={cn( className={cn(
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded-lg! outline-none! transition-all duration-200', 'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded outline-none! transition-all duration-200',
{ {
'border-error': isError, 'border-error': isError,
'border-success!': isValid, 'border-success!': isValid,
+6 -2
View File
@@ -49,14 +49,18 @@ const MenuItem = ({
); );
return ( return (
<li onClick={onClick}> <li>
{href && ( {href && (
<Link href={href} className={menuItemBaseClassName}> <Link href={href} className={menuItemBaseClassName}>
{menuItemContent} {menuItemContent}
</Link> </Link>
)} )}
{!href && <a className={menuItemBaseClassName}>{menuItemContent}</a>} {!href && (
<button className={menuItemBaseClassName} onClick={onClick}>
{menuItemContent}
</button>
)}
</li> </li>
); );
}; };
+366
View File
@@ -0,0 +1,366 @@
import { Icon } from '@iconify/react';
import Steps from '@/components/steps/Steps';
import StepItem from '@/components/steps/StepItem';
import Tooltip from '@/components/Tooltip';
import { cn, formatDate } from '@/lib/helper';
import {
BaseApiResponse,
BaseApproval,
BaseGroupedApproval,
} from '@/types/api/api-general';
import { ApprovalLine } from '@/types/config/constant';
import useSWR from 'swr';
import { httpClientFetcher } from '@/services/http/client';
import { isResponseSuccess } from '@/lib/api-helper';
import { useCallback, useMemo } from 'react';
export type ApprovalStepStatus = 'APPROVED' | 'REJECTED' | 'WAITING' | 'IDLE';
export type ApprovalStepLog = {
action_by?: string;
date?: string;
notes?: string | null;
};
interface ApprovalStepsProps {
approvals: {
name?: string;
status: ApprovalStepStatus;
logs?: ApprovalStepLog[];
}[];
}
const ApprovalSteps = ({ approvals }: ApprovalStepsProps) => {
return (
<Steps direction='vertical' className='w-full md:steps-horizontal'>
{approvals.map((approval, idx) => {
const stepItemColor =
approval.status === 'APPROVED'
? 'success'
: approval.status === 'REJECTED'
? 'error'
: approval.status === 'WAITING'
? 'warning'
: undefined;
const stepItemIcon =
approval.status === 'APPROVED'
? 'material-symbols:check-rounded'
: approval.status === 'REJECTED'
? 'material-symbols:close-rounded'
: approval.status === 'WAITING'
? 'pajamas:dash-circle'
: approval.logs && approval.logs.length > 0
? 'material-symbols:info-outline-rounded'
: 'bxs:hourglass';
return (
<StepItem
key={idx}
color={stepItemColor}
icon={
<Tooltip
color={stepItemColor}
position='right'
className={{
wrapper: 'md:tooltip-bottom',
}}
content={
<>
{approval.logs && approval.logs.length > 0 && (
<div className='flex flex-col gap-2'>
{approval.logs?.map((approvalLog, logIdx) => (
<div
key={logIdx}
className='flex flex-col text-base text-start'
>
{approvalLog.date && (
<span>
{formatDate(
approvalLog.date,
'YYYY-MM-DD, HH:mm:ss'
)}
</span>
)}
<span>Oleh: {approvalLog.action_by ?? '-'}</span>
<span>Catatan: {approvalLog.notes ?? '-'}</span>
</div>
))}
</div>
)}
</>
}
>
<Icon
icon={stepItemIcon}
width={24}
height={24}
className={cn({
invisible:
approval.status === 'IDLE' &&
(!approval.logs ||
(approval.logs && approval.logs.length === 0)),
})}
/>
</Tooltip>
}
>
{approval.name}
</StepItem>
);
})}
</Steps>
);
};
export const formatGroupedApprovalsToApprovalSteps = (
approvalLine: ApprovalLine,
groupedApprovals: BaseGroupedApproval[],
latestApproval: BaseApproval
): ApprovalStepsProps['approvals'] => {
const formattedApprovalSteps: ApprovalStepsProps['approvals'] =
approvalLine.map((approvalLineItem) => {
const approvalGroup = groupedApprovals.find(
(approvalGroupItem) =>
approvalGroupItem.step_number === approvalLineItem.step_number
);
const currentStepNumber = approvalLineItem.step_number;
const lastStepNumber =
groupedApprovals[groupedApprovals.length - 1]?.step_number;
if (!approvalGroup && currentStepNumber <= lastStepNumber) {
throw new Error(
`Approval dengan ${approvalLineItem.step_name} tidak ditemukan!`
);
}
if (!approvalGroup) {
const isWaiting = currentStepNumber === latestApproval.step_number + 1;
return {
name: approvalLineItem.step_name,
status: isWaiting ? 'WAITING' : 'IDLE',
};
}
let approvalStatus: ApprovalStepStatus = 'IDLE';
if (approvalGroup.step_number <= latestApproval.step_number) {
if (approvalGroup.approvals) {
switch (approvalGroup?.approvals[0]?.action) {
case 'CREATED':
case 'APPROVED':
approvalStatus = 'APPROVED';
break;
case 'REJECTED':
approvalStatus = 'REJECTED';
break;
default:
approvalStatus = 'IDLE';
break;
}
}
} else if (approvalGroup.step_number === latestApproval.step_number + 1) {
approvalStatus = 'WAITING';
} else {
approvalStatus = 'IDLE';
}
const approvalLogs: ApprovalStepLog[] = approvalGroup.approvals
? approvalGroup.approvals.map((approval) => ({
action_by: approval.action_by.name,
date: approval.action_at,
notes: approval.notes,
}))
: [];
return {
name: approvalGroup.step_name,
status: approvalStatus,
logs: approvalLogs,
};
});
return formattedApprovalSteps;
};
export default ApprovalSteps;
/**
* Mengubah array BaseApproval (datar) menjadi BaseGroupedApproval (berkelompok).
*/
const groupApprovalsByStep = (
approvals: BaseApproval[]
): BaseGroupedApproval[] => {
const groups: Record<number, BaseGroupedApproval> = {};
for (const approval of approvals) {
if (!groups[approval.step_number]) {
groups[approval.step_number] = {
step_number: approval.step_number,
step_name: approval.step_name,
approvals: [],
};
}
groups[approval.step_number].approvals.push(approval);
}
return Object.values(groups);
};
/**
* Mengubah array BaseGroupedApproval (berkelompok) kembali menjadi BaseApproval[] (datar).
*/
const flattenGroupedApprovals = (
groupedApprovals: BaseGroupedApproval[]
): BaseApproval[] => {
return groupedApprovals.flatMap((group) => group.approvals);
};
/**
* Type guard untuk memeriksa apakah data adalah BaseGroupedApproval[].
*/
const isGroupedApprovalData = (
data: BaseApproval[] | BaseGroupedApproval[]
): data is BaseGroupedApproval[] => {
if (!data || data.length === 0) {
return true;
}
const firstElement = data[0];
return (
typeof firstElement === 'object' &&
firstElement !== null &&
'approvals' in firstElement &&
Array.isArray(firstElement.approvals)
);
};
const useApprovalSteps = ({
latestApproval,
approvalLines,
moduleName,
moduleId,
params,
}: {
latestApproval: BaseApproval | undefined;
approvalLines: ApprovalLine;
moduleName: string;
moduleId: string;
params?: {
page: number;
limit: number;
search?: string;
group_step_number?: boolean;
};
}) => {
// Membuat URL Parameters
const paramString = new URLSearchParams({
page: params?.page?.toString() || '',
limit: params?.limit?.toString() || '',
search: params?.search || '',
}).toString();
// fetching data approvals
const SWR_KEY_APPROVALS =
moduleName && moduleId
? `/approvals?module_name=${moduleName}&module_id=${moduleId}${
params ? `&${paramString}` : ''
}`
: null;
const {
data: approvalData,
isLoading: approvalIsLoading,
mutate: mutateApprovals,
} = useSWR(SWR_KEY_APPROVALS, async (url) => {
return await httpClientFetcher<
BaseApiResponse<BaseApproval[] | BaseGroupedApproval[]>
>(url);
});
// Fungsi Refresh
const refresh = useCallback(async () => {
await mutateApprovals();
}, [mutateApprovals]);
const { groupedApprovals } = useMemo(() => {
const rawData = isResponseSuccess(approvalData)
? approvalData.data
: undefined;
let processedGroupedApprovals: BaseGroupedApproval[] = [];
if (rawData) {
if (isGroupedApprovalData(rawData)) {
processedGroupedApprovals = rawData;
} else {
processedGroupedApprovals = groupApprovalsByStep(
rawData as BaseApproval[]
);
}
}
return {
groupedApprovals: processedGroupedApprovals,
};
}, [approvalData]);
const isLoading = approvalIsLoading;
// Formatting Akhir
const approvals = useMemo(() => {
if (isLoading || !approvalLines.length || !latestApproval) {
return [];
}
try {
return formatGroupedApprovalsToApprovalSteps(
approvalLines,
groupedApprovals,
latestApproval
);
} catch (error) {
console.warn('Gagal memformat approval steps:', error);
return [];
}
}, [isLoading, approvalLines, groupedApprovals, latestApproval]);
// Raw Data Approvals
const rawDataApprovals = useMemo(() => {
const rawData = isResponseSuccess(approvalData)
? approvalData.data
: undefined;
if (!rawData) {
return undefined;
}
const isDataCurrentlyGrouped = isGroupedApprovalData(rawData);
const wantsGrouped = params?.group_step_number !== false;
if (wantsGrouped) {
if (isDataCurrentlyGrouped) {
return rawData as BaseGroupedApproval[];
} else {
return groupApprovalsByStep(rawData as BaseApproval[]);
}
} else {
if (isDataCurrentlyGrouped) {
return flattenGroupedApprovals(rawData as BaseGroupedApproval[]);
} else {
return rawData as BaseApproval[];
}
}
}, [approvalData, params?.group_step_number]);
// Return Hook
return {
approvals,
isLoading,
rawDataApprovals: rawDataApprovals,
refresh,
};
};
export { useApprovalSteps };
@@ -10,11 +10,7 @@ import { inventoryAdjustmentApi } from '@/services/api/inventory';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { InventoryAdjustment } from '@/types/api/inventory/adjustment'; import { InventoryAdjustment } from '@/types/api/inventory/adjustment';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table';
ColumnDef,
ColumnSort,
SortingState,
} from '@tanstack/react-table';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
@@ -44,10 +40,7 @@ const InventoryAdjustmentTable = () => {
}); });
// Fetch Data // Fetch Data
const { const { data: inventoryAdjustments, isLoading } = useSWR(
data: inventoryAdjustments,
isLoading,
} = useSWR(
`${inventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`, `${inventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`,
inventoryAdjustmentApi.getAllFetcher inventoryAdjustmentApi.getAllFetcher
); );
@@ -113,8 +106,8 @@ const InventoryAdjustmentTable = () => {
type === 'INCREASE' type === 'INCREASE'
? 'Peningkatan' ? 'Peningkatan'
: type === 'DECREASE' : type === 'DECREASE'
? 'Penurunan' ? 'Penurunan'
: '-'; : '-';
return ( return (
<div <div
@@ -187,8 +180,13 @@ const InventoryAdjustmentTable = () => {
<div className='w-full p-0 sm:p-4'> <div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'> <div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'> <div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='flex flex-row'> <div className='w-full flex flex-row'>
<Button href='/inventory/adjustment/add' color='primary'> <Button
href='/inventory/adjustment/add'
variant='outline'
color='primary'
className='w-full sm:w-fit'
>
<Icon icon='ic:round-plus' width={24} height={24} /> <Icon icon='ic:round-plus' width={24} height={24} />
Tambah Tambah
</Button> </Button>
@@ -211,7 +209,7 @@ const InventoryAdjustmentTable = () => {
value: tableFilterState.pageSize, value: tableFilterState.pageSize,
}} }}
onChange={pageSizeChangeHandler} onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }} className={{ wrapper: 'min-w-28' }}
/> />
</div> </div>
</div> </div>
@@ -4,24 +4,21 @@ export const InventoryAdjustmentFormSchema = Yup.object({
product_category: Yup.object({ product_category: Yup.object({
value: Yup.number().required('ID Kategori Produk wajib diisi!'), value: Yup.number().required('ID Kategori Produk wajib diisi!'),
label: Yup.string().required('Nama Kategori Produk wajib diisi!'), label: Yup.string().required('Nama Kategori Produk wajib diisi!'),
}) }).nullable(),
.nullable(),
product_category_id: Yup.number().nullable(), product_category_id: Yup.number().nullable(),
product: Yup.object({ product: Yup.object({
value: Yup.number().required('ID Produk wajib diisi!'), value: Yup.number().required('ID Produk wajib diisi!'),
label: Yup.string().required('Nama Produk wajib diisi!'), label: Yup.string().required('Nama Produk wajib diisi!'),
}) }).nullable(),
.nullable(),
product_id: Yup.number().nullable(), product_id: Yup.number().nullable(),
warehouse: Yup.object({ warehouse: Yup.object({
value: Yup.number().required('ID Gudang wajib diisi!'), value: Yup.number().required('ID Gudang wajib diisi!'),
label: Yup.string().required('Nama Gudang wajib diisi!'), label: Yup.string().required('Nama Gudang wajib diisi!'),
}) }).nullable(),
.nullable(),
warehouse_id: Yup.number().nullable(), warehouse_id: Yup.number().nullable(),
@@ -13,7 +13,7 @@ import toast from 'react-hot-toast';
import { import {
InventoryAdjustmentFormSchema, InventoryAdjustmentFormSchema,
InventoryAdjustmentFormValues, InventoryAdjustmentFormValues,
} from './InventoryAdjustmentForm.schema'; } from '@/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.schema';
import useSWR from 'swr'; import useSWR from 'swr';
import { import {
ProductApi, ProductApi,
@@ -51,9 +51,8 @@ const InventoryAdjustmentForm = ({
// Submit Handler // Submit Handler
const createInventoryAdjustmentHandler = useCallback( const createInventoryAdjustmentHandler = useCallback(
async (payload: CreateInventoryAdjustmentPayload) => { async (payload: CreateInventoryAdjustmentPayload) => {
const createInventoryAdjustmentRes = await inventoryAdjustmentApi.create( const createInventoryAdjustmentRes =
payload await inventoryAdjustmentApi.create(payload);
);
if (isResponseError(createInventoryAdjustmentRes)) { if (isResponseError(createInventoryAdjustmentRes)) {
setInventoryAdjustmentFormErrorMessage( setInventoryAdjustmentFormErrorMessage(
@@ -68,7 +67,9 @@ const InventoryAdjustmentForm = ({
[router] [router]
); );
const formikInitialValues = useMemo<Partial<InventoryAdjustmentFormValues>>(() => { const formikInitialValues = useMemo<
Partial<InventoryAdjustmentFormValues>
>(() => {
return { return {
product_category_id: initialValues?.product_category?.id ?? 0, product_category_id: initialValues?.product_category?.id ?? 0,
product_id: initialValues?.product?.id ?? 0, product_id: initialValues?.product?.id ?? 0,
@@ -185,7 +186,6 @@ const InventoryAdjustmentForm = ({
warehouseChangeHandler(null); warehouseChangeHandler(null);
}; };
const { setValues: formikSetValues } = formik; const { setValues: formikSetValues } = formik;
// Effect // Effect
@@ -225,7 +225,13 @@ const InventoryAdjustmentForm = ({
const type = initialValues.transaction_type.toLowerCase(); const type = initialValues.transaction_type.toLowerCase();
setQuantityLabel(type === 'increase' ? 'Tambah Stok' : 'Kurangi Stok'); setQuantityLabel(type === 'increase' ? 'Tambah Stok' : 'Kurangi Stok');
} }
}, [formik, initialValues, setQuantityLabel, setDisabledProduct, setSelectedProductCategories]); }, [
formik,
initialValues,
setQuantityLabel,
setDisabledProduct,
setSelectedProductCategories,
]);
useEffect(() => { useEffect(() => {
formikSetValues(formikInitialValues as InventoryAdjustmentFormValues); formikSetValues(formikInitialValues as InventoryAdjustmentFormValues);
}, [formikSetValues, formikInitialValues]); }, [formikSetValues, formikInitialValues]);
@@ -364,15 +370,19 @@ const InventoryAdjustmentForm = ({
errorMessage={formik.errors.transaction_type as string} errorMessage={formik.errors.transaction_type as string}
variant='radio-primary' variant='radio-primary'
required required
bottomLabel={formik.values.transaction_type == undefined ? 'Pilih salah satu tipe transaksi' : undefined} bottomLabel={
formik.values.transaction_type == undefined
? 'Pilih salah satu tipe transaksi'
: undefined
}
disabled={type === 'detail'} disabled={type === 'detail'}
/> />
{/* Number Input Stock */} {/* Number Input Stock */}
<TextInput <TextInput
className={{ className={{
wrapper: `${formik.values.transaction_type != undefined ? '' : 'hidden'}`, wrapper: `${formik.values.transaction_type != undefined ? '' : 'hidden'}`,
}} }}
required required
label={quantityLabel} label={quantityLabel}
name='quantity' name='quantity'
@@ -395,8 +405,6 @@ const InventoryAdjustmentForm = ({
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
{/* Text Area Input Reason */} {/* Text Area Input Reason */}
<TextArea <TextArea
required required
@@ -413,14 +421,23 @@ const InventoryAdjustmentForm = ({
<div className='flex flex-row justify-between gap-2 flex-wrap'> <div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'detail' && ( {type !== 'detail' && (
<div className='flex flex-row justify-end gap-2'> <div className='flex flex-row justify-end gap-2'>
<Button type='button' color='warning' className='px-4' onClick={resetHandler}> <Button
type='button'
color='warning'
className='px-4'
onClick={resetHandler}
>
Reset Reset
</Button> </Button>
<Button <Button
type='submit' type='submit'
color='primary' color='primary'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting || formik.values.product == undefined} disabled={
!formik.isValid ||
formik.isSubmitting ||
formik.values.product == undefined
}
className='px-4' className='px-4'
> >
Submit Submit
@@ -0,0 +1,227 @@
'use client';
import { useState } from 'react';
import useSWR from 'swr';
import { SortingState } from '@tanstack/react-table';
import Table from '@/components/Table';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import { Movement } from '@/types/api/inventory/movement';
import { MovementApi } from '@/services/api/inventory';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
import { TableToolbar } from '@/components/table/TableToolbar';
import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector';
import { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import { TableRowOptions } from '@/components/table/TableRowOptions';
const MovementTable = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: { search: '' },
paramMap: { page: 'page', pageSize: 'limit' },
});
const [sorting, setSorting] = useState<SortingState>([]);
const [selectedMovement, setSelectedMovement] = useState<
Movement | undefined
>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const deleteModal = useModal();
const {
data: movements,
isLoading,
mutate: refreshMovements,
} = useSWR(
`${MovementApi.basePath}${getTableFilterQueryString()}`,
MovementApi.getAllFetcher
);
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('search', e.target.value);
setPage(1);
};
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
setPage(1);
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
try {
await MovementApi.delete(selectedMovement?.id as number);
refreshMovements();
deleteModal.closeModal();
} finally {
setIsDeleteLoading(false);
}
};
return (
<div className='flex flex-col gap-4'>
<div className='flex flex-col gap-2 mb-4'>
<TableToolbar
addButton={{
href: '/inventory/movement/add',
label: 'Tambah',
}}
search={{
value: tableFilterState.search,
onChange: searchChangeHandler,
placeholder: 'Cari Movement',
}}
/>
<TableRowSizeSelector
value={tableFilterState.pageSize}
onChange={pageSizeChangeHandler}
options={ROWS_OPTIONS}
/>
</div>
<Table<Movement>
data={isResponseSuccess(movements) ? movements?.data : []}
columns={[
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorFn: (row) => row.source_warehouse?.name,
header: 'Gudang Asal',
},
{
accessorFn: (row) => row.destination_warehouse?.name,
header: 'Gudang Tujuan',
},
{
accessorKey: 'transfer_reason',
header: 'Catatan',
},
{
accessorKey: 'transfer_date',
header: 'Tanggal',
cell: (props) =>
new Date(props.row.original.transfer_date).toLocaleDateString(
'id-ID'
),
},
{
accessorFn: (row) => {
const totalCost = row.deliveries?.reduce(
(sum, d) => sum + (d.shipping_cost_total || 0),
0
);
return totalCost?.toLocaleString('id-ID');
},
header: 'Biaya Pengiriman',
},
{
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 = () => {
setSelectedMovement(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<TableRowOptions
type='dropdown'
recordId={props.row.original.id}
basePath='/inventory/movement'
queryParam='movementId'
showEdit={false}
showDelete={false}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<TableRowOptions
type='collapse'
recordId={props.row.original.id}
basePath='/inventory/movement'
queryParam='movementId'
showEdit={false}
showDelete={false}
/>
</RowCollapseOptions>
)}
</>
);
},
},
]}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(movements) ? movements?.meta?.page : 0}
totalItems={
isResponseSuccess(movements) ? movements?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(movements) && movements?.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',
}}
/>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Movement ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
</div>
);
};
export default MovementTable;
@@ -0,0 +1,210 @@
import * as Yup from 'yup';
import { Movement } from '@/types/api/inventory/movement';
export type ProductSchema = {
product: {
value: number;
label: string;
} | null;
product_id: number;
product_qty: number;
};
export type DeliverySchema = {
delivery_cost?: number | undefined;
delivery_cost_per_item?: number | undefined;
document?: File | string | null;
document_path?: string | null;
driver_name: string;
vehicle_plate: string;
supplier: {
value: number;
label: string;
} | null;
supplier_id: number;
products: {
product: {
value: number;
label: string;
} | null;
product_id: number;
product_qty: number;
}[];
};
const ProductObjectSchema: Yup.ObjectSchema<ProductSchema> = Yup.object({
product: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
product_id: Yup.number().required('Produk wajib diisi!'),
product_qty: Yup.number()
.required('Qty wajib diisi!')
.min(1, 'Qty minimal 1!')
.typeError('Qty harus berupa angka!'),
});
const DeliveryProductObjectSchema = Yup.object({
product: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
product_id: Yup.number().required('Produk wajib diisi!'),
product_qty: Yup.number()
.required('Qty wajib diisi!')
.min(1, 'Qty minimal 1!')
.typeError('Qty harus berupa angka!'),
});
const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({
delivery_cost: Yup.number()
.transform((value) => (isNaN(value) || value === 0 ? undefined : value))
.min(1, 'Biaya minimal 1!')
.typeError('Biaya harus berupa angka!')
.test('one-of-cost-fields', 'Wajib diisi salah satu!', function (value) {
const { delivery_cost_per_item } = this.parent;
return (
(value !== undefined && value > 0) ||
(delivery_cost_per_item !== undefined && delivery_cost_per_item > 0)
);
}),
delivery_cost_per_item: Yup.number()
.transform((value) => (isNaN(value) || value === 0 ? undefined : value))
.min(1, 'Biaya per item minimal 1!')
.typeError('Biaya per item harus berupa angka!')
.test('one-of-cost-fields', 'Wajib diisi salah satu!', function (value) {
const { delivery_cost } = this.parent;
return (
(value !== undefined && value > 0) ||
(delivery_cost !== undefined && delivery_cost > 0)
);
}),
document_path: Yup.string().optional(),
document_index: Yup.number().optional(),
document: Yup.mixed<File | string>()
.nullable()
.test('fileSize', 'Ukuran dokumen maksimal 2 MB', (value) => {
if (!value) return true;
if (typeof value === 'string') return true;
if (value instanceof File) return value.size <= 2 * 1024 * 1024;
return false;
}),
driver_name: Yup.string().required('Nama sopir wajib diisi!'),
vehicle_plate: Yup.string().required('Plat nomor wajib diisi!'),
supplier: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
supplier_id: Yup.number().required('Supplier wajib diisi!'),
products: Yup.array()
.of(DeliveryProductObjectSchema)
.min(1, 'Minimal harus ada 1 produk!')
.required('Produk wajib diisi!'),
});
export const MovementFormSchema = Yup.object({
transfer_reason: Yup.string().required('Alasan transfer wajib diisi!'),
transfer_date: Yup.string().required('Tanggal transfer wajib diisi!'),
source_warehouse: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
area: Yup.string().optional(),
location: Yup.string().optional(),
}).nullable(),
source_warehouse_id: Yup.number()
.required('Gudang asal wajib diisi!')
.typeError('Gudang asal wajib diisi!'),
destination_warehouse: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
area: Yup.string().optional(),
location: Yup.string().optional(),
}).nullable(),
destination_warehouse_id: Yup.number()
.required('Gudang tujuan wajib diisi!')
.typeError('Gudang tujuan wajib diisi!'),
products: Yup.array()
.of(ProductObjectSchema)
.min(1, 'Minimal harus ada 1 produk!')
.required('Produk wajib diisi!'),
deliveries: Yup.array()
.of(DeliveryObjectSchema)
.min(1, 'Minimal harus ada 1 pengiriman!')
.required('Pengiriman wajib diisi!'),
});
export const UpdateMovementFormSchema = MovementFormSchema;
export type MovementFormValues = Yup.InferType<typeof MovementFormSchema>;
export const getMovementFormInitialValues = (
initialValues?: Movement
): MovementFormValues => {
const detailIdToProductId = new Map<number, { id: number; name: string }>();
initialValues?.details?.forEach((detail) => {
detailIdToProductId.set(detail.id, {
id: detail.product.id,
name: detail.product.name,
});
});
return {
transfer_reason: initialValues?.transfer_reason ?? '',
transfer_date: initialValues?.transfer_date ?? '',
source_warehouse: initialValues?.source_warehouse
? {
value: initialValues.source_warehouse.id,
label: initialValues.source_warehouse.name,
area: initialValues.source_warehouse.area?.name ?? undefined,
location: initialValues.source_warehouse.location?.name ?? undefined,
}
: null,
source_warehouse_id: initialValues?.source_warehouse?.id ?? 0,
destination_warehouse: initialValues?.destination_warehouse
? {
value: initialValues.destination_warehouse.id,
label: initialValues.destination_warehouse.name,
area: initialValues.destination_warehouse.area?.name ?? undefined,
location:
initialValues.destination_warehouse.location?.name ?? undefined,
}
: null,
destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0,
products:
initialValues?.details?.map((detail) => ({
product: {
value: detail.product.id,
label: detail.product.name,
},
product_id: detail.product.id,
product_qty: detail.quantity,
})) ?? [],
deliveries:
initialValues?.deliveries?.map((d) => ({
delivery_cost: d.shipping_cost_total ?? undefined,
delivery_cost_per_item: d.shipping_cost_item ?? undefined,
document_number: d.document_number ?? '',
document: d.document_path ?? null,
document_path: d.document_path ?? null,
driver_name: d.driver_name ?? '',
vehicle_plate: d.vehicle_plate ?? '',
supplier: d.supplier
? { value: d.supplier.id, label: d.supplier.name }
: null,
supplier_id: d.supplier?.id ?? 0,
products:
d.items?.map((item) => {
const productData = detailIdToProductId.get(
item.stock_transfer_detail_id
);
return {
product: productData
? { value: productData.id, label: productData.name }
: null,
product_id: productData?.id ?? 0,
product_qty: item.quantity,
};
}) ?? [],
})) ?? [],
};
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,95 @@
import { useCallback, useState } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'react-hot-toast';
import { useModal } from '@/components/Modal';
import { MovementApi } from '@/services/api/inventory';
import {
CreateMovementPayload,
UpdateMovementPayload,
} from '@/types/api/inventory/movement';
import { isResponseError } from '@/lib/api-helper';
export const useMovementFormHandlers = (initialValuesId?: number) => {
const router = useRouter();
const deleteModal = useModal();
const [movementFormErrorMessage, setMovementFormErrorMessage] = useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const createMovementHandler = useCallback(
async (payload: CreateMovementPayload, documents: File[] = []) => {
const formData = new FormData();
formData.append('data', JSON.stringify(payload));
documents.forEach((file, index) => {
formData.append(`documents[${index}]`, file);
});
const res = await MovementApi.create(
formData as unknown as CreateMovementPayload
);
if (isResponseError(res)) {
setMovementFormErrorMessage(res.message);
return;
}
toast.success(res?.message as string);
router.push('/inventory/movement');
},
[router]
);
const updateMovementHandler = useCallback(
async (
movementId: number,
payload: UpdateMovementPayload,
documents: File[] = []
) => {
let finalPayload: UpdateMovementPayload | FormData;
if (documents.length > 0) {
const formData = new FormData();
formData.append('data', JSON.stringify(payload));
documents.forEach((file, index) => {
formData.append(`documents[${index}]`, file);
});
finalPayload = formData as unknown as UpdateMovementPayload;
} else {
finalPayload = payload;
}
const res = await MovementApi.update(movementId, finalPayload);
if (res?.status === 'error') {
setMovementFormErrorMessage(res.message);
return;
}
toast.success(res?.message as string);
router.refresh();
router.push('/inventory/movement');
},
[router]
);
const deleteMovementClickHandler = useCallback(() => {
deleteModal.openModal();
}, [deleteModal]);
const confirmationModalDeleteClickHandler = useCallback(async () => {
if (!initialValuesId) return;
setIsDeleteLoading(true);
await MovementApi.delete(initialValuesId);
deleteModal.closeModal();
toast.success('Successfully delete Movement!');
setIsDeleteLoading(false);
router.push('/inventory/movement');
}, [deleteModal, initialValuesId, router]);
return {
deleteModal,
movementFormErrorMessage,
isDeleteLoading,
createMovementHandler,
updateMovementHandler,
deleteMovementClickHandler,
confirmationModalDeleteClickHandler,
};
};
@@ -0,0 +1,406 @@
'use client';
import Button from '@/components/Button';
import CheckboxInput from '@/components/input/CheckboxInput';
import { OptionType } from '@/components/input/SelectInput';
import Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import Table from '@/components/Table';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector';
import { TableToolbar } from '@/components/table/TableToolbar';
import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatVechicleNumber } from '@/lib/helper';
import { MarketingApi } from '@/services/api/marketing/marketing';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Marketing, MarketingProduct } from '@/types/api/marketing/marketing';
import { Customer } from '@/types/api/master-data/customer';
import { Icon } from '@iconify/react';
import { CellContext } from '@tanstack/react-table';
import { useCallback, useState } from 'react';
import useSWR from 'swr';
const RowsOptionsMenu = ({
type = 'dropdown',
props,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<Marketing, unknown>;
deleteClickHandler: () => void;
}) => {
return (
<div
tabIndex={type === 'dropdown' ? 0 : undefined}
className={cn(
{
'dropdown-content': type === 'dropdown',
'mt-2': type === 'collapse',
},
'p-2.5 mr-2 bg-base-100 rounded-box z-10 border border-black/10 shadow'
)}
>
<div className='flex flex-col gap-1'>
<Button
href={`/marketing/sales-orders/detail/?salesOrderId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
<Button
href={`/marketing/sales-orders/detail/edit/?salesOrderId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='mdi:pencil-outline' width={16} height={16} />
Edit
</Button>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='text-error hover:text-inherit justify-start text-sm'
>
<Icon icon='mdi:delete-outline' width={16} height={16} />
Delete
</Button>
</div>
</div>
);
};
const SalesOrderTable = () => {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [approveAction, setApproveAction] = useState<
'approve' | 'reject' | null
>(null);
const [selectedItem, setSelectedItem] = useState<Marketing | null>(null);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const selectedRowIds = Object.keys(rowSelection).filter(
(id) => rowSelection[id]
);
const {
data: marketing,
isLoading: isLoadingMarketing,
mutate: refreshMarketing,
} = useSWR(MarketingApi.basePath, MarketingApi.getAllFetcher);
const deleteModal = useModal();
const confirmationModal = useModal();
const productsModal = useModal();
const searchChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
setPage(1);
},
[]
);
const pageSizeChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
setPage(1);
},
[]
);
const approveClickHandler = () => {
setApproveAction('approve');
confirmationModal.openModal();
};
const rejectClickHandler = () => {
setApproveAction('reject');
confirmationModal.openModal();
};
const productsClickHandler = (item: Marketing) => {
setSelectedItem(item);
productsModal.openModal();
};
const {
state: tableFilterState,
updateFilter,
toQueryString: getTableFilterToQueryString,
} = useTableFilter({
initial: {
search: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
});
return (
<>
<div className='flex flex-col gap-4'>
<div className='flex flex-col gap-2 mb-4'>
<TableToolbar
addButton={{
href: '/marketing/sales-orders/add',
label: 'Tambah Sales Order',
}}
search={{
value: search,
onChange: searchChangeHandler,
placeholder: 'Cari Sales Order',
}}
/>
<TableRowSizeSelector
value={pageSize}
onChange={pageSizeChangeHandler}
options={ROWS_OPTIONS}
/>
<div className='flex flex-row gap-2'>
<Button
color='success'
onClick={approveClickHandler}
className='justify-start text-sm'
disabled={!selectedRowIds.length}
>
<Icon icon='material-symbols:check' width={24} height={24} />
Approve
</Button>
<Button
color='error'
onClick={rejectClickHandler}
className='justify-start text-sm'
disabled={!selectedRowIds.length}
>
<Icon icon='material-symbols:close' width={24} height={24} />
Reject
</Button>
</div>
</div>
<Table
rowSelection={rowSelection}
setRowSelection={setRowSelection}
data={isResponseSuccess(marketing) ? marketing.data : []}
columns={[
{
id: 'select',
header: ({ table }) => (
<div className='w-full flex flex-row justify-center'>
<CheckboxInput
name='allRow'
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
</div>
),
cell: ({ row }) => (
<div>
<CheckboxInput
name='row'
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
</div>
),
},
{
accessorKey: 'so_number',
header: 'No. Order',
},
{
accessorKey: 'so_date',
header: 'Tanggal',
},
{
accessorKey: 'approval.step_name',
header: 'Status',
},
{
accessorKey: 'customer.name',
header: 'Customer',
},
{
accessorKey: 'grand_total',
header: 'Grand Total',
},
{
accessorKey: 'marketing_products.length',
header: 'Product Details',
cell: (props) => {
if (props?.row?.original?.marketing_products?.length) {
if (props?.row?.original?.marketing_products?.length > 1) {
return (
<Button
variant='link'
color='success'
className='p-0 text-none'
onClick={() => {
productsClickHandler(props?.row?.original);
}}
>
Lihat {props?.row?.original?.marketing_products?.length}{' '}
Produk
</Button>
);
} else {
const product = props?.row?.original?.marketing_products[0];
return <>{product?.product_warehouse?.product?.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 = () => {};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowsOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowsOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
]}
pageSize={pageSize}
page={page}
onPageChange={setPage}
className={{
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',
}}
/>
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Project Flock ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
}}
/>
<ConfirmationModal
ref={confirmationModal.ref}
type={approveAction === 'approve' ? 'success' : 'error'}
text={`Apakah anda yakin ingin ${approveAction} data penjualan (${selectedRowIds.length} data)?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: approveAction === 'approve' ? 'success' : 'error',
}}
/>
<Modal
ref={productsModal.ref}
className={{
modalBox: 'max-w-2/5 z-100',
}}
closeOnBackdrop
>
<div className='flex flex-row justify-between items-center mb-3'>
<h4 className='text-xl font-semibold'>Daftar Produk</h4>
<Button
variant='ghost'
color='error'
onClick={productsModal.closeModal}
className='justify-start text-sm rounded-full'
>
<Icon icon='mdi:close' width={16} height={16} />
</Button>
</div>
<Table<MarketingProduct>
data={
isResponseSuccess(marketing) && selectedItem
? (selectedItem?.marketing_products ?? [])
: []
}
columns={[
{
header: 'Kandang',
accessorFn(row) {
return row.product_warehouse.warehouse.name;
},
},
{
header: 'Produk',
accessorFn(row) {
return row.product_warehouse.product.name;
},
},
{
header: 'Harga Satuan (Rp)',
accessorFn(row) {
return formatCurrency(row.unit_price);
},
},
]}
className={{
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',
paginationClassName: 'hidden',
}}
/>
</Modal>
</>
);
};
export default SalesOrderTable;
@@ -0,0 +1,308 @@
'use client';
import Button from '@/components/Button';
import Card from '@/components/Card';
import { FormHeader } from '@/components/helper/form/FormHeader';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import Table from '@/components/Table';
import {
cn,
formatCurrency,
formatNumber,
formatVechicleNumber,
} from '@/lib/helper';
import { MarketingApi } from '@/services/api/marketing/marketing';
import { Marketing, MarketingProduct } from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react';
import { useState } from 'react';
import toast from 'react-hot-toast';
const SalesOrderDetail = ({
initialValues,
refreshValues,
}: {
initialValues?: Marketing;
refreshValues?: () => void;
}) => {
const [approvalAction, setApprovalAction] = useState<'approve' | 'reject'>(
'approve'
);
const [isLoading, setIsLoading] = useState(false);
const deleteModal = useModal();
const confirmationModal = useModal();
const deliveryModal = useModal();
const approveClickHandler = () => {
setApprovalAction('approve');
confirmationModal.openModal();
};
const rejectClickHandler = () => {
setApprovalAction('reject');
confirmationModal.openModal();
};
const deliveryClickHandler = () => {
deliveryModal.openModal();
};
const deleteClickHandler = () => {
deleteModal.openModal();
};
const confirmationModalDeleteClickHandler = async () => {
setIsLoading(true);
// await MarketingApi.delete(initialValues?.id as number);
setIsLoading(false);
deleteModal.closeModal();
toast.success('Successfully deleted Sales Order!');
refreshValues?.();
};
const confirmationModalApproveClickHandler = async () => {
setIsLoading(true);
// await MarketingApi.singleApproval(
// initialValues?.id as number,
// approvalAction
// );
setIsLoading(false);
confirmationModal.closeModal();
toast.success('Successfully approved Sales Order!');
refreshValues?.();
};
const confirmationModalDeliveryClickHandler = async () => {
setIsLoading(true);
// await MarketingApi.delivery(initialValues?.id as number);
setIsLoading(false);
deliveryModal.closeModal();
toast.success('Successfully delivered Sales Order!');
refreshValues?.();
};
return (
<>
<div className='flex flex-col w-full gap-4'>
<FormHeader
title='Detail Sales Order'
backUrl='/marketing/sales-orders'
/>
<div className='flex-row flex gap-3'>
{initialValues?.approval?.step_number != 3 && (
<>
<Button
color='success'
onClick={approveClickHandler}
disabled={initialValues?.approval?.step_number != 1}
>
<Icon icon='mdi:check' width={24} height={24} />
Approve
</Button>
<Button
color='error'
onClick={rejectClickHandler}
disabled={initialValues?.approval?.step_number != 2}
>
<Icon icon='mdi:close' width={24} height={24} />
Reject
</Button>
</>
)}
{initialValues?.approval?.step_number == 2 && (
<Button color='success' onClick={deliveryClickHandler}>
<Icon icon='mdi:check' width={24} height={24} />
Delivery Order
</Button>
)}
</div>
<Card
title='Informasi Sales Order'
className={{
wrapper: 'w-full bg-white',
}}
>
<div className='overflow-x-auto rounded-box border border-base-content/5 bg-base-100 mt-3'>
<table className='table'>
<tbody>
<tr>
<td width='45%' className='font-semibold'>
No. Sales Order
</td>
<td>:</td>
<td width='50%'>{initialValues?.so_number}</td>
</tr>
<tr>
<td className='font-semibold'>Nama Pelanggan</td>
<td>:</td>
<td>{initialValues?.customer?.name}</td>
</tr>
<tr>
<td className='font-semibold'>Status</td>
<td>:</td>
<td>{initialValues?.approval?.step_name}</td>
</tr>
<tr>
<td className='font-semibold'>Tanggal Penjualan</td>
<td>:</td>
<td>{initialValues?.so_date}</td>
</tr>
<tr>
<td className='font-semibold'>Total Penjualan</td>
<td>:</td>
<td>
{formatCurrency(initialValues?.grand_total as number)}
</td>
</tr>
<tr>
<td className='font-semibold'>Catatan</td>
<td>:</td>
<td>{initialValues?.notes ?? '-'}</td>
</tr>
</tbody>
</table>
</div>
</Card>
{initialValues?.marketing_products && (
<Card
title='Daftar Produk'
className={{
wrapper: 'w-full bg-white',
}}
>
<Table<MarketingProduct>
data={initialValues?.marketing_products}
columns={[
{
header: 'No. Polisi',
accessorFn(row) {
return formatVechicleNumber(
row.marketing_delivery_products?.vehicle_number as string
);
},
},
{
header: 'Kandang',
accessorFn(row) {
return row.product_warehouse.warehouse.name;
},
},
{
header: 'Produk',
accessorFn(row) {
return row.product_warehouse.product.name;
},
},
{
header: 'Harga Satuan (Rp)',
accessorFn(row) {
return formatCurrency(row.unit_price);
},
},
{
header: 'Total Bobot (Kg)',
accessorFn(row) {
return formatNumber(row.total_weight);
},
},
{
header: 'Kuantitas',
accessorFn(row) {
return formatNumber(row.qty);
},
},
{
header: 'Avg. Bobot (Kg)',
accessorFn(row) {
return formatNumber(row.avg_weight);
},
},
{
header: 'Total Penjualan (Rp)',
accessorFn(row) {
return formatCurrency(row.total_price);
},
},
]}
className={{
containerClassName: cn({
'mb-20':
initialValues?.marketing_products &&
initialValues?.marketing_products?.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',
paginationClassName: 'hidden',
}}
/>
</Card>
)}
<div className='flex flex-row gap-3'>
<Button
color='warning'
type='button'
href={`/marketing/sales-orders/detail/edit?salesOrderId=${initialValues?.id}`}
>
<Icon icon='mdi:pencil' width={24} height={24} />
Edit
</Button>
<Button color='error' onClick={deleteClickHandler}>
<Icon icon='mdi:delete' width={24} height={24} />
Hapus
</Button>
</div>
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data penjualan ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
<ConfirmationModal
ref={confirmationModal.ref}
type={approvalAction === 'approve' ? 'success' : 'error'}
text={`Apakah anda yakin ingin ${approvalAction} data penjualan ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: approvalAction === 'approve' ? 'success' : 'error',
isLoading: isLoading,
onClick: confirmationModalApproveClickHandler,
}}
/>
<ConfirmationModal
ref={deliveryModal.ref}
type={'success'}
text={`Apakah anda yakin ingin deliver penjualan ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'success',
isLoading: isLoading,
onClick: confirmationModalDeliveryClickHandler,
}}
/>
</>
);
};
export default SalesOrderDetail;
@@ -0,0 +1,38 @@
import * as Yup from 'yup';
import { MarketingProduct } from '@/types/api/marketing/marketing';
import {
MarketingProductFormValues,
MarketingProductSchema,
} from './repeater/MarketingProduct.schema';
type MarketingSchema = {
customer_id: number | undefined;
customer:
| {
value: number;
label: string;
}
| undefined
| null;
so_date: string | undefined;
notes: string | undefined;
marketing_products: MarketingProductFormValues[];
};
export const MarketingSchema: Yup.ObjectSchema<MarketingSchema> = Yup.object({
customer_id: Yup.number().required('Customer wajib diisi!'),
customer: Yup.object({
value: Yup.number().required(),
label: Yup.string().required(),
}).nullable(),
so_date: Yup.string().required('Tanggal wajib diisi!'),
notes: Yup.string().required('Catatan wajib diisi!'),
marketing_products: Yup.array()
.of(MarketingProductSchema)
.min(1, 'Minimal harus ada 1 produk!')
.required('Produk wajib diisi!'),
});
export const UpdateMarketingSchema = MarketingSchema;
export type MarketingFormValues = Yup.InferType<typeof MarketingSchema>;
@@ -0,0 +1,515 @@
'use client';
import Button from '@/components/Button';
import Card from '@/components/Card';
import { FormHeader } from '@/components/helper/form/FormHeader';
import DateInput from '@/components/input/DateInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import TextArea from '@/components/input/TextArea';
import Modal, { useModal } from '@/components/Modal';
import * as TanStack from '@tanstack/react-table';
import Table from '@/components/Table'; // Keep this import
import { cn, formatCurrency, formatNumber } from '@/lib/helper';
import {
CreateMarketingPayload,
CreateMarketingProductPayload,
Marketing,
MarketingProduct,
} from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import MarketingProductForm from './repeater/MarketingProductForm';
import CheckboxInput from '@/components/input/CheckboxInput';
import { Customer } from '@/types/api/master-data/customer';
import { CustomerApi } from '@/services/api/master-data';
import { useFormik } from 'formik';
import { MarketingFormValues, MarketingSchema } from './SalesForm.schema';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { MarketingApi } from '@/services/api/marketing/marketing';
import { MarketingProductFormValues } from './repeater/MarketingProduct.schema';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation';
const SalesForm = ({
formType = 'add',
initialValues,
}: {
formType?: 'add' | 'edit';
initialValues?: Marketing;
}) => {
const router = useRouter();
const addProductModal = useModal();
const deleteModal = useModal();
const [isLoading, setIsLoading] = useState(false);
const [selectedMarketingProduct, setSelectedMarketingProduct] =
useState<MarketingProduct | null>(null);
const [rawMarketingProducts, setRawMarketingProducts] = useState<
MarketingProduct[]
>(initialValues?.marketing_products || []);
const [selectedCustomer, setSelectedCustomer] = useState<OptionType | null>(
initialValues?.customer
? { value: initialValues.customer.id, label: initialValues.customer.name }
: null
);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const selectedRowIds = Object.keys(rowSelection).map((item) =>
parseInt(item)
);
const [grandTotal, setGrandTotal] = useState<number>(
initialValues?.grand_total ?? 0
);
const marketingProducts = useMemo(
() => rawMarketingProducts,
[rawMarketingProducts]
);
const {
options: customerOptions,
rawData: customerRawData,
isLoadingOptions: isLoadingCustomerOptions,
} = useSelect<Customer>(CustomerApi.basePath, 'id', 'name');
const handleAddProduct = useCallback(() => {
addProductModal.openModal();
}, [addProductModal]);
const handleDeleteProduct = useCallback((id: number) => {
setRawMarketingProducts((prev) => prev.filter((p) => p.id !== id));
}, []);
const handleBulkDeleteProduct = () => {
setRawMarketingProducts((prev) =>
prev.filter((product) => !selectedRowIds.includes(product.id))
);
};
const handleDelete = () => {
deleteModal.openModal();
};
const handleAddSubmitProduct = useCallback(
async (
tableValue: CreateMarketingProductPayload,
fieldValues: MarketingProductFormValues
) => {
const newMarketingProduct: MarketingProduct = {
id: rawMarketingProducts.length + 1,
product_warehouse: tableValue.product_warehouse!,
unit_price: tableValue.unit_price as number,
total_weight: tableValue.total_weight as number,
qty: tableValue.qty as number,
avg_weight: tableValue.avg_weight as number,
total_price: tableValue.total_price as number,
marketing_delivery_products: {
id: rawMarketingProducts.length + 1,
vehicle_number: tableValue.vehicle_number as string,
delivery_date: tableValue.delivery_date as string,
unit_price: tableValue.unit_price as number,
total_weight: tableValue.total_weight as number,
qty: tableValue.qty as number,
avg_weight: tableValue.avg_weight as number,
total_price: tableValue.total_price as number,
},
};
setRawMarketingProducts((prev) => [...prev, newMarketingProduct]);
formik.setValues({
...formik.values,
marketing_products: [...formik.values.marketing_products, fieldValues],
});
setGrandTotal((prev) => prev + (tableValue.total_price as number));
addProductModal.closeModal();
},
[rawMarketingProducts.length, addProductModal]
);
const handleChangeCustomer = useCallback(
(val: OptionType | OptionType[] | null) => {
setSelectedCustomer(val as OptionType);
formik.setFieldValue('customer_id', (val as OptionType)?.value);
formik.setFieldValue('customer', val as OptionType);
},
[selectedCustomer, setSelectedCustomer]
);
const createMarketingHandler = async (values: CreateMarketingPayload) => {
console.log(values);
const createMarketingRes = await MarketingApi.create(values);
if (isResponseSuccess(createMarketingRes)) {
console.log(createMarketingRes);
}
if (isResponseError(createMarketingRes)) {
console.log(createMarketingRes);
}
toast.success('Successfully created Sales Order!');
router.push('/marketing/sales-orders');
};
const updateMarketingHandler = async (values: CreateMarketingPayload) => {
console.log(values);
const createMarketingRes = await MarketingApi.update(
initialValues?.id as number,
values
);
if (isResponseSuccess(createMarketingRes)) {
console.log(createMarketingRes);
}
if (isResponseError(createMarketingRes)) {
console.log(createMarketingRes);
}
toast.success('Successfully updated Sales Order!');
router.push('/marketing/sales-orders');
};
const deleteMarketingHandler = async () => {
setIsLoading(true);
console.log(initialValues?.id);
const deleteMarketingRes = await MarketingApi.delete(
initialValues?.id as number
);
if (isResponseSuccess(deleteMarketingRes)) {
console.log(deleteMarketingRes);
}
if (isResponseError(deleteMarketingRes)) {
console.log(deleteMarketingRes);
}
toast.success('Successfully deleted Sales Order!');
setIsLoading(false);
deleteModal.closeModal();
router.push('/marketing/sales-orders');
};
const MarketingProductToFieldValues = (
product: MarketingProduct
): MarketingProductFormValues => {
return {
vehicle_number: product.marketing_delivery_products?.vehicle_number,
kandang_id: product.product_warehouse.warehouse.id,
kandang: {
value: product.product_warehouse.warehouse.id,
label: product.product_warehouse.warehouse.name,
},
product_warehouse: {
value: product.product_warehouse.product.id,
label: product.product_warehouse.product.name,
},
product_warehouse_id: product.product_warehouse.product.id,
unit_price: product.unit_price,
total_weight: product.total_weight,
qty: product.qty,
uom: product.product_warehouse?.product?.uom?.name,
avg_weight: product.avg_weight,
total_price: product.total_price,
delivery_date: product.marketing_delivery_products?.delivery_date,
};
};
const formik = useFormik<MarketingFormValues>({
enableReinitialize: true,
initialValues: {
so_date: initialValues?.so_date || undefined,
notes: initialValues?.notes || undefined,
customer_id: initialValues?.customer?.id || undefined,
customer: {
value: initialValues?.customer?.id as number,
label: initialValues?.customer?.name as string,
},
marketing_products:
initialValues?.marketing_products?.map((product) =>
MarketingProductToFieldValues(product)
) ?? [],
},
validationSchema: MarketingSchema,
onSubmit: async (values) => {
const payload = {
customer_id: values.customer_id as number,
date: values.so_date as string,
notes: values.notes as string,
marketing_products: values.marketing_products,
} as CreateMarketingPayload;
switch (formType) {
case 'add':
createMarketingHandler(payload);
break;
case 'edit':
updateMarketingHandler(payload);
break;
default:
break;
}
},
});
const { setValues: formikSetValues } = formik;
useEffect(() => {
formikSetValues(formik.initialValues);
}, [formikSetValues, formik.initialValues]);
const columns = useMemo(
() => [
{
id: 'select',
header: ({ table }: { table: TanStack.Table<MarketingProduct> }) => (
<div className='w-full flex flex-row justify-center'>
<CheckboxInput
name='allRow'
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
</div>
),
cell: ({ row }: { row: TanStack.Row<MarketingProduct> }) => (
<div>
<CheckboxInput
name='row'
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
</div>
),
},
{
accessorFn: (row: MarketingProduct) =>
row.marketing_delivery_products?.vehicle_number,
header: 'No. Polisi',
},
{
accessorFn: (row: MarketingProduct) =>
row.product_warehouse.warehouse.name,
header: 'Kandang',
},
{
accessorFn: (row: MarketingProduct) =>
row.product_warehouse.product.name,
header: 'Produk',
},
{
accessorFn: (row: MarketingProduct) => formatCurrency(row.unit_price),
header: 'Harga Satuan (Rp)',
},
{
accessorFn: (row: MarketingProduct) => formatNumber(row.total_weight),
header: 'Total Bobot (Kg)',
},
{
accessorFn: (row: MarketingProduct) => formatNumber(row.qty),
header: 'Kuantitas',
},
{
accessorFn: (row: MarketingProduct) => formatNumber(row.avg_weight),
header: 'Avg. Bobot (Kg)',
},
{
accessorFn: (row: MarketingProduct) => formatCurrency(row.total_price),
header: 'Total Penjualan (Rp)',
},
{
header: 'Aksi',
cell: (props: TanStack.CellContext<MarketingProduct, unknown>) => (
<div className='flex flex-row gap-1 items-center justify-end h-full mt-2'>
<Button
color='error'
className='p-1'
onClick={() => handleDeleteProduct(props.row.original.id)}
>
<Icon icon='mdi:trash' width={16} height={16} />
</Button>
</div>
),
},
],
[handleDeleteProduct] // dependensi tunggal
);
return (
<>
<form
className='flex flex-col gap-4'
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
>
<FormHeader
title={`${formType === 'add' ? 'Tambah' : 'Edit'} Sales Order`}
backUrl='/marketing/sales-orders'
/>
<Card
title='Informasi Order'
className={{
wrapper: 'bg-white w-full',
}}
>
<div className='grid grid-cols-2 gap-3 mt-3'>
<SelectInput
label='Pelanggan'
options={customerOptions}
isLoading={isLoadingCustomerOptions}
value={selectedCustomer}
onChange={handleChangeCustomer}
isError={
formik.touched.customer_id && Boolean(formik.errors.customer_id)
}
errorMessage={formik.errors.customer_id}
isClearable
placeholder='Pilih Pelanggan'
/>
<DateInput
name='so_date'
label='Tanggal'
value={formik.values.so_date}
onChange={formik.handleChange}
isError={formik.touched.so_date && Boolean(formik.errors.so_date)}
errorMessage={formik.errors.so_date}
placeholder='Pilih Tanggal'
/>
</div>
</Card>
<Card
title='Daftar Produk'
className={{
wrapper: 'bg-white w-full',
}}
>
<Table<MarketingProduct>
rowSelection={rowSelection}
setRowSelection={setRowSelection}
data={marketingProducts}
columns={columns}
className={{
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-2 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end first:flex first:flex-row first:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-2 py-2 last:flex last:flex-row last:justify-end first:flex first:flex-row first:justify-start',
paginationClassName: 'hidden',
}}
emptyContent={
<div
className={cn(
'w-full h-16 flex flex-col justify-center items-center gap-2'
)}
>
<span className='text-gray-500'>Belum ada data penjualan</span>
</div>
}
/>
<div className='flex flex-row gap-3 mt-3'>
<Button
type='button'
variant='outline'
className='justify-start w-fit py-1 text-sm'
onClick={handleAddProduct}
>
<Icon icon='mdi:plus' width={16} height={16} />
Tambah Produk
</Button>
{selectedRowIds.length > 0 && (
<Button
type='button'
variant='outline'
color='error'
className='justify-start w-fit py-1 text-sm'
onClick={handleBulkDeleteProduct}
>
<Icon icon='mdi:trash' width={16} height={16} />
Hapus
{selectedRowIds.length > 0
? ` (${selectedRowIds.length})`
: ''}{' '}
Produk
</Button>
)}
</div>
</Card>
<div className='grid grid-cols-2 gap-3'>
<TextArea
required
name='notes'
label='Catatan'
rows={3}
placeholder='Masukan catatan penjualan'
value={formik.values.notes}
onChange={formik.handleChange}
isError={formik.touched.notes && Boolean(formik.errors.notes)}
errorMessage={formik.errors.notes}
/>
<div className='flex flex-col h-full justify-between items-end py-6'>
<span>Total Penjualan</span>
<span className='text-lg font-semibold'>
{formatCurrency(grandTotal)}
</span>
</div>
</div>
<div className='flex flex-row items-start justify-center gap-2 mt-4'>
<Button type='reset' color='warning' disabled={formik.isSubmitting}>
Reset
</Button>
<Button
type='submit'
disabled={!formik.isValid || formik.isSubmitting}
>
Submit
</Button>
</div>
{JSON.stringify(formik.errors)}
</form>
{formType == 'edit' && (
<div className='flex flex-row justify-start'>
<Button type='button' color='error' onClick={handleDelete}>
<Icon icon='mdi:trash' width={24} height={24} />
Hapus
</Button>
</div>
)}
<Modal
ref={addProductModal.ref}
closeOnBackdrop
className={{
modalBox: 'max-w-4/5 z-100',
}}
>
<div className='flex flex-col gap-4'>
<div className='flex flex-row items-center justify-between'>
<h3 className='text-lg font-semibold mb-4'>Tambah Produk</h3>
<Button
variant='ghost'
color='error'
className='rounded-full'
onClick={addProductModal.closeModal}
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div>
<MarketingProductForm
onSubmitForm={handleAddSubmitProduct}
modalRef={addProductModal.ref}
data={rawMarketingProducts}
initialValues={selectedMarketingProduct ?? undefined}
/>
</div>
</div>
</Modal>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data penjualan ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
onClick: deleteMarketingHandler,
}}
/>
</>
);
};
export default SalesForm;

Some files were not shown because too many files have changed in this diff Show More