diff --git a/package-lock.json b/package-lock.json index f0212474..1e8f3fd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "clsx": "^2.1.1", "formik": "^2.4.6", "moment": "^2.30.1", - "next": "15.5.7", + "next": "^15.5.9", "react": "19.1.0", "react-day-picker": "^9.11.1", "react-dom": "19.1.0", @@ -26,6 +26,7 @@ "swr": "^2.3.6", "tailwind-merge": "^3.3.1", "use-debounce": "^10.0.6", + "xlsx": "^0.18.5", "yup": "^1.7.0", "zustand": "^5.0.8" }, @@ -1082,9 +1083,9 @@ } }, "node_modules/@next/env": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz", - "integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz", + "integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -2464,6 +2465,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2924,6 +2934,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2965,6 +2988,15 @@ "node": ">=6" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3035,6 +3067,18 @@ "node": ">=10" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4180,6 +4224,15 @@ "react": ">=16.8.0" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5654,12 +5707,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz", - "integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", + "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==", "license": "MIT", "dependencies": { - "@next/env": "15.5.7", + "@next/env": "15.5.9", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -6754,6 +6807,18 @@ "node": ">=0.10.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -7515,6 +7580,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -7525,6 +7608,27 @@ "node": ">=0.10.0" } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/yaml": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", diff --git a/package.json b/package.json index 52fc6ce2..aa26b7bf 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "clsx": "^2.1.1", "formik": "^2.4.6", "moment": "^2.30.1", - "next": "15.5.7", + "next": "^15.5.9", "react": "19.1.0", "react-day-picker": "^9.11.1", "react-dom": "19.1.0", @@ -29,6 +29,7 @@ "swr": "^2.3.6", "tailwind-merge": "^3.3.1", "use-debounce": "^10.0.6", + "xlsx": "^0.18.5", "yup": "^1.7.0", "zustand": "^5.0.8" }, diff --git a/src/app/report/marketing/page.tsx b/src/app/report/marketing/page.tsx new file mode 100644 index 00000000..52a3d4dd --- /dev/null +++ b/src/app/report/marketing/page.tsx @@ -0,0 +1,11 @@ +import MarketingReportContent from '@/components/pages/report/MarketingReportContent'; + +const MarketingReportPage = () => { + return ( +
+ +
+ ); +}; + +export default MarketingReportPage; diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index bee92a57..0d5b9bc8 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -54,7 +54,8 @@ const Navbar = ({ title, toggleSidebar }: NavbarProps) => {
@@ -62,9 +63,11 @@ const Navbar = ({ title, toggleSidebar }: NavbarProps) => {
} - contentClassName='w-52 mt-3' + className={{ + content: 'w-52 mt-3', + }} > - + diff --git a/src/components/Tabs.tsx b/src/components/Tabs.tsx index 2ad2477d..8f685452 100644 --- a/src/components/Tabs.tsx +++ b/src/components/Tabs.tsx @@ -21,6 +21,7 @@ export interface TabsProps className?: | string | { + container?: string; wrapper?: string; tab?: string; content?: string; @@ -53,10 +54,14 @@ const Tabs = ({ onTabChange?.(tabId); }; - const { wrapper: wrapperClassName, tab: tabClassName } = - typeof className === 'object' - ? className - : { wrapper: className, tab: undefined }; + const { + container: containerClassName, + wrapper: wrapperClassName, + tab: tabClassName, + content: contentClassName, + } = typeof className === 'object' + ? className + : { wrapper: className, tab: undefined }; const getTabsClasses = () => { const variantClasses: Record = { @@ -104,7 +109,7 @@ const Tabs = ({ {...props} className={cn( 'w-full', - typeof className === 'string' ? className : undefined + typeof className === 'string' ? className : containerClassName )} >
@@ -121,7 +126,9 @@ const Tabs = ({ ))}
- {activeContent &&
{activeContent}
} + {activeContent && ( +
{activeContent}
+ )} ); }; diff --git a/src/components/dropdown/Dropdown.tsx b/src/components/dropdown/Dropdown.tsx index 4489231d..5bfa7a7d 100644 --- a/src/components/dropdown/Dropdown.tsx +++ b/src/components/dropdown/Dropdown.tsx @@ -1,111 +1,109 @@ -'use client'; +import React, { ReactNode, useState, useRef } from 'react'; -import { ReactNode, useRef, useEffect, useState } from 'react'; import { cn } from '@/lib/helper'; -interface DropdownProps { +export interface DropdownProps { trigger: ReactNode; children: ReactNode; - position?: - | 'top' - | 'bottom' - | 'left' - | 'right' - | 'top-start' - | 'top-end' - | 'bottom-start' - | 'bottom-end' - | 'left-start' - | 'left-end' - | 'right-start' - | 'right-end'; + className?: { + wrapper?: string; + trigger?: string; + content?: string; + }; align?: 'start' | 'center' | 'end'; + direction?: 'top' | 'bottom' | 'left' | 'right'; hover?: boolean; - className?: string; - contentClassName?: string; + defaultOpen?: boolean; + open?: boolean; + close?: boolean; + controlled?: boolean; } const Dropdown = ({ trigger, children, - position = 'bottom', - align = 'start', - hover = false, className, - contentClassName, + align, + direction, + hover, + defaultOpen = false, + open, + close, + controlled = false, }: DropdownProps) => { - const [isOpen, setIsOpen] = useState(false); + const [isOpen, setIsOpen] = useState(defaultOpen); const dropdownRef = useRef(null); - // Handle click outside to close dropdown - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) - ) { - setIsOpen(false); - } - }; - - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); + const toggleDropdown = () => { + if (!controlled) { + const newState = !isOpen; + setIsOpen(newState); } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [isOpen]); - - // Build position classes - const getPositionClasses = () => { - const classes: string[] = []; - - // Handle combined positions like 'top-start' - if (position.includes('-')) { - const [pos, al] = position.split('-'); - classes.push(`dropdown-${pos}`); - classes.push(`dropdown-${al}`); - } else { - classes.push(`dropdown-${position}`); - if (align !== 'start') { - classes.push(`dropdown-${align}`); - } - } - - return classes.join(' '); }; - const handleToggle = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - // alert('clicked'); - setIsOpen(!isOpen); + const getWrapperClasses = () => { + const openState = controlled ? open : isOpen; + + return cn( + 'dropdown', + { + 'dropdown-start': align === 'start', + 'dropdown-center': align === 'center', + 'dropdown-end': align === 'end', + 'dropdown-top': direction === 'top', + 'dropdown-bottom': direction === 'bottom', + 'dropdown-left': direction === 'left', + 'dropdown-right': direction === 'right', + 'dropdown-hover': hover, + 'dropdown-open': openState && !close, + 'dropdown-close': close, + }, + className?.wrapper + ); }; + const getTriggerClasses = () => { + return cn(className?.trigger); + }; + + const getContentClasses = () => { + return cn( + 'dropdown-content z-[9999] shadow-sm bg-base-100 rounded-box', + className?.content + ); + }; + + if (controlled) { + return ( +
+ {trigger} + {open && !close && ( +
+ {children} +
+ )} +
+ ); + } + return ( -
- {/* Trigger Button */} -
+
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleDropdown(); + } + }} + > {trigger}
- - {/* Dropdown Content - Only render when open */} - {isOpen && ( -
setIsOpen(false)} // Close on item click - > + {!close && ( +
{children}
)} diff --git a/src/components/helper/RequireAuth.tsx b/src/components/helper/RequireAuth.tsx index 65adf48c..9dbd2557 100644 --- a/src/components/helper/RequireAuth.tsx +++ b/src/components/helper/RequireAuth.tsx @@ -27,6 +27,9 @@ const RequireAuth = ({ children }: RequireAuthProps) => { SWRHttpKey >('/sso/userinfo', httpClientFetcher, { shouldRetryOnError: false, + + // refresh every 13 minutes + refreshInterval: 13 * 60 * 1000, }); useEffect(() => { diff --git a/src/components/menu/MenuItem.tsx b/src/components/menu/MenuItem.tsx index dce81dac..61af4b04 100644 --- a/src/components/menu/MenuItem.tsx +++ b/src/components/menu/MenuItem.tsx @@ -8,6 +8,7 @@ interface MenuItemProps { href?: string; icon?: string; active?: boolean; + isLoading?: boolean; onClick?: () => void; className?: string; } @@ -17,6 +18,7 @@ const MenuItem = ({ href, icon, active = false, + isLoading = false, className, onClick, }: MenuItemProps) => { @@ -50,17 +52,28 @@ const MenuItem = ({ return (
  • - {href && ( + {!isLoading && href && ( {menuItemContent} )} - {!href && ( + {!isLoading && !href && ( )} + + {isLoading && ( + + )}
  • ); }; diff --git a/src/components/pages/report/DailyMarketingReportContent.tsx b/src/components/pages/report/DailyMarketingReportContent.tsx new file mode 100644 index 00000000..1eba4ea3 --- /dev/null +++ b/src/components/pages/report/DailyMarketingReportContent.tsx @@ -0,0 +1,413 @@ +'use client'; + +import { ChangeEventHandler, useState } from 'react'; +import { pdf } from '@react-pdf/renderer'; +import toast from 'react-hot-toast'; + +import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; +import Dropdown from '@/components/dropdown/Dropdown'; +import DateInput from '@/components/input/DateInput'; +import SelectInput, { + OptionType, + useSelect, +} from '@/components/input/SelectInput'; +import Menu from '@/components/menu/Menu'; +import MenuItem from '@/components/menu/MenuItem'; +import DailyMarketingsTable from '@/components/pages/report/DailyMarketingsTable'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import DailyMarketingReportPDF from '@/components/pages/report/DailyMarketingReportPDF'; + +import { Area } from '@/types/api/master-data/area'; +import { + AreaApi, + CustomerApi, + LocationApi, + WarehouseApi, +} from '@/services/api/master-data'; +import { Warehouse } from '@/types/api/master-data/warehouse'; +import { Customer } from '@/types/api/master-data/customer'; +import { MarketingReportApi } from '@/services/api/report/marketing-report'; +import { MARKETING_TYPE_OPTIONS } from '@/config/constant'; +import { httpClient } from '@/services/http/client'; +import { BaseApiResponse } from '@/types/api/api-general'; +import { DailyMarketingReport } from '@/types/api/report/marketing'; +import { isResponseError } from '@/lib/api-helper'; + +const DailyMarketingReportContent = () => { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + reset: resetFilter, + } = useTableFilter({ + initial: { + search: '', + area_id: '', + location_id: '', + warehouse_id: '', + customer_id: '', + start_date: '', + end_date: '', + marketing_type: '', + filter_by: '', + sort_by: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + area_id: 'area_id', + location_id: 'location_id', + warehouse_id: 'warehouse_id', + customer_id: 'customer_id', + start_date: 'start_date', + end_date: 'end_date', + marketing_type: 'marketing_type', + filter_by: 'filter_by', + sort_by: 'sort_by', + }, + }); + + const dailyMarketingsReportUrl = `${MarketingReportApi.basePath}${getTableFilterQueryString()}`; + + const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] = + useState(false); + const [isLoadingExportingToPdf, setIsLoadingExportingToPdf] = useState(false); + + const [selectedArea, setSelectedArea] = useState(null); + const { + setInputValue: setAreaInputValue, + options: areaOptions, + isLoadingOptions: isLoadingAreaOptions, + } = useSelect(AreaApi.basePath, 'id', 'name'); + + const areaChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedArea(val as OptionType); + updateFilter('area_id', val ? ((val as OptionType).value as string) : ''); + }; + + const [selectedLocation, setSelectedLocation] = useState( + null + ); + const { + setInputValue: setLocationInputValue, + options: locationOptions, + isLoadingOptions: isLoadingLocationOptions, + } = useSelect(LocationApi.basePath, 'id', 'name'); + + const locationChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedLocation(val as OptionType); + updateFilter( + 'location_id', + val ? ((val as OptionType).value as string) : '' + ); + }; + + const [selectedWarehouse, setSelectedWarehouse] = useState( + null + ); + const { + setInputValue: setWarehouseInputValue, + options: warehouseOptions, + isLoadingOptions: isLoadingWarehouseOptions, + } = useSelect(WarehouseApi.basePath, 'id', 'name'); + + const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedWarehouse(val as OptionType); + updateFilter( + 'warehouse_id', + val ? ((val as OptionType).value as string) : '' + ); + }; + + const [selectedCustomer, setSelectedCustomer] = useState( + null + ); + const { + setInputValue: setCustomerInputValue, + options: customerOptions, + isLoadingOptions: isLoadingCustomerOptions, + } = useSelect(CustomerApi.basePath, 'id', 'name'); + + const customerChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedCustomer(val as OptionType); + updateFilter( + 'customer_id', + val ? ((val as OptionType).value as string) : '' + ); + }; + + const startDateChangeHandler = (e: React.ChangeEvent) => { + updateFilter('start_date', e.target.value ? e.target.value : ''); + }; + + const endDateChangeHandler = (e: React.ChangeEvent) => { + updateFilter('end_date', e.target.value ? e.target.value : ''); + }; + + const [selectedMarketingType, setSelectedMarketingType] = + useState(null); + const marketingTypeChangeHandler = ( + val: OptionType | OptionType[] | null + ) => { + setSelectedMarketingType(val as OptionType); + updateFilter( + 'marketing_type', + val ? ((val as OptionType).value as string) : '' + ); + }; + + const searchChangeHandler: ChangeEventHandler = (e) => { + updateFilter('search', e.target.value); + }; + + const filterByChangeHandler = (filterBy: string) => { + updateFilter('filter_by', filterBy); + }; + + const sortByChangeHandler = (sort: 'asc' | 'desc' | '') => { + updateFilter('sort_by', sort); + }; + + const exportToExcelHandler = async () => { + setIsLoadingExportingToExcel(true); + + await MarketingReportApi.exportDailyMarketingToExcel( + getTableFilterQueryString() + ); + + setIsLoadingExportingToExcel(false); + }; + + const exportToPdfHandler = async () => { + setIsLoadingExportingToPdf(true); + + const params = new URLSearchParams(getTableFilterQueryString()); + + params.set('limit', '9999999'); + + const queryString = `?${params.toString()}`; + + try { + const dailyMarketingsReport = await httpClient< + BaseApiResponse + >(`${MarketingReportApi.basePath}${queryString}`); + + if (isResponseError(dailyMarketingsReport)) { + toast.error('Gagal melakukan export penjualan harian! Coba lagi.'); + return; + } + + const openPdf = async () => { + const dailyMarketingReportPdfBlob = await pdf( + + ).toBlob(); + + const dailyMarketingReportPdfUrl = URL.createObjectURL( + dailyMarketingReportPdfBlob + ); + window.open(dailyMarketingReportPdfUrl, '_blank'); + }; + + const downloadPdf = async () => { + const blob = await pdf( + + ).toBlob(); + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = 'laporan-penjualan-harian.pdf'; + link.click(); + + URL.revokeObjectURL(url); + }; + + await openPdf(); + } catch (error) { + toast.error('Gagal melakukan export penjualan harian! Coba lagi.'); + } + + setIsLoadingExportingToPdf(false); + }; + + const handleReset = () => { + setSelectedArea(null); + setSelectedLocation(null); + setSelectedWarehouse(null); + setSelectedCustomer(null); + setSelectedMarketingType(null); + resetFilter(); + }; + + return ( +
    +
    +

    Penjualan Harian

    +
    + + {/* Filters */} +
    +
    + + + + + + + + + + + +
    + +
    + + +
    + + + + + + Export{' '} + + + } + > + + + + + +
    +
    +
    + + +
    + ); +}; + +export default DailyMarketingReportContent; diff --git a/src/components/pages/report/DailyMarketingReportPDF.tsx b/src/components/pages/report/DailyMarketingReportPDF.tsx new file mode 100644 index 00000000..337892b3 --- /dev/null +++ b/src/components/pages/report/DailyMarketingReportPDF.tsx @@ -0,0 +1,550 @@ +'use client'; + +import { + Document, + Image, + Page, + StyleSheet, + Text, + View, +} from '@react-pdf/renderer'; + +import { DailyMarketingReport } from '@/types/api/report/marketing'; +import { formatCurrency, formatDate, formatNumber } from '@/lib/helper'; + +interface DailyMarketingReportPDFProps { + data?: DailyMarketingReport; +} + +const DailyMarketingReportPDFStyle = StyleSheet.create({ + page: { + paddingTop: 24, + paddingBottom: 64, + paddingHorizontal: 16, // Reduce padding to fit more columns + orientation: 'landscape', + }, + + companyInfoHeader: { + width: '100%', + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 8, + }, + companyLogo: { + width: 64, + height: 'auto', + }, + companyInfoHeaderDate: { + paddingTop: 8, + fontSize: 10, + }, + companyName: { + fontSize: 12, + fontWeight: 'bold', + marginBottom: 4, + }, + companyAddress: { + fontSize: 8, + maxWidth: 400, + marginBottom: 10, + }, + + title: { + marginTop: 16, + fontSize: 14, + lineHeight: '150%', + textAlign: 'center', + fontFamily: 'Times-Roman', + fontWeight: 'bold', + }, + + footer: { + width: '100%', + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + + position: 'absolute', + fontSize: 8, + bottom: 30, + left: 0, + right: 0, + textAlign: 'center', + color: 'grey', + }, + + // Table Styles + table: { + width: '100%', + marginTop: 16, + borderWidth: 1, + borderColor: '#000000', + borderBottomWidth: 0, + fontSize: 7, // Smaller font for report + }, + tableRow: { + flexDirection: 'row', + borderBottomWidth: 1, + borderBottomColor: '#000000', + alignItems: 'center', + minHeight: 20, + }, + tableHeader: { + backgroundColor: '#f0f0f0', + fontWeight: 'bold', + }, + + // Columns definition (Total 100%) + colNo: { + width: '3%', + padding: 2, + textAlign: 'center', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colSoDate: { + width: '6%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colDoDate: { + width: '6%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colAging: { + width: '3%', + padding: 2, + textAlign: 'center', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colWarehouse: { + width: '7%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colCustomer: { + width: '9%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, // Reduced slightly + colSales: { + width: '6%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colProduct: { + width: '8%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, // Reduced slightly + colDoNumber: { + width: '7%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colVehicle: { + width: '5%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colMarketingType: { + width: '5%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colQty: { + width: '4%', + padding: 2, + textAlign: 'right', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colAvgWeight: { + width: '4%', + padding: 2, + textAlign: 'right', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colTotalWeight: { + width: '5%', + padding: 2, + textAlign: 'right', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colSalesPrice: { + width: '5%', + padding: 2, + textAlign: 'right', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colHppPrice: { + width: '5%', + padding: 2, + textAlign: 'right', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colSalesAmount: { + width: '6%', + padding: 2, + textAlign: 'right', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colHppAmount: { width: '6%', padding: 2, textAlign: 'right' }, // Last column + + // Text inside columns + cellText: { + fontSize: 6, + }, + headerText: { + fontSize: 7, + fontWeight: 'bold', + textAlign: 'center', + }, + + // Utils + doubleDivider: { + width: '100%', + height: 6, + borderTop: '2px solid black', + borderBottom: '2px solid black', + }, + + // Summary + summaryContainer: { + marginTop: 12, + flexDirection: 'row', + justifyContent: 'flex-end', + width: '100%', + }, + summaryTable: { + width: '30%', + borderWidth: 1, + borderColor: '#000000', + fontSize: 8, + }, + summaryRow: { + flexDirection: 'row', + padding: 2, + borderBottomWidth: 1, + borderBottomColor: '#eee', + }, + summaryLabel: { + width: '50%', + fontWeight: 'bold', + }, + summaryValue: { + width: '50%', + textAlign: 'right', + }, +}); + +const DailyMarketingReportPDF = ({ data }: DailyMarketingReportPDFProps) => { + const rows = data?.rows || []; + const summary = data?.summary; + + return ( + + + + + + + + {formatDate(Date.now(), 'DD MMMM YYYY')} + + + + + + PT LUMBUNG TELUR INDONESIA + + + SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel. + Cipedes, Kec. Sukajadi, Kota Bandung 40162 + + + + + + + + Laporan Penjualan Harian + + + {/* Data Table */} + + {/* Header */} + + + No + + + + Tgl SO + + + + + Tgl DO + + + + Aging + + + + Gudang + + + + + Pelanggan + + + + Sales + + + + Produk + + + + No DO + + + + Plat No + + + + Tipe + + + Qty + + + + Rerata + + + + Berat + + + + Hrg Jual + + + + + HPP/kg + + + + + Total Jual + + + + + Total HPP + + + + + {/* Rows */} + {rows.map((row, index) => ( + + + + {index + 1} + + + + + {formatDate(row.so_date, 'DD/MM/YYYY')} + + + + + {formatDate(row.do_date, 'DD/MM/YYYY')} + + + + + {row.aging_days} + + + + + {row.warehouse?.name} + + + + + {row.customer?.name} + + + + + {row.sales} + + + + + {row.product?.name} + + + + + {row.do_number} + + + + + {row.vehicle_number} + + + + + {row.marketing_type} + + + + + {formatNumber(row.qty)} + + + + + {formatNumber(row.average_weight_kg)} + + + + + {formatNumber(row.total_weight_kg)} + + + + + {formatCurrency(row.sales_price_per_kg)} + + + + + {formatCurrency(row.hpp_price_per_kg)} + + + + + {formatCurrency(row.sales_amount)} + + + + + {formatCurrency(row.hpp_amount)} + + + + ))} + + + {/* Summary */} + + + + + Total Qty: + + + {formatNumber(summary?.total_qty ?? 0)} + + + + + Total Berat (kg): + + + {formatNumber(summary?.total_weight_kg ?? 0)} + + + + + Total Penjualan: + + + {formatCurrency(summary?.total_sales_amount ?? 0)} + + + + + Total HPP: + + + {formatCurrency(summary?.total_hpp_amount ?? 0)} + + + + + + + + `${pageNumber} / ${totalPages}` + } + fixed + /> + + + + ); +}; + +export default DailyMarketingReportPDF; diff --git a/src/components/pages/report/DailyMarketingsTable.tsx b/src/components/pages/report/DailyMarketingsTable.tsx new file mode 100644 index 00000000..d6914cf1 --- /dev/null +++ b/src/components/pages/report/DailyMarketingsTable.tsx @@ -0,0 +1,255 @@ +'use client'; + +import { ChangeEventHandler, useEffect, useState } from 'react'; +import useSWR from 'swr'; +import { ColumnDef, SortingState } from '@tanstack/react-table'; + +import { Icon } from '@iconify/react'; +import Table from '@/components/Table'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import Card from '@/components/Card'; +import Collapse from '@/components/Collapse'; + +import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { DailyMarketingRow } from '@/types/api/report/marketing'; +import { MarketingReportApi } from '@/services/api/report/marketing-report'; + +interface DailyMarketingsTableProps { + dailyMarketingsReportUrl: string; + onSetPage: (page: number) => void; + pageSize: number; + onSetPageSize: (pageSize: number) => void; + searchValue: string; + onSearchChange: ChangeEventHandler; + onFilterByChange: (filterBy: string) => void; + onSortByChange: (sort: 'asc' | 'desc' | '') => void; +} + +const DailyMarketingsTable = ({ + dailyMarketingsReportUrl, + onSetPage, + pageSize, + onSetPageSize, + searchValue, + onSearchChange, + onFilterByChange, + onSortByChange, +}: DailyMarketingsTableProps) => { + const { data: dailyMarketings, isLoading: isLoadingDailyMarketings } = useSWR( + dailyMarketingsReportUrl, + MarketingReportApi.getAllDailyMarketingFetcher, + { + keepPreviousData: true, + } + ); + + const [open, setOpen] = useState(true); + + const [sorting, setSorting] = useState([]); + + const dailyMarketingColumns: ColumnDef[] = [ + { + header: 'No', + cell: (props) => props.row.index + 1, + }, + { + accessorKey: 'so_date', + header: 'Tanggal Jual', + cell: (props) => formatDate(props.row.original.so_date, 'DD-MMM-YYYY'), + footer: 'Total', + }, + { + accessorKey: 'do_date', + header: 'Tanggal DO', + cell: (props) => formatDate(props.row.original.do_date, 'DD-MMM-YYYY'), + }, + { + accessorKey: 'aging_days', + header: 'Aging', + cell: (props) => `${props.row.original.aging_days} hari`, + }, + { + accessorKey: 'warehouse.name', + header: 'Gudang', + }, + { + accessorKey: 'customer.name', + header: 'Pelanggan', + }, + { + accessorKey: 'do_number', + header: 'No. DO', + }, + { + accessorKey: 'sales', + header: 'Sales/Marketing', + }, + { + accessorKey: 'vehicle_number', + header: 'No. Polisi', + cell: (props) => ( + {props.row.original.vehicle_number} + ), + }, + { + accessorKey: 'marketing_type', + header: 'Marketing Type', + }, + { + accessorKey: 'product.name', + header: 'Produk', + }, + { + accessorKey: 'qty', + header: 'Kuantitas', + cell: (props) => formatNumber(props.row.original.qty), + footer: () => { + const totalQty = isResponseSuccess(dailyMarketings) + ? dailyMarketings.data.summary.total_qty + : 0; + + return formatNumber(totalQty); + }, + }, + { + accessorKey: 'average_weight_kg', + header: 'Bobot Rata-Rata (Kg)', + cell: (props) => formatNumber(props.row.original.average_weight_kg), + }, + { + accessorKey: 'total_weight_kg', + header: 'Bobot Total (Kg)', + cell: (props) => formatNumber(props.row.original.total_weight_kg), + footer: () => { + const totalWeightKg = isResponseSuccess(dailyMarketings) + ? dailyMarketings.data.summary.total_weight_kg + : 0; + + return formatNumber(totalWeightKg); + }, + }, + { + accessorKey: 'sales_price_per_kg', + header: 'Harga Jual (Rp)', + cell: (props) => formatCurrency(props.row.original.sales_price_per_kg), + }, + { + accessorKey: 'hpp_price_per_kg', + header: 'HPP (Rp)', + cell: (props) => formatCurrency(props.row.original.hpp_price_per_kg), + }, + { + accessorKey: 'sales_amount', + header: 'Total (Rp)', + cell: (props) => formatCurrency(props.row.original.sales_amount), + footer: () => { + const totalSalesAmount = isResponseSuccess(dailyMarketings) + ? dailyMarketings.data.summary.total_sales_amount + : 0; + + return formatCurrency(totalSalesAmount); + }, + }, + ]; + + useEffect(() => { + if (sorting.length === 1) { + onFilterByChange(sorting[0].id); + onSortByChange(sorting[0].desc ? 'desc' : 'asc'); + } else { + onFilterByChange(''); + onSortByChange(''); + } + }, [sorting]); + + useEffect(() => { + if (!open) { + setOpen( + isResponseSuccess(dailyMarketings) + ? dailyMarketings.data.rows.length > 0 + : false + ); + } + }, [dailyMarketings, isResponseSuccess]); + + return ( + + +
    Penjualan Harian
    + + +
    + } + className='w-full!' + titleClassName='w-full p-0!' + > +
    +
    +
    + +
    +
    + + + data={ + isResponseSuccess(dailyMarketings) + ? dailyMarketings?.data.rows + : [] + } + columns={dailyMarketingColumns} + pageSize={pageSize} + onPageSizeChange={onSetPageSize} + rowOptions={[10, 20, 50, 100]} + page={ + isResponseSuccess(dailyMarketings) + ? dailyMarketings?.meta?.page + : 0 + } + totalItems={ + isResponseSuccess(dailyMarketings) + ? dailyMarketings?.meta?.total_results + : 0 + } + onPageChange={onSetPage} + isLoading={isLoadingDailyMarketings} + sorting={sorting} + setSorting={setSorting} + renderFooter={true} + className={{ + containerClassName: cn({ + 'w-full mb-20': + isResponseSuccess(dailyMarketings) && + dailyMarketings?.data?.rows.length === 0, + }), + }} + /> +
    + + + ); +}; + +export default DailyMarketingsTable; diff --git a/src/components/pages/report/MarketingReportContent.tsx b/src/components/pages/report/MarketingReportContent.tsx new file mode 100644 index 00000000..160de8b2 --- /dev/null +++ b/src/components/pages/report/MarketingReportContent.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { JSX, useState } from 'react'; + +import Tabs from '@/components/Tabs'; +import DailyMarketingReportContent from '@/components/pages/report/DailyMarketingReportContent'; + +type MarketingReportTabType = + | 'daily' + | 'transaction' + | 'hpp-comparison' + | 'daily-hpp'; + +const marketingReportTabs: { + id: MarketingReportTabType; + label: string; + content: JSX.Element; +}[] = [ + { + id: 'daily', + label: 'Penjualan Harian', + content: , + }, +]; + +const MarketingReportContent = () => { + const [activeTab, setActiveTab] = useState('daily'); + + return ( +
    + +
    + ); +}; + +export default MarketingReportContent; diff --git a/src/config/constant.ts b/src/config/constant.ts index 96fc8401..c16862af 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -45,6 +45,17 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [ link: '/closing', icon: 'heroicons-outline:presentation-chart-bar', }, + { + text: 'Laporan', + link: '/report', + icon: 'heroicons-outline:document-text', + submenu: [ + { + text: 'Penjualan', + link: '/report/marketing', + }, + ], + }, { text: 'Persediaan', link: '/inventory', @@ -251,3 +262,29 @@ export const ACCEPTED_FILE_TYPE = { 'image/*': [], }, }; + +export const FILTER_TYPE_OPTIONS = [ + { + label: 'Tanggal Realisasi', + value: 'REALIZATION_DATE', + }, + { + label: 'Tanggal DO', + value: 'DO_DATE', + }, +]; + +export const MARKETING_TYPE_OPTIONS = [ + { + label: 'Ayam', + value: 'ayam', + }, + { + label: 'Telur', + value: 'telur', + }, + { + label: 'Trading', + value: 'trading', + }, +]; diff --git a/src/dummy/report/marketing-report.dummy.ts b/src/dummy/report/marketing-report.dummy.ts new file mode 100644 index 00000000..ea5af398 --- /dev/null +++ b/src/dummy/report/marketing-report.dummy.ts @@ -0,0 +1,139 @@ +import { BaseApiResponse } from '@/types/api/api-general'; +import { DailyMarketingReport } from '@/types/api/report/marketing'; + +// TODO: delete this later +export const DAILY_MARKETING_DUMMY_DATA: BaseApiResponse = + { + code: 200, + status: 'success', + message: 'Get daily marketing report successfully', + meta: { + page: 1, + limit: 10, + total_pages: 1, + total_results: 2, + }, + data: { + rows: [ + { + // metadata + created_user: { + id: 1, + id_user: 101, + email: 'admin@example.com', + name: 'Admin User', + }, + created_at: '2025-12-01T08:00:00Z', + updated_at: '2025-12-01T08:00:00Z', + + // row data + no: 1, + so_date: '2025-12-01', + do_date: '2025-12-08', + aging_days: 7, + + warehouse: { + id: 1, + name: 'Warehouse Kandang A', + type: 'KANDANG', + area: { + id: 1, + name: 'Area Barat', + }, + location: { + id: 1, + name: 'Farm Bandung', + address: 'Jl. Raya Farm No. 1', + area: null, + }, + kandang: { + id: 1, + name: 'Kandang A1', + status: 'ACTIVE', + capacity: 5000, + location: null, + pic: null, + }, + }, + + customer: { + id: 1, + name: 'PT Maju Jaya', + pic_id: 10, + pic: { + id: 10, + id_user: 210, + email: 'pic@majujaya.com', + name: 'Budi Santoso', + }, + type: 'BROILER', + address: 'Jl. Industri No. 10', + phone: '08123456789', + email: 'contact@majujaya.com', + account_number: '1234567890', + }, + + sales: 'Andi Wijaya', + + product: { + id: 1, + name: 'Live Chicken', + brand: 'LTI Farm', + sku: 'LC-001', + product_price: 18_000, + selling_price: 20_000, + tax: 0, + expiry_period: 0, + uom: { + id: 1, + name: 'Kg', + created_user: { + id: 1, + id_user: 101, + email: 'admin@example.com', + name: 'Admin User', + }, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + }, + product_category: { + id: 1, + code: 'BROILER', + name: 'Broiler Chicken', + created_user: { + id: 1, + id_user: 101, + email: 'admin@example.com', + name: 'Admin User', + }, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + }, + suppliers: [], + flags: ['LIVE'], + }, + + do_number: 'DO-2025-0001', + vehicle_number: 'B 1234 CD', + marketing_type: 'REGULAR', + + qty: 1000, + average_weight_kg: 1.8, + total_weight_kg: 1800, + + sales_price_per_kg: 20_000, + hpp_price_per_kg: 18_000, + + sales_amount: 36_000_000, + hpp_amount: 32_400_000, + }, + ], + + summary: { + total_qty: 1000, + total_weight_kg: 1800, + total_sales_amount: 36_000_000, + total_hpp_amount: 32_400_000, + }, + }, + }; diff --git a/src/services/api/closing.ts b/src/services/api/closing.ts index 5e6ced3a..21ae1cf8 100644 --- a/src/services/api/closing.ts +++ b/src/services/api/closing.ts @@ -25,6 +25,7 @@ import { } from '@/dummy/closing.dummy'; import { httpClient, httpClientFetcher } from '@/services/http/client'; import { ClosingSales } from '@/types/api/closing'; +import { sleep } from '@/lib/helper'; export class ClosingApiService extends BaseApiService { constructor(basePath: string) { diff --git a/src/services/api/report/marketing-report.ts b/src/services/api/report/marketing-report.ts new file mode 100644 index 00000000..b1bcafae --- /dev/null +++ b/src/services/api/report/marketing-report.ts @@ -0,0 +1,75 @@ +import * as XLSX from 'xlsx'; +import toast from 'react-hot-toast'; + +import { BaseApiService } from '@/services/api/base'; +import { httpClient, httpClientFetcher } from '@/services/http/client'; +import { BaseApiResponse } from '@/types/api/api-general'; +import { DailyMarketingReport } from '@/types/api/report/marketing'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { formatDate, sleep } from '@/lib/helper'; + +export class MarketingReportApiService extends BaseApiService< + DailyMarketingReport, + unknown, + unknown +> { + constructor(basePath: string = '/reports/marketings/daily-marketing') { + super(basePath); + } + + async getAllDailyMarketingFetcher( + endpoint: string + ): Promise> { + return await httpClientFetcher>( + endpoint + ); + } + + async exportDailyMarketingToExcel(initialQueryString: string) { + const params = new URLSearchParams(initialQueryString); + + params.set('limit', '9999999'); + + const queryString = `?${params.toString()}`; + + try { + const dailyMarketingsReport = await httpClientFetcher< + BaseApiResponse + >(`${this.basePath}${queryString}`); + + if (isResponseError(dailyMarketingsReport)) { + toast.error('Gagal melakukan export penjualan harian! Coba lagi.'); + return; + } + + const rows = dailyMarketingsReport.data.rows; + + const formattedRows = []; + + for (let i = 0; i < rows.length; i++) { + formattedRows.push({ + ...rows[i], + created_user: rows[i].created_user.name, + created_at: formatDate(rows[i].created_at, 'YYYY-MM-DD'), + updated_at: formatDate(rows[i].updated_at, 'YYYY-MM-DD'), + warehouse: rows[i].warehouse.name, + customer: rows[i].customer.name, + product: rows[i].product.name, + }); + } + + const ws = XLSX.utils.json_to_sheet(formattedRows); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'laporan-penjualan-harian'); + + // triggers download in browser + XLSX.writeFile(wb, 'laporan-penjualan-harian.xlsx'); + } catch (error) { + toast.error('Gagal melakukan export penjualan harian! Coba lagi.'); + } + } +} + +export const MarketingReportApi = new MarketingReportApiService( + '/reports/marketings/daily-marketing' +); diff --git a/src/types/api/master-data/kandang.d.ts b/src/types/api/master-data/kandang.d.ts index c9c14882..eafa0334 100644 --- a/src/types/api/master-data/kandang.d.ts +++ b/src/types/api/master-data/kandang.d.ts @@ -10,7 +10,6 @@ export type BaseKandang = { capacity: number; pic: BaseUser; project_flock_kandang_id?: number; - capacity: number; }; export type Kandang = BaseMetadata & BaseKandang; diff --git a/src/types/api/report/marketing.d.ts b/src/types/api/report/marketing.d.ts new file mode 100644 index 00000000..d1e81f77 --- /dev/null +++ b/src/types/api/report/marketing.d.ts @@ -0,0 +1,61 @@ +import { BaseMetadata } from '@/types/api/api-general'; +import { BaseCustomer, Customer } from '@/types/api/master-data/customer'; +import { + BaseWarehouseArea, + BaseWarehouseKandang, + BaseWarehouseLocation, + Warehouse, +} from '@/types/api/master-data/warehouse'; +import { Location } from '@/types/api/master-data/location'; +import { Area } from '@/types/api/master-data/area'; +import { BaseProduct } from '@/types/api/master-data/product'; + +export type BaseDailyMarketingRow = { + no: number; + so_date: string; // e.g. "01-Dec-2025" + do_date: string; // e.g. "08-Dec-2025" + aging_days: number; + + warehouse: BaseWarehouseArea | BaseWarehouseLocation | BaseWarehouseKandang; + customer: BaseCustomer; + sales: string; + product: BaseProduct; + + do_number: string; + vehicle_number: string; + marketing_type: string; + + qty: number; + average_weight_kg: number; + total_weight_kg: number; + + sales_price_per_kg: number; + hpp_price_per_kg: number; + + sales_amount: number; + hpp_amount: number; +}; + +export type DailyMarketingRow = BaseMetadata & BaseDailyMarketingRow; + +export interface SalesSummary { + total_qty: number; + total_weight_kg: number; + total_sales_amount: number; + total_hpp_amount: number; +} + +export type DailyMarketingReport = { + rows: DailyMarketingRow[]; + summary: SalesSummary; +}; + +export type MarketingReportFilters = { + area_id?: number; + location_id?: number; + warehouse_id?: number; + customer_id?: number; + start_date?: string; + end_date?: string; + date_type?: 'realized' | 'transaction'; +};