diff --git a/.husky/pre-commit b/.husky/pre-commit index e7bb3165..3782914b 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,3 +1,3 @@ npm run format npm run lint -npm run build +npm run build \ No newline at end of file diff --git a/src/app/purchase/add/page.tsx b/src/app/purchase/add/page.tsx new file mode 100644 index 00000000..7e1cb9e7 --- /dev/null +++ b/src/app/purchase/add/page.tsx @@ -0,0 +1,11 @@ +import PurchaseRequestForm from '@/components/pages/purchase/form/request/PurchaseRequestForm'; + +const AddPurchaseRequest = () => { + return ( +
+ +
+ ); +}; + +export default AddPurchaseRequest; diff --git a/src/app/purchase/detail/edit/page.tsx b/src/app/purchase/detail/edit/page.tsx new file mode 100644 index 00000000..f93d1618 --- /dev/null +++ b/src/app/purchase/detail/edit/page.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; +import PurchaseRequestForm from '@/components/pages/purchase/form/request/PurchaseRequestForm'; +import { PurchaseApi } from '@/services/api/purchase'; +import { isResponseSuccess, isResponseError } from '@/lib/api-helper'; + +const PurchaseEdit = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const purchaseId = searchParams.get('purchaseId'); + + const { data: purchase, isLoading: isLoadingPurchase } = useSWR( + purchaseId, + (id: number) => PurchaseApi.getSingle(id) + ); + + if (!purchaseId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingPurchase && (!purchase || isResponseError(purchase))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingPurchase && ( + + )} + {!isLoadingPurchase && isResponseSuccess(purchase) && ( + + )} +
+ ); +}; + +export default PurchaseEdit; diff --git a/src/app/purchase/detail/page.tsx b/src/app/purchase/detail/page.tsx new file mode 100644 index 00000000..df0de97b --- /dev/null +++ b/src/app/purchase/detail/page.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; +import PurchaseOrderDetail from '@/components/pages/purchase/order/PurchaseOrderDetail'; +import { PurchaseApi } from '@/services/api/purchase'; +import { isResponseSuccess, isResponseError } from '@/lib/api-helper'; + +const PurchaseDetail = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const purchaseId = searchParams.get('purchaseId'); + + const { + data: purchase, + isLoading: isLoadingPurchase, + mutate: mutatePurchase, + } = useSWR(purchaseId, (id: number) => PurchaseApi.getSingle(id)); + + if (!purchaseId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingPurchase && (!purchase || isResponseError(purchase))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingPurchase && ( +
+ +
+ )} + {!isLoadingPurchase && isResponseSuccess(purchase) && ( + + )} +
+ ); +}; + +export default PurchaseDetail; diff --git a/src/app/purchase/layout.tsx b/src/app/purchase/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/purchase/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/purchase/page.tsx b/src/app/purchase/page.tsx new file mode 100644 index 00000000..dc25a99d --- /dev/null +++ b/src/app/purchase/page.tsx @@ -0,0 +1,11 @@ +import PurchaseTable from '@/components/pages/purchase/PurchaseTable'; + +const Purchase = () => { + return ( +
+ +
+ ); +}; + +export default Purchase; diff --git a/src/components/Card.tsx b/src/components/Card.tsx index 7b022971..d3ff80b1 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -1,9 +1,11 @@ 'use client'; -import { HTMLAttributes, ReactNode } from 'react'; +import { HTMLAttributes, ReactNode, useState } from 'react'; import { cn } from '@/lib/helper'; import Image from 'next/image'; +import Collapse from './Collapse'; +import { Icon } from '@iconify/react'; export interface CardProps extends Omit, 'className'> { @@ -11,8 +13,13 @@ export interface CardProps subtitle?: string; image?: string; imageAlt?: string; + imageWidth?: number; + imageHeight?: number; actions?: ReactNode; footer?: ReactNode; + collapsible?: boolean; + defaultCollapsed?: boolean; + onCollapsedChange?: (collapsed: boolean) => void; className?: { wrapper?: string; image?: string; @@ -21,6 +28,7 @@ export interface CardProps subtitle?: string; actions?: string; footer?: string; + collapsible?: string; }; variant?: 'default' | 'compact' | 'bordered' | 'shadow' | 'image-full'; size?: 'sm' | 'md' | 'lg'; @@ -31,14 +39,27 @@ const Card = ({ subtitle, image, imageAlt, + imageWidth, + imageHeight, actions, footer, + collapsible, + defaultCollapsed = false, + onCollapsedChange, className, variant = 'default', size = 'md', children, ...props }: CardProps) => { + const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed); + + const handleCollapsedChange = (open: boolean) => { + const collapsed = !open; + setIsCollapsed(collapsed); + onCollapsedChange?.(collapsed); + }; + const getCardClasses = () => { const baseClasses = 'card bg-base-100'; @@ -64,11 +85,31 @@ const Card = ({ ); }; + const getImageDimensions = () => { + if (variant === 'image-full') { + return { + width: imageWidth || 128, + height: imageHeight || 128, + }; + } + + const cardWidths = { + sm: 256, // w-64 + md: 384, // w-96 + lg: 448, // w-[28rem] + }; + + return { + width: imageWidth || cardWidths[size], + height: imageHeight || 192, + }; + }; + const getImageClasses = () => { if (variant === 'image-full') { - return cn('w-32 h-32 object-cover', className?.image); + return cn('object-cover', className?.image); } - return cn('h-48 object-cover', className?.image); + return cn('w-full object-cover', className?.image); }; const getBodyClasses = () => { @@ -103,45 +144,98 @@ const Card = ({ return cn('border-t border-base-300 mt-4 pt-4', className?.footer); }; + const renderCardContent = () => { + const hasContent = children || actions || footer; + + const titleContent = ( +
+
+ {title &&

{title}

} + {subtitle &&

{subtitle}

} +
+ {collapsible && ( + + )} +
+ ); + + const cardContent = ( +
+ {children} + {actions &&
{actions}
} + {footer &&
{footer}
} +
+ ); + + return ( + <> + {image && ( +
+ {imageAlt +
+ )} +
+ {collapsible && hasContent ? ( + + {cardContent} + + ) : ( + <> + {(title || subtitle) && ( +
+ {title &&

{title}

} + {subtitle && ( +

{subtitle}

+ )} +
+ )} + {hasContent && cardContent} + + )} +
+ + ); + }; + if (variant === 'image-full' && image) { return (
-
- {imageAlt -
-
- {title &&

{title}

} - {subtitle &&

{subtitle}

} - {children} - {actions &&
{actions}
} -
- {footer &&
{footer}
} + {renderCardContent()}
); } return (
- {image && ( -
- {imageAlt -
- )} -
- {title &&

{title}

} - {subtitle &&

{subtitle}

} - {children} - {actions &&
{actions}
} -
- {footer &&
{footer}
} + {renderCardContent()}
); }; diff --git a/src/components/Collapse.tsx b/src/components/Collapse.tsx index 8506f65c..50d68017 100644 --- a/src/components/Collapse.tsx +++ b/src/components/Collapse.tsx @@ -26,6 +26,9 @@ export type CollapseProps = { disabled?: boolean; /** Allow only one open at a time by switching to radio input */ asRadio?: boolean; + /** Force full width instead of auto-fit when collapsed + * (Khusus justify-between dan justify-end) */ + fullWidth?: boolean; /** Extra classnames */ className?: string; titleClassName?: string; @@ -44,6 +47,7 @@ export const Collapse = ({ bordered, disabled, asRadio = false, + fullWidth, className, titleClassName, contentClassName, @@ -68,9 +72,9 @@ export const Collapse = ({ 'collapse', variant === 'arrow' && 'collapse-arrow', variant === 'plus' && 'collapse-plus', - bordered && 'border base-content/20 border-opacity-20 rounded', + bordered && 'border base-content/20 border-opacity-20 rounded-box', disabled && 'opacity-60 pointer-events-none', - !open && 'w-fit', + !fullWidth && !open && 'w-fit', className ); diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index a242b1e4..5a1dc806 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -10,15 +10,19 @@ import { } from 'react'; import { cn } from '@/lib/helper'; -export const useModal = () => { +export const useModal = (isNestingModal = false) => { const ref = useRef(null); const [open, setOpen] = useState(false); const openModal = useCallback(() => { if (!ref.current) return; - ref.current.show(); + if (isNestingModal) { + ref.current.showModal(); + } else { + ref.current.show(); + } setOpen(true); - }, []); + }, [isNestingModal]); const closeModal = useCallback(() => { if (!ref.current) return; diff --git a/src/components/input/DateInput.tsx b/src/components/input/DateInput.tsx index 8c3dd35d..77267090 100644 --- a/src/components/input/DateInput.tsx +++ b/src/components/input/DateInput.tsx @@ -7,10 +7,10 @@ import { useState, } from 'react'; import { cn, formatDate } from '@/lib/helper'; -import Modal, { useModal } from '@/components/Modal'; +import Modal, { useModal } from '../Modal'; import { DateRange, DayPicker, Matcher } from 'react-day-picker'; import 'react-day-picker/dist/style.css'; -import Button from '@/components/Button'; +import Button from '../Button'; import { Icon } from '@iconify/react'; export interface DateInputProps { @@ -34,6 +34,7 @@ export interface DateInputProps { required?: boolean; isLoading?: boolean; isRange?: boolean; + isNestedModal?: boolean; // New prop to indicate if used inside another modal errorMessage?: string; onChange?: ChangeEventHandler; onBlur?: FocusEventHandler; @@ -58,6 +59,7 @@ const DateInput = ({ readOnly = false, isLoading = false, isRange = false, + isNestedModal = false, }: DateInputProps) => { const [internalError, setInternalError] = useState(null); const [selected, setSelected] = useState(); @@ -74,7 +76,7 @@ const DateInput = ({ ? new Date(max.split('/').reverse().join('-')) : undefined; - const calendarModal = useModal(); + const calendarModal = useModal(isNestedModal); // --- Sync value props --- useEffect(() => { @@ -213,7 +215,7 @@ const DateInput = ({
diff --git a/src/components/input/DebouncedTextArea.tsx b/src/components/input/DebouncedTextArea.tsx new file mode 100644 index 00000000..3df2c032 --- /dev/null +++ b/src/components/input/DebouncedTextArea.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { ChangeEvent, ChangeEventHandler, useEffect, useState } from 'react'; +import { useDebounce } from 'use-debounce'; + +import TextArea, { TextAreaProps } from '@/components/input/TextArea'; + +interface DebouncedTextAreaProps extends TextAreaProps { + delay?: number; +} + +const DebouncedTextArea = (props: DebouncedTextAreaProps) => { + const { delay, onChange } = props; + + const [internalChangeEvent, setInternalChangeEvent] = + useState>(); + const [internalValue, setInternalValue] = useState(props.value); + + const [debouncedChangeEvent] = useDebounce(internalChangeEvent, delay ?? 300); + const [debouncedValue] = useDebounce(internalValue, delay ?? 300); + + const internalChangeHandler: ChangeEventHandler = ( + e + ) => { + setInternalValue(e.target.value); + setInternalChangeEvent(e); + }; + + useEffect(() => { + if (debouncedChangeEvent) { + onChange?.(debouncedChangeEvent); + } + }, [debouncedValue]); + + return ( +