mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
feat(FE-328-329-330): Adding Feature Inventory Product Stocks
This commit is contained in:
@@ -140,7 +140,6 @@ deploy:dev:
|
|||||||
environment:
|
environment:
|
||||||
name: development
|
name: development
|
||||||
url: https://dev-lti-erp.mbugroup.id
|
url: https://dev-lti-erp.mbugroup.id
|
||||||
|
|
||||||
# ====== PRODUCTION ======
|
# ====== PRODUCTION ======
|
||||||
# build:production:
|
# build:production:
|
||||||
# <<: *build_template
|
# <<: *build_template
|
||||||
@@ -163,4 +162,3 @@ deploy:dev:
|
|||||||
# environment:
|
# environment:
|
||||||
# name: production
|
# name: production
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import InventoryProductDetail from '@/components/pages/inventory/product/detail/InventoryProductDetail';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { InventoryProductApi } from '@/services/api/inventory';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
const InventoryProductDetailPage = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const inventoryProductId = searchParams.get('inventoryProductId');
|
||||||
|
|
||||||
|
const { data: inventoryProduct, isLoading: isLoadingInventoryProduct } =
|
||||||
|
useSWR(inventoryProductId, (id: number) =>
|
||||||
|
InventoryProductApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!inventoryProductId) {
|
||||||
|
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 (
|
||||||
|
!isLoadingInventoryProduct &&
|
||||||
|
(!inventoryProduct || isResponseError(inventoryProduct))
|
||||||
|
) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='size-full'>
|
||||||
|
{isLoadingInventoryProduct && (
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
)}
|
||||||
|
{!isLoadingInventoryProduct && isResponseSuccess(inventoryProduct) && (
|
||||||
|
<InventoryProductDetail inventoryProduct={inventoryProduct.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InventoryProductDetailPage;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import InventoryProductTable from '@/components/pages/inventory/product/InventoryProductTable';
|
||||||
|
|
||||||
|
const InventoryProductPage = () => {
|
||||||
|
return (
|
||||||
|
<div className='size-full'>
|
||||||
|
<InventoryProductTable />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InventoryProductPage;
|
||||||
@@ -6,9 +6,147 @@ import useSWRImmutable from 'swr/immutable';
|
|||||||
|
|
||||||
import { useAuth } from '@/services/hooks/useAuth';
|
import { useAuth } from '@/services/hooks/useAuth';
|
||||||
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
|
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
|
||||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
import { BaseApiResponse, GetMeResponse } from '@/types/api/api-general';
|
import { GetMeResponse } from '@/types/api/api-general';
|
||||||
import { AxiosError } from 'axios';
|
|
||||||
|
// TODO: delete this later, DONT HARDCODE USER DATA
|
||||||
|
const DUMMY_USER = {
|
||||||
|
id: 1,
|
||||||
|
email: 'admin@mbugroup.id',
|
||||||
|
npk: '0001',
|
||||||
|
name: 'Super Admin',
|
||||||
|
image: null,
|
||||||
|
created_at: '2025-09-30T03:24:20.899229Z',
|
||||||
|
updated_at: '2025-09-30T03:24:20.899229Z',
|
||||||
|
roles: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
key: 'mbu.super_admin',
|
||||||
|
name: 'MBU Administrator',
|
||||||
|
client: {
|
||||||
|
id: 1,
|
||||||
|
name: 'PT Mitra Berlian Unggas',
|
||||||
|
alias: 'MBU',
|
||||||
|
},
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'mbu:purchase:read',
|
||||||
|
action: 'read',
|
||||||
|
client: {
|
||||||
|
id: 1,
|
||||||
|
name: 'PT Mitra Berlian Unggas',
|
||||||
|
alias: 'MBU',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'mbu:purchase:create',
|
||||||
|
action: 'create',
|
||||||
|
client: {
|
||||||
|
id: 1,
|
||||||
|
name: 'PT Mitra Berlian Unggas',
|
||||||
|
alias: 'MBU',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'mbu:purchase:approve',
|
||||||
|
action: 'approve',
|
||||||
|
client: {
|
||||||
|
id: 1,
|
||||||
|
name: 'PT Mitra Berlian Unggas',
|
||||||
|
alias: 'MBU',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
key: 'lti.super_admin',
|
||||||
|
name: 'LTI Administrator',
|
||||||
|
client: {
|
||||||
|
id: 2,
|
||||||
|
name: 'PT Lumbung Telur Indonesia',
|
||||||
|
alias: 'LTI',
|
||||||
|
},
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: 'lti:purchase:read',
|
||||||
|
action: 'read',
|
||||||
|
client: {
|
||||||
|
id: 2,
|
||||||
|
name: 'PT Lumbung Telur Indonesia',
|
||||||
|
alias: 'LTI',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: 'lti:purchase:create',
|
||||||
|
action: 'create',
|
||||||
|
client: {
|
||||||
|
id: 2,
|
||||||
|
name: 'PT Lumbung Telur Indonesia',
|
||||||
|
alias: 'LTI',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
name: 'lti:purchase:approve',
|
||||||
|
action: 'approve',
|
||||||
|
client: {
|
||||||
|
id: 2,
|
||||||
|
name: 'PT Lumbung Telur Indonesia',
|
||||||
|
alias: 'LTI',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
key: 'manbu.super_admin',
|
||||||
|
name: 'MANBU Administrator',
|
||||||
|
client: {
|
||||||
|
id: 3,
|
||||||
|
name: 'PT Mandiri Berlian Unggas',
|
||||||
|
alias: 'MANBU',
|
||||||
|
},
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
name: 'manbu:purchase:read',
|
||||||
|
action: 'read',
|
||||||
|
client: {
|
||||||
|
id: 3,
|
||||||
|
name: 'PT Mandiri Berlian Unggas',
|
||||||
|
alias: 'MANBU',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
name: 'manbu:purchase:create',
|
||||||
|
action: 'create',
|
||||||
|
client: {
|
||||||
|
id: 3,
|
||||||
|
name: 'PT Mandiri Berlian Unggas',
|
||||||
|
alias: 'MANBU',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
name: 'manbu:purchase:approve',
|
||||||
|
action: 'approve',
|
||||||
|
client: {
|
||||||
|
id: 3,
|
||||||
|
name: 'PT Mandiri Berlian Unggas',
|
||||||
|
alias: 'MANBU',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
interface RequireAuthProps {
|
interface RequireAuthProps {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
@@ -18,20 +156,17 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { setUser, setIsLoadingUser } = useAuth();
|
const { setUser, setIsLoadingUser } = useAuth();
|
||||||
|
|
||||||
const {
|
const { data: userResponse, isLoading: isLoadingUserResponse } =
|
||||||
data: userResponse,
|
useSWRImmutable<GetMeResponse & { ok?: boolean }, unknown, SWRHttpKey>(
|
||||||
isLoading: isLoadingUserResponse,
|
'/auth/sso/userinfo',
|
||||||
error: userErrorResponse,
|
httpClientFetcher,
|
||||||
} = useSWRImmutable<
|
{
|
||||||
GetMeResponse & { ok?: boolean },
|
shouldRetryOnError: false,
|
||||||
AxiosError<BaseApiResponse>,
|
revalidateOnFocus: false,
|
||||||
SWRHttpKey
|
revalidateOnReconnect: false,
|
||||||
>('/sso/userinfo', httpClientFetcher, {
|
refreshInterval: 0,
|
||||||
shouldRetryOnError: false,
|
}
|
||||||
revalidateOnFocus: false,
|
);
|
||||||
revalidateOnReconnect: false,
|
|
||||||
refreshInterval: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoadingUser(isLoadingUserResponse);
|
setIsLoadingUser(isLoadingUserResponse);
|
||||||
@@ -40,25 +175,23 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isResponseSuccess(userResponse)) {
|
if (isResponseSuccess(userResponse)) {
|
||||||
setUser(userResponse.data);
|
setUser(userResponse.data);
|
||||||
} else if (
|
} else {
|
||||||
isResponseError(userErrorResponse?.response?.data) &&
|
// router.replace(process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string);
|
||||||
typeof window !== 'undefined'
|
// TODO: remove this later, DONT HARDCODE USER DATA
|
||||||
) {
|
setUser(DUMMY_USER);
|
||||||
router.replace(
|
|
||||||
`${process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string}?redirect_url=${window.location.href}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [userResponse, userErrorResponse, setIsLoadingUser, setUser]);
|
}, [userResponse, setIsLoadingUser, setUser]);
|
||||||
|
|
||||||
if (isLoadingUserResponse && !userResponse && !userErrorResponse) {
|
// TODO: uncomment this later
|
||||||
return (
|
// if (isLoadingUserResponse && !userResponse) {
|
||||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
// return (
|
||||||
<span className='loading loading-spinner loading-xl' />
|
// <div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
</div>
|
// <span className='loading loading-spinner loading-xl' />
|
||||||
);
|
// </div>
|
||||||
}
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
return <>{isResponseSuccess(userResponse) && children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default RequireAuth;
|
export default RequireAuth;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Table from '@/components/Table';
|
|||||||
import { ROWS_OPTIONS } from '@/config/constant';
|
import { ROWS_OPTIONS } from '@/config/constant';
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
import { cn } from '@/lib/helper';
|
import { cn } from '@/lib/helper';
|
||||||
import { inventoryAdjustmentApi } from '@/services/api/inventory';
|
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';
|
||||||
@@ -41,8 +41,8 @@ const InventoryAdjustmentTable = () => {
|
|||||||
|
|
||||||
// Fetch Data
|
// Fetch Data
|
||||||
const { data: inventoryAdjustments, isLoading } = useSWR(
|
const { data: inventoryAdjustments, isLoading } = useSWR(
|
||||||
`${inventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`,
|
`${InventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`,
|
||||||
inventoryAdjustmentApi.getAllFetcher
|
InventoryAdjustmentApi.getAllFetcher
|
||||||
);
|
);
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
import { inventoryAdjustmentApi } from '@/services/api/inventory';
|
import { InventoryAdjustmentApi } from '@/services/api/inventory';
|
||||||
import {
|
import {
|
||||||
CreateInventoryAdjustmentPayload,
|
CreateInventoryAdjustmentPayload,
|
||||||
InventoryAdjustment,
|
InventoryAdjustment,
|
||||||
@@ -52,7 +52,7 @@ const InventoryAdjustmentForm = ({
|
|||||||
const createInventoryAdjustmentHandler = useCallback(
|
const createInventoryAdjustmentHandler = useCallback(
|
||||||
async (payload: CreateInventoryAdjustmentPayload) => {
|
async (payload: CreateInventoryAdjustmentPayload) => {
|
||||||
const createInventoryAdjustmentRes =
|
const createInventoryAdjustmentRes =
|
||||||
await inventoryAdjustmentApi.create(payload);
|
await InventoryAdjustmentApi.create(payload);
|
||||||
|
|
||||||
if (isResponseError(createInventoryAdjustmentRes)) {
|
if (isResponseError(createInventoryAdjustmentRes)) {
|
||||||
setInventoryAdjustmentFormErrorMessage(
|
setInventoryAdjustmentFormErrorMessage(
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||||
|
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
|
||||||
|
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
|
||||||
|
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
|
||||||
|
import { ROWS_OPTIONS } from '@/config/constant';
|
||||||
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { cn, formatCurrency } from '@/lib/helper';
|
||||||
|
import { InventoryProductApi } from '@/services/api/inventory';
|
||||||
|
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||||
|
import { InventoryProduct } from '@/types/api/inventory/product';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import {
|
||||||
|
CellContext,
|
||||||
|
ColumnDef,
|
||||||
|
Row,
|
||||||
|
SortingState,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
const RowOptionsMenu = ({
|
||||||
|
type = 'dropdown',
|
||||||
|
props,
|
||||||
|
}: {
|
||||||
|
type: 'dropdown' | 'collapse';
|
||||||
|
props: CellContext<InventoryProduct, unknown>;
|
||||||
|
}) => (
|
||||||
|
<RowOptionsMenuWrapper type={type}>
|
||||||
|
<Button
|
||||||
|
href={`/inventory/product/detail?inventoryProductId=${props.row.original.id}`}
|
||||||
|
variant='ghost'
|
||||||
|
color='primary'
|
||||||
|
className='justify-start text-sm'
|
||||||
|
>
|
||||||
|
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||||
|
Detail
|
||||||
|
</Button>
|
||||||
|
</RowOptionsMenuWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const InventoryProductTable = () => {
|
||||||
|
const {
|
||||||
|
state: tableFilterState,
|
||||||
|
updateFilter,
|
||||||
|
setPage,
|
||||||
|
setPageSize,
|
||||||
|
toQueryString: getTableFilterQueryString,
|
||||||
|
} = useTableFilter({
|
||||||
|
initial: {
|
||||||
|
search: '',
|
||||||
|
},
|
||||||
|
paramMap: {
|
||||||
|
page: 'page',
|
||||||
|
pageSize: 'limit',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
|
||||||
|
const { data: inventoryProducts, isLoading } = useSWR(
|
||||||
|
`${InventoryProductApi.basePath}${getTableFilterQueryString()}`,
|
||||||
|
InventoryProductApi.getAllFetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
updateFilter('search', e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
|
const newVal = val as OptionType;
|
||||||
|
setPageSize(newVal.value as number);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<InventoryProduct>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
header: '#',
|
||||||
|
cell: (props) =>
|
||||||
|
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||||
|
props.row.index +
|
||||||
|
1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Nama',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'product_price',
|
||||||
|
header: 'Harga Beli',
|
||||||
|
cell: (props) => {
|
||||||
|
return props.row.original.product_price
|
||||||
|
? formatCurrency(props.row.original.product_price)
|
||||||
|
: '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'selling_price',
|
||||||
|
header: 'Harga Jual',
|
||||||
|
cell: (props) => {
|
||||||
|
return props.row.original.selling_price
|
||||||
|
? formatCurrency(props.row.original.selling_price)
|
||||||
|
: '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: (row) => row.product_category.name,
|
||||||
|
header: 'Kategori',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: (row) => row.uom.name,
|
||||||
|
header: 'Satuan',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{currentPageSize > 2 && (
|
||||||
|
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
||||||
|
<RowOptionsMenu type='dropdown' props={props} />
|
||||||
|
</RowDropdownOptions>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentPageSize <= 2 && (
|
||||||
|
<RowCollapseOptions>
|
||||||
|
<RowOptionsMenu type='collapse' props={props} />
|
||||||
|
</RowCollapseOptions>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='w-full p-0 sm:p-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-row gap-2'></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex justify-between items-end gap-4'>
|
||||||
|
<DebouncedTextInput
|
||||||
|
name='search'
|
||||||
|
placeholder='Cari Produk'
|
||||||
|
value={tableFilterState.search}
|
||||||
|
onChange={searchChangeHandler}
|
||||||
|
className={{ wrapper: 'sm:max-w-3xs' }}
|
||||||
|
/>
|
||||||
|
<SelectInput
|
||||||
|
label='Baris'
|
||||||
|
options={ROWS_OPTIONS}
|
||||||
|
value={{
|
||||||
|
label: String(tableFilterState.pageSize),
|
||||||
|
value: tableFilterState.pageSize,
|
||||||
|
}}
|
||||||
|
onChange={pageSizeChangeHandler}
|
||||||
|
className={{
|
||||||
|
wrapper:
|
||||||
|
'col-span-6 sm:col-span-4 max-w-28 sm:justify-self-end',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table<InventoryProduct>
|
||||||
|
data={
|
||||||
|
isResponseSuccess(inventoryProducts) ? inventoryProducts?.data : []
|
||||||
|
}
|
||||||
|
columns={columns}
|
||||||
|
pageSize={tableFilterState.pageSize}
|
||||||
|
page={
|
||||||
|
isResponseSuccess(inventoryProducts)
|
||||||
|
? inventoryProducts?.meta?.page
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
totalItems={
|
||||||
|
isResponseSuccess(inventoryProducts)
|
||||||
|
? inventoryProducts?.meta?.total_results
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
onPageChange={setPage}
|
||||||
|
isLoading={isLoading}
|
||||||
|
sorting={sorting}
|
||||||
|
setSorting={setSorting}
|
||||||
|
className={{
|
||||||
|
containerClassName: cn({
|
||||||
|
'mb-20':
|
||||||
|
isResponseSuccess(inventoryProducts) &&
|
||||||
|
inventoryProducts?.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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InventoryProductTable;
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import Card from '@/components/Card';
|
||||||
|
import { FormHeader } from '@/components/helper/form/FormHeader';
|
||||||
|
import StockLogTable from '@/components/pages/inventory/product/detail/StockLogTable';
|
||||||
|
import StockProductWarehouseTable from '@/components/pages/inventory/product/detail/StockProductWarehouseTable';
|
||||||
|
import { formatCurrency, formatNumber } from '@/lib/helper';
|
||||||
|
import { InventoryProduct } from '@/types/api/inventory/product';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
const InventoryProductDetail = ({
|
||||||
|
inventoryProduct,
|
||||||
|
refresh,
|
||||||
|
}: {
|
||||||
|
inventoryProduct?: InventoryProduct;
|
||||||
|
refresh?: () => void;
|
||||||
|
}) => {
|
||||||
|
const totalStok = useMemo(() => {
|
||||||
|
return (
|
||||||
|
inventoryProduct?.product_warehouses?.reduce(
|
||||||
|
(total, warehouse) => total + (warehouse.current_stock || 0),
|
||||||
|
0
|
||||||
|
) || 0
|
||||||
|
);
|
||||||
|
}, [inventoryProduct]);
|
||||||
|
|
||||||
|
const stockLogs = useMemo(() => {
|
||||||
|
return (
|
||||||
|
inventoryProduct?.product_warehouses?.flatMap(
|
||||||
|
(warehouse) => warehouse.stock_logs || []
|
||||||
|
) || []
|
||||||
|
);
|
||||||
|
}, [inventoryProduct]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col gap-4 p-4'>
|
||||||
|
<FormHeader
|
||||||
|
title='Detail Persediaan Produk'
|
||||||
|
backUrl='/inventory/product'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title='Informasi Produk'
|
||||||
|
variant='bordered'
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full mt-4',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='grid grid-cols-2 gap-4'>
|
||||||
|
<div className='overflow-x-auto rounded-box border border-base-content/5 bg-base-100 mt-3'>
|
||||||
|
<table className='table'>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td className='font-semibold'>SKU</td>
|
||||||
|
<td>:</td>
|
||||||
|
<td>{inventoryProduct?.sku}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className='font-semibold'>Nama Produk</td>
|
||||||
|
<td>:</td>
|
||||||
|
<td>{inventoryProduct?.name}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className='font-semibold'>Kategory</td>
|
||||||
|
<td>:</td>
|
||||||
|
<td>{inventoryProduct?.product_category.name}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className='font-semibold'>Satuan</td>
|
||||||
|
<td>:</td>
|
||||||
|
<td>{inventoryProduct?.uom.name}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='overflow-x-auto rounded-box border border-base-content/5 bg-base-100 mt-3'>
|
||||||
|
<table className='table'>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td className='font-semibold'>Harga Jual</td>
|
||||||
|
<td>:</td>
|
||||||
|
<td>
|
||||||
|
{inventoryProduct?.product_price
|
||||||
|
? formatCurrency(inventoryProduct.product_price)
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className='font-semibold'>Harga Beli</td>
|
||||||
|
<td>:</td>
|
||||||
|
<td>
|
||||||
|
{inventoryProduct?.selling_price
|
||||||
|
? formatCurrency(inventoryProduct?.selling_price)
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className='font-semibold'>Pajak</td>
|
||||||
|
<td>:</td>
|
||||||
|
<td>
|
||||||
|
{inventoryProduct?.tax
|
||||||
|
? formatCurrency(inventoryProduct?.tax)
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className='font-semibold'>Total Stok</td>
|
||||||
|
<td>:</td>
|
||||||
|
<td>{formatNumber(totalStok)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<StockProductWarehouseTable
|
||||||
|
productWarehouseStock={inventoryProduct?.product_warehouses ?? []}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StockLogTable stockLogs={stockLogs} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InventoryProductDetail;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import Card from '@/components/Card';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import { formatDate, formatNumber, formatTitleCase } from '@/lib/helper';
|
||||||
|
import { StockLog } from '@/types/api/inventory/product';
|
||||||
|
|
||||||
|
const StockLogTable = ({ stockLogs }: { stockLogs: StockLog[] }) => {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title='Informasi Stock Produk'
|
||||||
|
collapsible
|
||||||
|
variant='bordered'
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table<StockLog>
|
||||||
|
data={stockLogs}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
header: 'ID',
|
||||||
|
accessorKey: 'id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Tanggal',
|
||||||
|
accessorKey: 'created_at',
|
||||||
|
cell: (props) => {
|
||||||
|
return formatDate(props.row.original.created_at, 'DD-MMM-yyyy');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Peningkatan',
|
||||||
|
accessorKey: 'increase',
|
||||||
|
cell: (props) => {
|
||||||
|
return formatNumber(props.row.original.increase);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Penurunan',
|
||||||
|
accessorKey: 'decrease',
|
||||||
|
cell: (props) => {
|
||||||
|
return formatNumber(props.row.original.decrease);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Jenis Transaksi',
|
||||||
|
accessorKey: 'loggable_type',
|
||||||
|
cell: (props) => {
|
||||||
|
return props.row.original.loggable_type
|
||||||
|
? formatTitleCase(props.row.original.loggable_type)
|
||||||
|
: '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Catatan',
|
||||||
|
accessorKey: 'notes',
|
||||||
|
cell: (props) => {
|
||||||
|
return props.row.original.notes ? props.row.original.notes : '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Oleh',
|
||||||
|
accessorKey: 'created_by',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
className={{
|
||||||
|
containerClassName: 'mt-6',
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StockLogTable;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import Card from '@/components/Card';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import { formatNumber } from '@/lib/helper';
|
||||||
|
import {
|
||||||
|
InventoryProduct,
|
||||||
|
ProductWarehouseStock,
|
||||||
|
} from '@/types/api/inventory/product';
|
||||||
|
|
||||||
|
const StockProductWarehouseTable = ({
|
||||||
|
productWarehouseStock,
|
||||||
|
}: {
|
||||||
|
productWarehouseStock?: ProductWarehouseStock[];
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title='Informasi Gudang'
|
||||||
|
collapsible
|
||||||
|
variant='bordered'
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table<ProductWarehouseStock>
|
||||||
|
data={productWarehouseStock ?? []}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
header: 'Nama Gudang',
|
||||||
|
accessorKey: 'warehouse_name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Lokasi',
|
||||||
|
accessorKey: 'location',
|
||||||
|
cell: (props) => {
|
||||||
|
return Boolean(props.row.original.location)
|
||||||
|
? props.row.original.location
|
||||||
|
: '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Stok',
|
||||||
|
accessorFn(row) {
|
||||||
|
return row.current_stock;
|
||||||
|
},
|
||||||
|
cell: (props) => {
|
||||||
|
return formatNumber(props.row.original.current_stock);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
className={{
|
||||||
|
containerClassName: 'mt-6',
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StockProductWarehouseTable;
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
CreateInventoryAdjustmentPayload,
|
CreateInventoryAdjustmentPayload,
|
||||||
InventoryAdjustment,
|
InventoryAdjustment,
|
||||||
} from '@/types/api/inventory/adjustment';
|
} from '@/types/api/inventory/adjustment';
|
||||||
|
import { InventoryProduct } from '@/types/api/inventory/product';
|
||||||
|
|
||||||
export const ProductWarehouseApi = new BaseApiService<
|
export const ProductWarehouseApi = new BaseApiService<
|
||||||
ProductWarehouse,
|
ProductWarehouse,
|
||||||
@@ -25,8 +26,14 @@ export const MovementApi = new BaseApiService<
|
|||||||
unknown
|
unknown
|
||||||
>('/inventory/transfers');
|
>('/inventory/transfers');
|
||||||
|
|
||||||
export const inventoryAdjustmentApi = new BaseApiService<
|
export const InventoryAdjustmentApi = new BaseApiService<
|
||||||
InventoryAdjustment,
|
InventoryAdjustment,
|
||||||
CreateInventoryAdjustmentPayload,
|
CreateInventoryAdjustmentPayload,
|
||||||
unknown
|
unknown
|
||||||
>('/inventory/adjustments');
|
>('/inventory/adjustments');
|
||||||
|
|
||||||
|
export const InventoryProductApi = new BaseApiService<
|
||||||
|
InventoryProduct,
|
||||||
|
unknown,
|
||||||
|
unknown
|
||||||
|
>('/inventory/product-stocks');
|
||||||
|
|||||||
Vendored
+45
@@ -0,0 +1,45 @@
|
|||||||
|
import { BaseMetadata } from '@/types/api/api-general';
|
||||||
|
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
|
||||||
|
import { ProductCategory } from '@/types/api/master-data/product-category';
|
||||||
|
import { Supplier } from '@/types/api/master-data/supplier';
|
||||||
|
import { Uom } from '@/types/api/master-data/uom';
|
||||||
|
|
||||||
|
export type BaseInventoryProduct = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
sku: string;
|
||||||
|
product_price: number;
|
||||||
|
selling_price?: number;
|
||||||
|
tax?: number;
|
||||||
|
expiry_period?: number;
|
||||||
|
uom: Uom;
|
||||||
|
product_category: ProductCategory;
|
||||||
|
suppliers: Supplier[];
|
||||||
|
flags: string[];
|
||||||
|
product_warehouses?: ProductWarehouseStock[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductWarehouseStock = {
|
||||||
|
id: number;
|
||||||
|
product_id: number;
|
||||||
|
warehouse_id: number;
|
||||||
|
warehouse_name: string;
|
||||||
|
location: Location | string;
|
||||||
|
current_stock: number;
|
||||||
|
stock_logs: StockLog[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StockLog = {
|
||||||
|
id: number;
|
||||||
|
increase: number;
|
||||||
|
decrease: number;
|
||||||
|
loggable_type: string;
|
||||||
|
loggable_id: number;
|
||||||
|
notes: string;
|
||||||
|
product_warehouse_id: number;
|
||||||
|
created_by: number;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InventoryProduct = BaseInventoryProduct & BaseMetadata;
|
||||||
Reference in New Issue
Block a user