fix(FE) resolve merge conflict

This commit is contained in:
randy-ar
2025-10-21 10:19:03 +07:00
31 changed files with 2742 additions and 33 deletions
+1
View File
@@ -1,5 +1,6 @@
@import 'tailwindcss';
@plugin "daisyui";
@import '../styles/daisyui.css';
@plugin "daisyui/theme" {
name: "lti";
+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,11 +1,11 @@
import SuspenseHelper from "@/components/helper/SuspenseHelper"
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children
children,
}: Readonly<{
children: React.ReactNode
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>
}
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
@@ -1,11 +1,11 @@
import SuspenseHelper from "@/components/helper/SuspenseHelper"
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children
children,
}: Readonly<{
children: React.ReactNode
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>
}
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+6 -2
View File
@@ -1,7 +1,5 @@
import react from 'react';
import Link from 'next/link';
import { cn } from '@/lib/helper';
import { Color } from '@/types/theme';
@@ -10,6 +8,8 @@ interface ButtonProps extends react.ComponentProps<'button'> {
color?: Color;
href?: string;
isLoading?: boolean;
target?: string;
rel?: string;
}
const Button = ({
@@ -22,6 +22,8 @@ const Button = ({
className,
disabled,
onClick,
target,
rel,
...props
}: ButtonProps) => {
const btnBaseClassName = cn(
@@ -68,6 +70,8 @@ const Button = ({
{href && (
<Link
href={disabled ? '#' : href}
target={target}
rel={rel}
aria-disabled={disabled}
className={cn(
btnBaseClassName,
+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;
@@ -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>
);
};
+24
View File
@@ -0,0 +1,24 @@
import Button from '@/components/Button';
import { Icon } from '@iconify/react';
interface FormHeaderProps {
type: 'add' | 'edit' | 'detail';
title: string;
backUrl: string;
}
export const FormHeader = ({ type, title, backUrl }: FormHeaderProps) => {
return (
<header className='flex flex-col gap-4'>
<Button href={backUrl} 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}`}
</h1>
</header>
);
};
+64
View File
@@ -0,0 +1,64 @@
import { Icon } from '@iconify/react';
import Steps from '@/components/steps/Steps';
import StepItem from '@/components/steps/StepItem';
import Tooltip from '@/components/Tooltip';
import { formatDate } from '@/lib/helper';
import { ApprovalsLine } from '@/types/api/api-general';
interface ApprovalStepsProps {
approvals: ApprovalsLine;
}
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'
: undefined;
const stepItemIcon =
approval.status === 'approved'
? 'material-symbols:check-rounded'
: approval.status === 'rejected'
? 'material-symbols:close-rounded'
: 'bxs:hourglass';
return (
<StepItem
key={idx}
color={stepItemColor}
icon={
approval.status !== 'waiting' && (
<Tooltip
color={stepItemColor}
position='right'
className={{
wrapper: 'md:tooltip-bottom',
}}
content={
<div className='flex flex-col text-base'>
<span>{formatDate(approval.date, 'YYYY-MM-DD')}</span>
<span>Oleh: {approval.action_by}</span>
<span>Catatan: {approval.notes}</span>
</div>
}
>
<Icon icon={stepItemIcon} width={24} height={24} />
</Tooltip>
)
}
>
{approval.role}
</StepItem>
);
})}
</Steps>
);
};
export default ApprovalSteps;
@@ -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 Movement',
}}
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,218 @@
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',
'Biaya pengiriman atau biaya per item wajib diisi!',
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',
'Biaya pengiriman atau biaya per item wajib diisi!',
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,
};
};
+34
View File
@@ -0,0 +1,34 @@
import { ReactNode } from 'react';
import { cn } from '@/lib/helper';
import { Color } from '@/types/theme';
interface StepItemProps {
children?: ReactNode;
icon?: ReactNode;
className?: string;
color?: Color;
}
const StepItem = ({ children, icon, className, color }: StepItemProps) => {
const stepItemBaseClassName = cn('step', {
'step-primary': color === 'primary',
'step-secondary': color === 'secondary',
'step-accent': color === 'accent',
'step-neutral': color === 'neutral',
'step-info': color === 'info',
'step-success': color === 'success',
'step-warning': color === 'warning',
'step-error': color === 'error',
});
return (
<li className={cn(stepItemBaseClassName, className)}>
<span className='step-icon'>{icon}</span>
<div>{children}</div>
</li>
);
};
export default StepItem;
+23
View File
@@ -0,0 +1,23 @@
import { ReactNode } from 'react';
import { cn } from '@/lib/helper';
interface StepsProps {
children?: ReactNode;
className?: string;
direction?: 'horizontal' | 'vertical';
}
const Steps = ({ children, className, direction }: StepsProps) => {
const stepsBaseClassName = cn('steps gap-2', {
'steps-horizontal': direction === 'horizontal',
'steps-vertical': direction === 'vertical',
});
return (
<ul className={cn(stepsBaseClassName, 'overflow-visible!', className)}>
{children}
</ul>
);
};
export default Steps;
+71
View File
@@ -0,0 +1,71 @@
import { Icon } from '@iconify/react';
import Button from '../Button';
import { cn } from '@/lib/helper';
interface TableRowOptionsProps {
type?: 'dropdown' | 'collapse';
recordId: string | number;
basePath: string;
onDelete?: () => void;
queryParam?: string;
showEdit?: boolean;
showDelete?: boolean;
}
export const TableRowOptions = ({
type = 'dropdown',
recordId,
basePath,
onDelete,
queryParam = 'id',
showEdit = true,
showDelete = true,
}: TableRowOptionsProps) => (
<div
tabIndex={type === 'dropdown' ? 0 : undefined}
className={cn(
{
'dropdown-content': type === 'dropdown',
'mt-2': type === 'collapse',
},
'p-2.5 mr-2 flex flex-col gap-1 bg-base-100 rounded-box z-10 border border-black/10 shadow'
)}
>
<Button
href={`${basePath}/detail/?${queryParam}=${recordId}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
{showEdit && (
<Button
href={`${basePath}/detail/edit/?${queryParam}=${recordId}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='mdi:pencil-outline' width={16} height={16} />
Edit
</Button>
)}
{showDelete && onDelete && (
<Button
onClick={onDelete}
variant='ghost'
color='error'
className='text-error hover:text-inherit justify-start text-sm'
>
<Icon
icon='mdi:delete-outline'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
)}
</div>
);
@@ -0,0 +1,33 @@
import SelectInput from '../input/SelectInput';
export interface OptionType {
label: string;
value: string | number;
}
interface TableRowSizeSelectorProps {
value: number;
onChange: (val: OptionType | OptionType[] | null) => void;
options: OptionType[];
}
export const TableRowSizeSelector = ({
value,
onChange,
options,
}: TableRowSizeSelectorProps) => {
return (
<div className='flex flex-row justify-end'>
<SelectInput
label='Baris'
options={options}
value={{
label: String(value),
value: value,
}}
onChange={onChange}
className={{ wrapper: 'max-w-28' }}
/>
</div>
);
};
+37
View File
@@ -0,0 +1,37 @@
import { Icon } from '@iconify/react';
import Button from '../Button';
import DebouncedTextInput from '../input/DebouncedTextInput';
interface TableToolbarProps {
addButton?: {
href: string;
label: string;
};
search: {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
placeholder?: string;
};
}
export const TableToolbar = ({ addButton, search }: TableToolbarProps) => {
return (
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
{addButton && (
<div className='flex flex-row'>
<Button href={addButton.href} color='primary'>
<Icon icon='ic:round-plus' width={24} height={24} />
{addButton.label}
</Button>
</div>
)}
<DebouncedTextInput
name='search'
placeholder={search.placeholder || 'Cari...'}
value={search.value}
onChange={search.onChange}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
);
};
+45
View File
@@ -0,0 +1,45 @@
export function toFormData(
value: unknown,
form = new FormData(),
parentKey?: string
) {
if (value === undefined || value === null) {
if (parentKey) form.append(parentKey, '');
return form;
}
if (value instanceof File) {
if (!parentKey) throw new Error('File must have a key');
form.append(parentKey, value);
return form;
}
if (Array.isArray(value)) {
value.forEach((v, i) => {
const key = parentKey ? `${parentKey}[${i}]` : `${i}`;
toFormData(v, form, key);
});
return form;
}
if (typeof value === 'object') {
Object.entries(value as Record<string, unknown>).forEach(([k, v]) => {
const key = parentKey ? `${parentKey}[${k}]` : k;
toFormData(v, form, key);
});
return form;
}
if (parentKey) form.append(parentKey, String(value));
return form;
}
export function containsFile(obj: unknown): boolean {
if (!obj) return false;
if (obj instanceof File) return true;
if (Array.isArray(obj)) return obj.some(containsFile);
if (typeof obj === 'object') {
return Object.values(obj as Record<string, unknown>).some(containsFile);
}
return false;
}
+18 -8
View File
@@ -4,9 +4,11 @@ import { BaseApiResponse } from '@/types/api/api-general';
export class BaseApiService<T, CreatePayloadGeneric, UpdatePayloadGeneric> {
basePath: string;
header?: Record<string, string>;
constructor(basePath: string) {
constructor(basePath: string, header?: Record<string, string>) {
this.basePath = basePath;
this.header = header;
}
async getAllFetcher(endpoint: string): Promise<BaseApiResponse<T[]>> {
@@ -23,42 +25,52 @@ export class BaseApiService<T, CreatePayloadGeneric, UpdatePayloadGeneric> {
if (axios.isAxiosError<BaseApiResponse<T>>(error)) {
return error.response?.data;
}
return undefined;
}
}
async create(payload: CreatePayloadGeneric) {
const isFormData =
typeof FormData !== 'undefined' && payload instanceof FormData;
try {
const headers = isFormData
? { ...(this.header ?? {}) }
: { 'Content-Type': 'application/json', ...(this.header ?? {}) };
const createRes = await httpClient<BaseApiResponse<T>>(this.basePath, {
method: 'POST',
body: payload,
headers,
});
return createRes;
} catch (error: unknown) {
if (axios.isAxiosError<BaseApiResponse<T>>(error)) {
return error.response?.data;
}
return undefined;
}
}
async update(id: number, payload: UpdatePayloadGeneric) {
const isFormData =
typeof FormData !== 'undefined' && payload instanceof FormData;
try {
const updatePath = `${this.basePath}/${id}`;
const headers = isFormData
? { ...(this.header ?? {}) }
: { 'Content-Type': 'application/json', ...(this.header ?? {}) };
const updateRes = await httpClient<BaseApiResponse<T>>(updatePath, {
method: 'PATCH',
body: payload,
headers,
});
return updateRes;
} catch (error: unknown) {
if (axios.isAxiosError<BaseApiResponse<T>>(error)) {
return error.response?.data;
}
return undefined;
}
}
@@ -69,13 +81,11 @@ export class BaseApiService<T, CreatePayloadGeneric, UpdatePayloadGeneric> {
const deleteRes = await httpClient<BaseApiResponse>(deletePath, {
method: 'DELETE',
});
return deleteRes;
} catch (error) {
if (axios.isAxiosError<BaseApiResponse>(error)) {
return error.response?.data;
}
return undefined;
}
}
+26 -4
View File
@@ -1,8 +1,30 @@
import {
InventoryAdjustment,
CreateInventoryAdjustmentPayload,
} from '@/types/api/inventory/adjustment';
import { BaseApiService } from '@/services/api/base';
import {
CreateProductWarehousePayload,
ProductWarehouse,
UpdateProductWarehousePayload,
} from '@/types/api/inventory/product-warehouse';
import {
CreateMovementPayload,
Movement,
UpdateMovementPayload,
} from '@/types/api/inventory/movement';
import {
CreateInventoryAdjustmentPayload,
InventoryAdjustment,
} from '@/types/api/inventory/adjustment';
export const ProductWarehouseApi = new BaseApiService<
ProductWarehouse,
CreateProductWarehousePayload,
UpdateProductWarehousePayload
>('/inventory/product-warehouses');
export const MovementApi = new BaseApiService<
Movement,
CreateMovementPayload,
UpdateMovementPayload
>('/inventory/transfers');
export const inventoryAdjustmentApi = new BaseApiService<
InventoryAdjustment,
+23 -2
View File
@@ -6,22 +6,43 @@ type AuthStore = {
isLoadingUser?: boolean;
setUser: (newUserData?: UserWithRoles) => void;
setIsLoadingUser: (isLoading?: boolean) => void;
permissionCheck: (permissionName: string) => boolean;
};
const useAuthStore = create<AuthStore>()((set) => ({
const useAuthStore = create<AuthStore>()((set, get) => ({
user: undefined,
isLoadingUser: false,
setUser: (newUserData) => set({ user: newUserData }),
setIsLoadingUser: (isLoading) => set({ isLoadingUser: Boolean(isLoading) }),
permissionCheck: (name) => {
const { user, isLoadingUser } = get();
if (!isLoadingUser && user) {
const isAllowed = user.roles.some((role) => {
const isPermissionNameAllowed = role.permissions.some(
(permission) => permission.name === name
);
return isPermissionNameAllowed;
});
return isAllowed;
}
return false;
},
}));
export const useAuth = () => {
const { user, setUser, isLoadingUser, setIsLoadingUser } = useAuthStore();
const { user, setUser, isLoadingUser, setIsLoadingUser, permissionCheck } =
useAuthStore();
return {
user,
setUser,
isLoadingUser,
setIsLoadingUser,
permissionCheck,
};
};
+4 -1
View File
@@ -14,6 +14,9 @@ export async function httpClient<T, B = unknown>(
(!opts.auth && opts.auth !== 'none' && opts.auth !== 'bearer');
const isBearerAuth = opts.auth === 'bearer' && !!opts.token;
const isFormData =
typeof FormData !== 'undefined' && opts.body instanceof FormData;
const config: AxiosRequestConfig = {
url: path,
method: opts.method ?? 'GET',
@@ -22,7 +25,7 @@ export async function httpClient<T, B = unknown>(
timeout: opts.timeoutMs ?? 10_000,
withCredentials: isCookieAuth && !isBearerAuth,
headers: {
'Content-Type': 'application/json',
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
...(opts.headers ?? {}),
...(isBearerAuth && !isCookieAuth
? { Authorization: `Bearer ${opts.token}` }
+11
View File
@@ -0,0 +1,11 @@
@layer utilities {
.step.step-success::before {
--step-bg: var(--color-success);
--step-fg: var(--color-success-content);
}
.step.step-error::before {
--step-bg: var(--color-error);
--step-fg: var(--color-error-content);
}
}
+38
View File
@@ -24,6 +24,36 @@ export type LogoutResponse = BaseApiResponse;
export type GetMeResponse = BaseApiResponse<UserWithRoles>;
export type Client = {
id: number;
name: stirng;
alias: string;
created_at: string;
updated_at: string;
};
export type Permission = {
id: number;
name: string;
action: string;
client: Omit<Client, 'created_at' | 'updated_at'>;
created_at: string;
updated_at: string;
};
export type Role = {
id: number;
key: string;
name: string;
client: Omit<Client, 'created_at' | 'updated_at'>;
created_at: string;
updated_at: string;
};
export type RoleWithPermissions = Omit<Role, 'created_at' | 'updated_at'> & {
permissions: Omit<Permission, 'created_at' | 'updated_at'>[];
};
export type User = {
id: number;
email: string;
@@ -66,3 +96,11 @@ export type flags =
| 'STARTER'
| 'FINISHER'
| 'OVK';
export type ApprovalsLine = {
action_by?: string;
date?: string;
notes?: string;
role?: string;
status: 'approved' | 'rejected' | 'waiting';
}[];
+75
View File
@@ -0,0 +1,75 @@
import { BaseMetadata } from '@/types/api/api-general';
import { Supplier } from '@/types/api/master-data/supplier';
type MovementWarehouse = {
id: number;
name: string;
location: {
id: number;
name: string;
} | null;
area: {
id: number;
name: string;
};
};
export type BaseMovement = {
id: number;
transfer_reason: string;
transfer_date: string;
source_warehouse: MovementWarehouse;
destination_warehouse: MovementWarehouse;
details: {
id: number;
product: {
id: number;
name: string;
};
quantity: number;
before_quantity: number;
after_quantity: number;
}[];
deliveries: {
id: number;
supplier: Supplier;
vehicle_plate: string;
driver_name: string;
document_number: string;
document_path: string;
shipping_cost_item: number;
shipping_cost_total: number;
items: {
id: number;
stock_transfer_detail_id: number;
quantity: number;
}[];
}[];
};
export type Movement = BaseMetadata & BaseMovement;
export type CreateMovementPayload = {
transfer_reason: string;
transfer_date: string;
source_warehouse_id: number;
destination_warehouse_id: number;
products: {
product_id: number;
product_qty: number;
}[];
deliveries: {
delivery_cost: number;
delivery_cost_per_item: number;
document_index?: number;
driver_name: string;
vehicle_plate: string;
supplier_id: number;
products: {
product_id: number;
product_qty: number;
}[];
}[];
};
export type UpdateMovementPayload = CreateMovementPayload;
+22
View File
@@ -0,0 +1,22 @@
import { BaseMetadata } from '@/types/api/api-general';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { Product } from '@/types/api/master-data/product';
export type BaseProductWarehouse = {
id: number;
product_id: number;
warehouse_id: number;
quantity: number;
product: Product;
warehouse: Warehouse;
};
export type ProductWarehouse = BaseMetadata & BaseProductWarehouse;
export type CreateProductWarehousePayload = {
product_id: number;
warehouse_id: number;
quantity: number;
};
export type UpdateProductWarehousePayload = CreateProductWarehousePayload;