Compare commits

...

186 Commits

Author SHA1 Message Date
Adnan Zahir a4b9b3fd2f Merge branch 'feat/FE/US-334/expedition-hpp-report' into 'development'
[FEAT/FE][US#334] Slicing and Integrate API Expedition HPP Report Table

See merge request mbugroup/lti-web-client!111
2025-12-23 10:57:59 +07:00
Adnan Zahir b91a199d13 Merge branch 'feat/FE/US-339/purchase-report' into 'development'
[FEAT/FE][US#339] Slicing and Integrate API Purchase Report Page

See merge request mbugroup/lti-web-client!109
2025-12-23 10:57:46 +07:00
rstubryan bf16d259bd Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-334/expedition-hpp-report 2025-12-23 10:43:56 +07:00
rstubryan 5ae299a4b5 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-339/purchase-report 2025-12-23 10:31:32 +07:00
Adnan Zahir c840f881bb Merge branch 'feat/FE/US-352/daily-kandang-hpp-report' into 'development'
[FEAT/FE][US#352] HPP Harian Per Kandang Report

See merge request mbugroup/lti-web-client!114
2025-12-23 10:20:10 +07:00
Adnan Zahir 6f16cf6deb Merge branch 'feat/FE/US-338/expense-report' into 'development'
[FEAT/FE][US#338] Expense Report

See merge request mbugroup/lti-web-client!110
2025-12-23 10:19:43 +07:00
Adnan Zahir 5b4bc136f2 Merge branch 'feat/FE/US-336/finance-report' into 'development'
[FEAT/FE][US#336] Reporting Closing Finance

See merge request mbugroup/lti-web-client!108
2025-12-23 10:19:02 +07:00
rstubryan 346d655406 refactor(FE): Upgrade xlsx and add HPP per kandang tab 2025-12-23 09:25:26 +07:00
rstubryan 5ff132070c Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-352/daily-kandang-hpp-report 2025-12-23 09:24:17 +07:00
randy-ar 398a09bf3b Merge branch 'development' into 'feat/FE/US-336/finance-report'
# Conflicts:
#   src/config/constant.ts
2025-12-23 02:22:41 +00:00
randy-ar 4e1315a027 Merge branch 'development' into 'feat/FE/US-338/expense-report'
# Conflicts:
#   src/components/pages/closing/ClosingDetail.tsx
2025-12-23 02:21:47 +00:00
Adnan Zahir dcf2acc799 Merge branch 'feat/FE/US-333/overhead-report' into 'development'
[FEAT/FE][US#333] Reporting Closing Overhead

See merge request mbugroup/lti-web-client!107
2025-12-22 17:45:56 +07:00
randy-ar 67a3ce2906 fix(FE): resolve conflict with branch development 2025-12-22 16:53:06 +07:00
randy-ar 1d28e80b66 fix(FE): resolve conflict with branch development 2025-12-22 16:45:18 +07:00
randy-ar 9dae6f1e95 fix(FE): resolve merge conflict with branch development 2025-12-22 16:34:49 +07:00
Adnan Zahir 4bc9926356 Merge branch 'feat/FE/US-335/production-data-report' into 'development'
[FEAT/FE][US#335] Production Data Report

See merge request mbugroup/lti-web-client!106
2025-12-22 15:38:54 +07:00
ValdiANS ea32056ca8 feat(FE-347): adjust getProductionData method 2025-12-22 15:29:39 +07:00
ValdiANS ddfdfe4d91 Merge branch 'development' into feat/FE/US-335/production-data-report 2025-12-22 15:28:01 +07:00
ValdiANS 7ea16d6a8a Merge branch 'development' into feat/FE/US-335/production-data-report 2025-12-22 15:23:15 +07:00
ValdiANS 206d6c0b4e Merge branch 'development' into feat/FE/US-335/production-data-report 2025-12-22 15:15:04 +07:00
Adnan Zahir 382721059a Merge branch 'feat/FE/US-340/marketing-report' into 'development'
[FEAT/FE][US#340] Marketing Report

See merge request mbugroup/lti-web-client!103
2025-12-22 14:47:12 +07:00
Rivaldi A N S 6a71828167 Merge branch 'feat/FE/US-352/TASK-355-356-357-slicing-and-integrate-kandang-hpp-daily-report-page' into 'feat/FE/US-352/daily-kandang-hpp-report'
[FEAT/FE][US#352/TASK-355-356-357] Slicing and Integrate API Hpp Harian Per Kandang Report

See merge request mbugroup/lti-web-client!113
2025-12-20 03:53:12 +00:00
rstubryan a5e79570c5 refactor(FE-356): Clarify 'Sisa Kg' label to specify Ayam 2025-12-20 10:30:11 +07:00
rstubryan 804aa700d3 feat(FE-356): Display egg production and HPP in PDF export 2025-12-20 10:26:18 +07:00
rstubryan 982a5d0d11 feat(FE-356): Display egg production and HPP in PDF export 2025-12-20 10:25:40 +07:00
rstubryan 478e9eb541 refactor(FE-356): Use summaryTotal and memoize per-weight summary 2025-12-20 10:18:50 +07:00
rstubryan 9e0631a415 feat(FE-355,357): Use summary totals and show egg metrics 2025-12-20 10:13:48 +07:00
rstubryan 18ca7d8a59 refactor(FE-357): Reference summary.total in footers 2025-12-20 09:50:19 +07:00
rstubryan eb8a1567c6 refactor(FE-355): Render weight-range summaries as table rows 2025-12-20 09:47:50 +07:00
rstubryan a0e63ea2d4 refactor(FE-355): Use flexRender and prepend default row in table 2025-12-20 09:37:42 +07:00
rstubryan 1ac35691ff refactor(FE-355): Move td classes into tr with [&_td] utilities 2025-12-20 09:32:35 +07:00
Rivaldi A N S f9aa254c18 Merge branch 'dev/randy' into 'feat/FE/US-338/expense-report'
[FIX/FE][US#338] Fixing Export PDF Using jspdf Library

See merge request mbugroup/lti-web-client!112
2025-12-20 02:17:37 +00:00
rstubryan c8effe4473 feat(FE-355,357): Render per-weight-range summary rows 2025-12-20 09:17:31 +07:00
rstubryan c230c8000b refactor(FE-355,356,257): Use API summary per_weight_range for HPP
reports
2025-12-20 08:52:34 +07:00
randy-ar a7267370a0 fix(FE): fix export report expense & fix report closing table view & fix project flock form set disable inofrmasi umum on edit 2025-12-19 17:20:02 +07:00
rstubryan daddebc0a6 refactor(FE-357): Refactor HppPerKandang types and add BaseMetadata 2025-12-19 16:30:06 +07:00
rstubryan 856d1f5c0c feat(FE): Filter supplier options by SAPRONAK category 2025-12-19 14:05:50 +07:00
rstubryan da5a577fde refactor(FE-357): Add key to summary table row 2025-12-19 13:46:02 +07:00
rstubryan c36d1ee153 feat(FE-355): Add custom row renderer to HppPerKandangTab 2025-12-19 13:40:41 +07:00
rstubryan 7259de8b14 feat(FE): Add renderCustomRow prop to Table 2025-12-19 12:56:25 +07:00
Rivaldi A N S 9e576cf444 Merge branch 'feat/FE/US-334/TASK-344-345-slicing-and-integrate-expedition-hpp-report-table' into 'feat/FE/US-334/expedition-hpp-report'
[FEAT/FE][US#334/TASK-344-345] Slicing and Integrate API Expedition HPP Report Table

See merge request mbugroup/lti-web-client!105
2025-12-19 03:33:05 +00:00
rstubryan d7b828cb47 chore(FE): Add xlsx@0.20.3 from SheetJS CDN 2025-12-19 10:24:16 +07:00
Rivaldi A N S f757e5f6ba Merge branch 'dev/randy' into 'feat/FE/US-338/expense-report'
[FEAT/FE][US#338] Expense Report

See merge request mbugroup/lti-web-client!104
2025-12-19 03:17:50 +00:00
rstubryan 7f694c7298 chore(FE): Bump Next.js to 15.5.9 2025-12-19 10:17:08 +07:00
Rivaldi A N S 5326fc918a Merge branch 'feat/FE/US-339/TASK-361-362-363-slicing-purchase-and-integrate-purchase-report-page' into 'feat/FE/US-339/purchase-report'
[FEAT/FE][US#339/TASK-361-362-363-367] Slicing and Integrate API Purchase Report Page

See merge request mbugroup/lti-web-client!101
2025-12-19 03:06:04 +00:00
Rivaldi A N S 6658312427 Merge branch 'dev/randy' into 'feat/FE/US-336/finance-report'
[FEAT/FE][US#336] Reporting Closing Finance

See merge request mbugroup/lti-web-client!100
2025-12-19 02:51:28 +00:00
Rivaldi A N S de73c626b1 Merge branch 'dev/randy' into 'feat/FE/US-333/overhead-report'
[FEAT/FE][US#333] Reporting Closing Overhead

See merge request mbugroup/lti-web-client!99
2025-12-19 02:47:10 +00:00
ValdiANS faaa10b74b chore(FE-347): update ClosingProductionData type 2025-12-19 09:39:56 +07:00
ValdiANS d66eaf08c0 chore(FE-347): set return type for getProductionData method 2025-12-19 09:39:43 +07:00
ValdiANS a6a6ff9f72 feat: create dummyClosingProductionData 2025-12-19 09:38:57 +07:00
ValdiANS 5a21a3b44c chore(FE-347): adjust UI based on updated ClosingProductionData type 2025-12-19 09:38:39 +07:00
rstubryan 00e0126e42 refactor(FE-357): Use string weights and parse floats for filters 2025-12-18 20:35:47 +07:00
rstubryan 2f23755510 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-352/TASK-355-356-357-slicing-and-integrate-kandang-hpp-daily-report-page 2025-12-18 20:27:08 +07:00
rstubryan 996e132660 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-339/TASK-361-362-363-slicing-purchase-and-integrate-purchase-report-page 2025-12-18 20:18:02 +07:00
rstubryan e3eda4f5e4 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-334/TASK-344-345-slicing-and-integrate-expedition-hpp-report-table 2025-12-18 20:15:19 +07:00
ValdiANS 7cc616ff41 Merge branch 'development' into feat/FE/US-335/production-data-report 2025-12-18 19:29:27 +07:00
randy-ar 0b75d68494 Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into dev/randy 2025-12-18 19:01:48 +07:00
randy-ar 83224e046b fix(FE): fix submenu stock product name 2025-12-18 19:00:30 +07:00
ValdiANS 096a8d394e Merge branch 'development' into feat/FE/US-335/production-data-report 2025-12-18 18:50:22 +07:00
randy-ar 11bf6ad760 feat(FE): adding xlsx package 2025-12-18 18:14:56 +07:00
randy-ar c8a834f84a feat(FE): adding export xlsx for report expense, change report data fetching, adding progress bar 2025-12-18 18:13:27 +07:00
kris ead5ba759d Update .gitlab-ci.yml file 2025-12-18 10:00:38 +00:00
rstubryan 8ceca2cc59 refactor(FE-345): Rename CostOfRevenueExpedition to HppExpedition 2025-12-18 16:38:39 +07:00
rstubryan 8dc23f83cd refactor(FE-335): Rename expedition HPP types and update usages 2025-12-18 16:26:09 +07:00
Rivaldi A N S 57f53b0a04 Merge branch 'feat/FE/US-340/TASK-364-365-368-marketing-report' into 'feat/FE/US-340/marketing-report'
[FEAT/FE][US#340/TASK#364-365-368] Marketing Report

See merge request mbugroup/lti-web-client!102
2025-12-18 09:25:46 +00:00
rstubryan 447b8067f7 refactor(FE-345): Rename hpp-ekspedisi endpoint to expedition-hpp 2025-12-18 15:55:20 +07:00
rstubryan 4a8f2b1e1d feat(FE-345): Add HPP expedition types and API method 2025-12-18 15:49:01 +07:00
rstubryan 36389bae2a refactor(FE-364): Refactor purchases export to use supplier reports 2025-12-18 15:11:34 +07:00
rstubryan d001b05c4e refactor(FE-361,363,364): Use per-supplier report arrays for export 2025-12-18 14:58:54 +07:00
rstubryan 20494657c6 refactor(FE-363): Extract row and summary types for logistic report 2025-12-18 14:35:10 +07:00
rstubryan 2d8e479b6c refactor(FE-363): Switch LogisticApi service to reports endpoint 2025-12-18 14:16:00 +07:00
rstubryan fef7f0e29b refactor(FE-363): Use relative endpoint for logistic reports 2025-12-18 13:59:11 +07:00
rstubryan 81d242bd1d refactor(FE-361,363): Add sort order selector to PurchasesPerSupplierTab 2025-12-18 13:57:58 +07:00
rstubryan 1be596921a refactor(FE-363): Fetch export data on demand via callback 2025-12-18 13:50:38 +07:00
rstubryan 8fb1ccbdce refactor(FE-364): Add hyphen before timestamp in export filenames 2025-12-18 13:40:26 +07:00
rstubryan 85fddcb19a refactor(FE-364): Timestamp purchases-per-supplier export filenames 2025-12-18 13:39:05 +07:00
rstubryan 915e68f755 refactor(FE-363): Send joined filter params to logistic report API 2025-12-18 13:35:40 +07:00
rstubryan 87adbf8547 refactor(FE-363): Accept array filters in logistic purchase report 2025-12-18 13:31:08 +07:00
rstubryan e8492f87ba refactor(FE-364): Enable multi-select filters for purchases report 2025-12-18 13:28:00 +07:00
rstubryan ceae338c73 refactor(FE-356): Include period in export filenames 2025-12-18 13:10:21 +07:00
rstubryan fa7824224c refactor(FE-356): Use timestamped filename for HPP export 2025-12-18 13:05:01 +07:00
rstubryan 6b30457ec2 feat(FE-356): Include filter details in export header 2025-12-18 13:02:16 +07:00
rstubryan 843fa6ee7a refactor(FE-357): Make area/location/kandang filters multi-select 2025-12-18 12:56:05 +07:00
randy-ar a935ffd9f5 fix(FE): fixing floating button & revert require auth component 2025-12-18 11:33:18 +07:00
rstubryan 1de98db4ba refactor(FE-361): Hide unused logistic tabs and adjust card 2025-12-18 11:31:05 +07:00
rstubryan f844c9ff2c refactor(FE-357): Use relative URL for marketing sale reports 2025-12-18 11:19:23 +07:00
rstubryan 83fc92d48b refactor(FE-356): Refactor weight-range grouping and format currency 2025-12-18 11:12:30 +07:00
rstubryan 4edd4f1285 feat(FE-356): Add egg production and HPP fields to PDF export 2025-12-18 09:57:21 +07:00
rstubryan cc3765abcd refactor(FE-356): Add egg production and pricing columns 2025-12-18 09:50:11 +07:00
rstubryan 3497a6346c refactor(FE-357): Deduplicate totals and supplier aggregation 2025-12-18 09:47:52 +07:00
rstubryan 69b4ca455e refactor(FE-355): Guard HPP total calculation for empty data 2025-12-18 09:33:13 +07:00
rstubryan 320bc52244 refactor(FE-355,357): Show HPP and average DOC totals in table footer 2025-12-18 09:24:49 +07:00
rstubryan 40f2d0ba93 refactor(FE-355): Reorder egg_value_rp column in HppPerKandangTab 2025-12-18 09:12:28 +07:00
rstubryan 481a643b3c refactor(FE-355): Use summary fallbacks for report footers 2025-12-18 09:11:21 +07:00
rstubryan 9b2d98f7ce feat(FE-355,357): Add egg columns to HPP table and disable sale tabs 2025-12-18 08:59:07 +07:00
rstubryan 6155929e14 refactor(FE-357): Use hppPerKandangExport instead of fetchAllExportData 2025-12-17 14:54:28 +07:00
rstubryan 7c9f68d3a3 feat(FE-363): Add Excel export loading and combine export state 2025-12-17 14:44:36 +07:00
randy-ar 918e85e0cc fix(FE): resolve merge conflict with branch development 2025-12-17 14:26:03 +07:00
randy-ar bb80e9e9c6 fix(FE): remove dummy data and integrate live API for closing finance and fixing closing UI when given data is null 2025-12-17 14:22:15 +07:00
rstubryan 80fd75dfc1 refactor(FE-357): Rename API response and export fetch vars 2025-12-17 14:14:27 +07:00
rstubryan e1b562c175 refactor(FE-357): Refactor export data fetching and handlers 2025-12-17 14:05:29 +07:00
rstubryan 9365320b03 feat(FE-357): Add loading state for export 2025-12-17 13:45:00 +07:00
rstubryan e515438312 refactor(FE-355): Use NumberInput and SelectInput for filters 2025-12-17 13:33:51 +07:00
rstubryan 530ef4982d refactor(FE-355,357): Use summary fields for egg HPP and total value 2025-12-17 13:04:26 +07:00
rstubryan a8c3b1a66f feat(FE-356): Include HPP and supplier aggregates in export 2025-12-17 11:56:42 +07:00
rstubryan 62d4d7b7db refactor(FE): Fix Dropdown import path 2025-12-17 11:49:41 +07:00
rstubryan 57df2e9aed Merge branch 'dev/restu' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-352/TASK-355-356-357-slicing-and-integrate-kandang-hpp-daily-report-page 2025-12-17 11:48:24 +07:00
rstubryan 9ea86fe5c3 Merge branch 'dev/restu' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-339/TASK-361-362-363-slicing-purchase-and-integrate-purchase-report-page 2025-12-17 11:47:57 +07:00
rstubryan ecf1677c27 Merge branch 'dev/restu' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-334/TASK-344-345-slicing-and-integrate-expedition-hpp-report-table 2025-12-17 11:47:36 +07:00
rstubryan c1e075b1ff refactor(FE): refactor Dropdown component API and Navbar usage 2025-12-17 11:47:12 +07:00
rstubryan c70cfbd450 feat(FE-355,356,357): Add PDF export for HPP per kandang report 2025-12-17 11:42:45 +07:00
rstubryan 4cc41c0167 feat(FE-355,357): Use API summary for weighted averages 2025-12-17 11:11:44 +07:00
rstubryan 730b7903a7 feat(FE-355): Remove pagination from HppPerKandangTab 2025-12-17 11:01:01 +07:00
rstubryan 78efd587be feat(FE-355,357): Add aggregated footers for HPP per kandang table 2025-12-17 10:54:02 +07:00
rstubryan 3d91c12874 feat(FE-355,356,357): Implement HPP Per Kandang report tab 2025-12-17 10:38:56 +07:00
rstubryan 12e6d60745 feat(FE-357): Rename HppPerkandang types and update API path 2025-12-17 10:37:05 +07:00
rstubryan 01313f0b09 refactor(FE-363): Move logistic API to report/logistic-stock 2025-12-17 10:05:32 +07:00
rstubryan 9f521a6a08 refactor(FE-363): Rename logistic stock report API method 2025-12-17 10:00:35 +07:00
rstubryan 43afc5781c feat(FE-355): Add sale report tabs and marketing layout 2025-12-17 09:50:55 +07:00
rstubryan 0f7f4e891c feat(FE-357): Add marketing sales report API and types 2025-12-17 09:50:36 +07:00
randy-ar b02b458034 feat(FE): Closing Finance and adjust reports expense filter request 2025-12-16 17:52:59 +07:00
rstubryan 304084ea2c Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-334/TASK-344-345-slicing-and-integrate-expedition-hpp-report-table 2025-12-16 16:21:39 +07:00
rstubryan 204a1098a3 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-339/TASK-361-362-363-slicing-purchase-and-integrate-purchase-report-page 2025-12-16 16:20:35 +07:00
rstubryan c04cd29ac7 refactor(FE-363): Use snake_case for totals in purchases tab 2025-12-16 15:19:47 +07:00
rstubryan d17c11e2f2 feat(FE-364): Implement Excel export for purchases per supplier 2025-12-16 14:48:17 +07:00
rstubryan c9544e1bd0 refactor(FE-361): Format quantity with formatNumber 2025-12-16 14:21:58 +07:00
rstubryan 1560908101 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-334/TASK-344-345-slicing-and-integrate-expedition-hpp-report-table 2025-12-16 14:08:45 +07:00
rstubryan 874bc27e4c Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-339/TASK-361-362-363-slicing-purchase-and-integrate-purchase-report-page 2025-12-16 13:57:40 +07:00
rstubryan aea39a878a Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-339/TASK-361-362-363-slicing-purchase-and-integrate-purchase-report-page 2025-12-16 13:57:09 +07:00
rstubryan 84e562e22c refactor(FE-364): Refine PDF table cell styles and header alignment 2025-12-16 13:56:48 +07:00
rstubryan 5c8bc4fc6e feat(FE-364): Center align badge in PDF export styles 2025-12-16 13:52:55 +07:00
rstubryan 9ba3fa1b6c refactor(FE-364): refactor PDF table styles and remove total styles 2025-12-16 13:34:36 +07:00
rstubryan 6f18c58042 refactor(FE-364): refactor PDF table styles and remove total styles 2025-12-16 13:33:57 +07:00
rstubryan 0d8e642b4e refactor(FE-364): update PDF export layout and parameter badges 2025-12-16 13:26:16 +07:00
rstubryan 615d4d5ffe feat(FE-364): Add PDF export for purchases per supplier 2025-12-16 12:00:56 +07:00
rstubryan 2a00da0298 refactor(FE-361): Make filter layout responsive 2025-12-16 11:12:30 +07:00
rstubryan 68437b3b7e feat(FE-361,363): Add product category filter and API params 2025-12-16 10:26:31 +07:00
rstubryan 31b2a5a548 refactor(FE-361,363): Adapt PurchasesPerSupplier to new report shape 2025-12-16 10:01:13 +07:00
rstubryan 4d997256ad refactor(FE-363): Update logistic report API endpoint and types 2025-12-16 10:00:39 +07:00
rstubryan 7ea9e10ad2 chore(FE): Add xlsx dependency from SheetJS CDN 2025-12-15 16:34:00 +07:00
rstubryan 245da2595c fix(FE-vulnerability): Bump Next.js to 15.5.9 2025-12-15 16:29:27 +07:00
rstubryan 45f1e923b7 feat(FE-361,363): Add export dropdown to PurchasesPerSupplier tab 2025-12-15 15:10:22 +07:00
rstubryan 3c3c2345c7 feat(FE-361): Update action buttons in PurchasesPerSupplierTab 2025-12-15 14:14:34 +07:00
rstubryan 3f78cfdb63 refactor(FE): refactor Dropdown component API and Navbar usage 2025-12-15 14:04:16 +07:00
rstubryan 69eaae6d43 feat(FE=361,363): Add Submit button to PurchasesPerSupplierTab 2025-12-15 11:41:12 +07:00
rstubryan 5c9332537c feat(FE-363): Add pagination to purchases per supplier report 2025-12-15 11:08:05 +07:00
rstubryan 63c2a240d2 refactor(FE-363): Use SWR key+fetcher with params for logistic report 2025-12-13 12:03:56 +07:00
rstubryan fd2e1f8b96 refactor(FE-363): Replace LogisticService with LogisticApi instance 2025-12-13 11:51:41 +07:00
rstubryan fdb3e0481a refactor(FE-363): Rename response variable to purchasePerSupplier 2025-12-13 11:46:00 +07:00
rstubryan 9bc632c286 feat(FE-361): Add Reset button to clear report filters 2025-12-13 11:34:37 +07:00
rstubryan 81f98c5f06 feat(FE-361): Add pagination control to supplier purchases table 2025-12-13 11:21:04 +07:00
rstubryan 0983f154d2 refactor(FE-363): Rename SupplierWithItems type for clarity 2025-12-13 10:57:56 +07:00
rstubryan bd4c51cb04 refactor(FE-361,363): Refactor purchases-per-supplier report to use API 2025-12-13 10:55:15 +07:00
randy-ar 9c09395677 feat(FE-338): Slicing UI Halaman Reporting BOP & API integration & refactor debounce input: adding useEffect for sync value 2025-12-11 18:23:55 +07:00
rstubryan 67b5187d39 feat(FE-363): Add Logistic API service and update types 2025-12-11 15:47:21 +07:00
rstubryan 5de5dcffc0 fix(FE): Area import path in logistic-stock types 2025-12-11 15:28:26 +07:00
rstubryan 51ff739926 Merge branch 'dev/hotfix/restu' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-339/TASK-361-362-363-slicing-purchase-and-integrate-purchase-report-page 2025-12-11 15:23:15 +07:00
rstubryan 238edfeca2 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-339/TASK-361-362-363-slicing-purchase-and-integrate-purchase-report-page 2025-12-11 15:22:21 +07:00
rstubryan bd8d121113 feat(FE-344,345): Integrate HPP Ekspedisi into Closing detail 2025-12-11 15:04:37 +07:00
rstubryan 38b91a57f0 refactor(FE-345): Remove unused fields from BaseClosingSales 2025-12-11 14:43:54 +07:00
randy-ar d0abc0e9ff fix(FE): adjust inventory adjustment and inventory product table 2025-12-11 14:35:27 +07:00
rstubryan 4262e8e286 refactor(FE-344): Add Cost of Revenue Expedition table 2025-12-11 14:27:29 +07:00
randy-ar 48c163c1cd fix(FE): remove pengajuan from project flock kandang approval lines 2025-12-11 14:00:46 +07:00
rstubryan 3c03494bd3 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-334/TASK-344-345-slicing-and-integrate-expedition-hpp-report-table 2025-12-11 14:00:19 +07:00
randy-ar 4ea56f2e18 fix(FE): fixing closing button project flock 2025-12-11 01:20:48 +07:00
rstubryan 312580a7fc Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-339/TASK-361-362-363-slicing-purchase-and-integrate-purchase-report-page 2025-12-10 15:37:16 +07:00
rstubryan 9c7033b53a feat(FE-361): Migrate main drawer links to SidebarMenuItem 2025-12-10 14:17:12 +07:00
rstubryan 07bfe0a20b Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-339/TASK-361-362-363-slicing-purchase-and-integrate-purchase-report-page 2025-12-10 14:12:25 +07:00
rstubryan e23b698fc7 feat(FE-363): Add TypeScript definitions for logistic stock report 2025-12-10 11:18:54 +07:00
rstubryan e3f90a49d0 feat(FE-361): Extract LogisticStockTabs component 2025-12-10 09:22:09 +07:00
rstubryan a1679ba5ff feat(FE-363): Add data type and date range filters 2025-12-09 21:49:26 +07:00
rstubryan 8a7149c123 feat(FE-361): Add logistic stock tabs and tweak styles 2025-12-09 20:19:00 +07:00
rstubryan f87854ed07 refactor(FE-361): Remove wrapper top margin and clear table margin 2025-12-09 18:51:36 +07:00
rstubryan 1ef7130661 refactor(FE-344): Export TABLE_DEFAULT_STYLING and refine classes 2025-12-09 18:45:38 +07:00
rstubryan 066c356d4f refactor(FE-339): Export TABLE_DEFAULT_STYLING and refine table classes 2025-12-09 18:44:35 +07:00
rstubryan 8f5dd1851a refactor(FE-361): Refactor table and pagination components 2025-12-09 18:04:33 +07:00
rstubryan 6a926f881d feat(FE-334): Use column footers and fix closing loading check 2025-12-09 17:51:11 +07:00
rstubryan 68dd5b1121 refactor(FE-344): Add table cell side borders in sales report 2025-12-09 17:49:50 +07:00
rstubryan 5a3c7d71b0 refactor(FE-345): HPP ekspedisi API path to use /closings 2025-12-09 17:48:03 +07:00
rstubryan 5ad61d483a refactor(FE-344): update pagination UI and table footer handling 2025-12-09 16:47:59 +07:00
rstubryan b039ec832b feat(FE-361): Add logistic-stock report page and table footer 2025-12-09 15:49:59 +07:00
rstubryan d7384752a0 feat(FE-361): Add Laporan report menu with logistic submenu 2025-12-09 14:11:15 +07:00
rstubryan dfecef2e0c feat(FE-344,345): Fetch and render HPP Ekspedisi report 2025-12-09 13:28:47 +07:00
rstubryan 80fd8bb7ba feat(FE-344,345): Add CosExpeditionReportTable component 2025-12-09 13:28:14 +07:00
rstubryan 44b9f94cec feat(FE-345): Add getHppEkspedisi to ClosingApiService 2025-12-09 13:27:15 +07:00
rstubryan e3b3f5ccdc feat(FE-345): Add Cost of Services expedition types and export
ClosingSales
2025-12-09 13:26:19 +07:00
52 changed files with 6177 additions and 937 deletions
+28 -4
View File
@@ -73,8 +73,8 @@ stages:
if [ "$CI_COMMIT_BRANCH" = "development" ]; then
ENVIRONMENT_NAME="WEB-LTI-DEV"
elif [ "$CI_COMMIT_BRANCH" = "master" ]; then
ENVIRONMENT_NAME="WEB-LTI-PROD"
elif [ "$CI_COMMIT_BRANCH" = "staging" ]; then
ENVIRONMENT_NAME="WEB-LTI-STAGING"
else
ENVIRONMENT_NAME="UNKNOWN"
fi
@@ -122,8 +122,6 @@ build:dev:
environment:
name: development
variables:
# NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id'
# NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-api-sso.mbugroup.id'
NEXT_PUBLIC_LTI_URL: 'https://dev-lti-erp.mbugroup.id'
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-auth-erp.mbugroup.id'
NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id/api'
@@ -141,6 +139,32 @@ deploy:dev:
environment:
name: development
url: https://dev-lti-erp.mbugroup.id
# ====== STAGING (Branch staging) ======
build:staging:
<<: *build_template
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
environment:
name: staging
variables:
NEXT_PUBLIC_LTI_URL: 'https://stg-lti-erp.mbugroup.id'
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://stg-auth-erp.mbugroup.id'
NEXT_PUBLIC_API_BASE_URL: 'https://stg-api-lti.mbugroup.id/api'
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
deploy:staging:
<<: *deploy_template
needs: ['build:staging']
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
variables:
S3_BUCKET: 'stg-lti-erp.mbugroup.id'
AWS_REGION: 'ap-southeast-3'
CLOUDFRONT_DISTRIBUTION_ID: 'E2V6PPO1AUIU7H'
environment:
name: staging
url: https://stg-lti-erp.mbugroup.id
# ====== PRODUCTION ======
# build:production:
# <<: *build_template
+239 -92
View File
@@ -14,8 +14,10 @@
"axios": "^1.12.2",
"clsx": "^2.1.1",
"formik": "^2.4.6",
"jspdf": "^3.0.4",
"jspdf-autotable": "^5.0.2",
"moment": "^2.30.1",
"next": "^15.5.9",
"next": "15.5.9",
"react": "19.1.0",
"react-day-picker": "^9.11.1",
"react-dom": "19.1.0",
@@ -26,7 +28,7 @@
"swr": "^2.3.6",
"tailwind-merge": "^3.3.1",
"use-debounce": "^10.0.6",
"xlsx": "^0.18.5",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"yup": "^1.7.0",
"zustand": "^5.0.8"
},
@@ -1845,17 +1847,31 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/parse-json": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/react": {
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -1879,6 +1895,13 @@
"@types/react": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz",
@@ -1925,6 +1948,7 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@@ -2448,6 +2472,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2465,15 +2490,6 @@
"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",
@@ -2785,6 +2801,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -2934,17 +2960,24 @@
],
"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",
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=0.8"
"node": ">=10.0.0"
}
},
"node_modules/chalk": {
@@ -2988,15 +3021,6 @@
"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",
@@ -3051,6 +3075,18 @@
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"license": "MIT"
},
"node_modules/core-js": {
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz",
"integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cosmiconfig": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
@@ -3067,18 +3103,6 @@
"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",
@@ -3100,11 +3124,22 @@
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/daisyui": {
"version": "5.5.8",
@@ -3319,6 +3354,16 @@
"csstype": "^3.0.2"
}
},
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3560,6 +3605,7 @@
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3733,6 +3779,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -4038,6 +4085,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fast-png/node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"license": "(MIT AND Zlib)"
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@@ -4048,6 +4112,12 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -4224,15 +4294,6 @@
"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",
@@ -4544,6 +4605,20 @@
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
"license": "ISC"
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"optional": true,
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/husky": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
@@ -4623,6 +4698,12 @@
"node": ">= 0.4"
}
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -5158,6 +5239,33 @@
"json5": "lib/cli.js"
}
},
"node_modules/jspdf": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.4.tgz",
"integrity": "sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.2.4",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/jspdf-autotable": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.2.tgz",
"integrity": "sha512-YNKeB7qmx3pxOLcNeoqAv3qTS7KuvVwkFe5AduCawpop3NOkBUtqDToxNc225MlNecxT4kP2Zy3z/y/yvGdXUQ==",
"license": "MIT",
"peerDependencies": {
"jspdf": "^2 || ^3"
}
},
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -6062,6 +6170,13 @@
"node": ">=8"
}
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -6215,11 +6330,22 @@
],
"license": "MIT"
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/react": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -6250,6 +6376,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -6373,6 +6500,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -6465,6 +6599,16 @@
"node": ">=0.10.0"
}
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -6807,18 +6951,6 @@
"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",
@@ -6826,6 +6958,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -7045,6 +7187,16 @@
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
"license": "ISC"
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/swr": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz",
@@ -7089,6 +7241,16 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/tiny-case": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
@@ -7148,6 +7310,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -7315,6 +7478,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7461,6 +7625,16 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"optional": true,
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vite-compatible-readable-stream": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz",
@@ -7580,24 +7754,6 @@
"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",
@@ -7609,19 +7765,10 @@
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"version": "0.20.3",
"resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
"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"
},
+4 -2
View File
@@ -17,8 +17,10 @@
"axios": "^1.12.2",
"clsx": "^2.1.1",
"formik": "^2.4.6",
"jspdf": "^3.0.4",
"jspdf-autotable": "^5.0.2",
"moment": "^2.30.1",
"next": "^15.5.9",
"next": "15.5.9",
"react": "19.1.0",
"react-day-picker": "^9.11.1",
"react-dom": "19.1.0",
@@ -29,7 +31,7 @@
"swr": "^2.3.6",
"tailwind-merge": "^3.3.1",
"use-debounce": "^10.0.6",
"xlsx": "^0.18.5",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"yup": "^1.7.0",
"zustand": "^5.0.8"
},
+11 -1
View File
@@ -24,6 +24,11 @@ const ClosingDetailPage = () => {
() => ClosingApi.getPenjualan(Number(closingId))
);
const { data: hppEkspedisiData, isLoading: isLoadingHppEkspedisi } = useSWR(
closingId ? `hpp-ekspedisi-${closingId}` : null,
() => ClosingApi.getHppEkspedisi(Number(closingId))
);
if (!closingId) {
router.back();
@@ -39,7 +44,7 @@ const ClosingDetailPage = () => {
return;
}
const isLoading = isLoadingClosing || isLoadingSales;
const isLoading = isLoadingClosing || isLoadingSales || isLoadingHppEkspedisi;
return (
<div className='w-full p-4 flex flex-row justify-center'>
@@ -50,6 +55,11 @@ const ClosingDetailPage = () => {
id={Number(closingId)}
initialValue={closing.data}
salesData={isResponseSuccess(salesData) ? salesData.data : undefined}
hppExpeditionData={
isResponseSuccess(hppEkspedisiData)
? hppEkspedisiData.data
: undefined
}
/>
)}
</div>
+11
View File
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+5
View File
@@ -0,0 +1,5 @@
const ReportExpenseDetail = () => {
return <div>ReportExpenseDetail</div>;
};
export default ReportExpenseDetail;
+13
View File
@@ -0,0 +1,13 @@
'use client';
import ReportExpenseTable from '@/components/pages/report/expense/ReportExpenseTable';
const ReportExpense = () => {
return (
<div className='w-full p-4'>
<ReportExpenseTable />
</div>
);
};
export default ReportExpense;
+11
View File
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+7
View File
@@ -0,0 +1,7 @@
import LogisticStockTabs from '@/components/pages/report/logistic-stock/LogisticStockTabs';
const LogisticStock = () => {
return <LogisticStockTabs />;
};
export default LogisticStock;
+11
View File
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+114
View File
@@ -0,0 +1,114 @@
import React, { ReactNode, useState, useRef } from 'react';
import { cn } from '@/lib/helper';
export interface DropdownProps {
trigger: ReactNode;
children: ReactNode;
className?: {
wrapper?: string;
trigger?: string;
content?: string;
};
align?: 'start' | 'center' | 'end';
direction?: 'top' | 'bottom' | 'left' | 'right';
hover?: boolean;
defaultOpen?: boolean;
open?: boolean;
close?: boolean;
controlled?: boolean;
}
const Dropdown = ({
trigger,
children,
className,
align,
direction,
hover,
defaultOpen = false,
open,
close,
controlled = false,
}: DropdownProps) => {
const [isOpen, setIsOpen] = useState(defaultOpen);
const dropdownRef = useRef<HTMLDivElement>(null);
const toggleDropdown = () => {
if (!controlled) {
const newState = !isOpen;
setIsOpen(newState);
}
};
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 (
<div className={getWrapperClasses()}>
{trigger}
{open && !close && (
<div tabIndex={-1} className={getContentClasses()}>
{children}
</div>
)}
</div>
);
}
return (
<div ref={dropdownRef} className={getWrapperClasses()}>
<div
tabIndex={0}
role='button'
className={getTriggerClasses()}
onClick={toggleDropdown}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleDropdown();
}
}}
>
{trigger}
</div>
{!close && (
<div tabIndex={-1} className={getContentClasses()}>
{children}
</div>
)}
</div>
);
};
export default Dropdown;
+3 -1
View File
@@ -33,7 +33,9 @@ const FloatingActionsButton = ({
}: FloatingActionsButtonProps) => {
// Jika tidak ada baris yang dipilih, jangan tampilkan FAB
const positionStyles =
selectedRowIds.length > 0 ? 'bottom-[10%]' : 'bottom-[-100%]';
selectedRowIds.length > 0
? 'bottom-[10%] opacity-100'
: 'bottom-[-10%] opacity-0';
// Helper untuk menentukan gaya warna tombol approval
const getApprovalColor = (action: 'APPROVED' | 'REJECTED') => {
+1 -1
View File
@@ -7,7 +7,7 @@ import { Icon } from '@iconify/react';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import Button from '@/components/Button';
import Dropdown from '@/components/dropdown/Dropdown';
import Dropdown from '@/components/Dropdown';
import { useAuth } from '@/services/hooks/useAuth';
import { AuthApi } from '@/services/api/auth';
+35 -17
View File
@@ -60,6 +60,12 @@ export interface TableProps<TData extends object> {
renderFooter?: boolean;
withCheckbox?: boolean;
rowOptions?: number[];
/**
* Custom row renderer. Should return a complete <tr> element or null.
* This gives full control over the row structure including colspan.
* Return null to render the default row.
*/
renderCustomRow?: (row: Row<TData>) => ReactNode | null;
}
const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}];
@@ -112,6 +118,7 @@ const Table = <TData extends object>({
renderFooter = false,
withCheckbox = false,
rowOptions = [10, 20, 50, 100],
renderCustomRow,
}: TableProps<TData>) => {
const isServerSideTable =
totalItems !== undefined &&
@@ -305,24 +312,35 @@ const Table = <TData extends object>({
</thead>
<tbody className={tableClassNames.tableBodyClassName}>
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className={tableClassNames.bodyRowClassName}>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className={cn(
{ 'first:w-9 first:pr-0': withCheckbox },
tableClassNames.bodyColumnClassName
)}
>
{!isLoading &&
flexRender(cell.column.columnDef.cell, cell.getContext())}
{table.getRowModel().rows.map((row) => {
const customRowContent = renderCustomRow?.(row);
{isLoading && <div className='skeleton w-full h-4' />}
</td>
))}
</tr>
))}
if (customRowContent) {
return renderCustomRow?.(row);
}
return (
<tr key={row.id} className={tableClassNames.bodyRowClassName}>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className={cn(
{ 'first:w-9 first:pr-0': withCheckbox },
tableClassNames.bodyColumnClassName
)}
>
{!isLoading &&
flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
{isLoading && <div className='skeleton w-full h-4' />}
</td>
))}
</tr>
);
})}
</tbody>
<tfoot className={cn(tableClassNames.tableFooterClassName)}>
{renderFooter && (
@@ -24,6 +24,11 @@ const DebouncedTextInput = (props: DebouncedTextInputProps) => {
setInternalChangeEvent(e);
};
// Sync internal value with external value prop changes (e.g., from reset)
useEffect(() => {
setInternalValue(props.value);
}, [props.value]);
useEffect(() => {
if (debouncedChangeEvent) {
onChange?.(debouncedChangeEvent);
@@ -12,21 +12,26 @@ import ClosingProductionDataTabContent from '@/components/pages/closing/ClosingP
import {
ClosingGeneralInformation,
BaseClosingSales,
ClosingHppExpedition,
} from '@/types/api/closing';
import ClosingSapronakCalculationTabContent from '@/components/pages/closing/ClosingSapronakCalculationTabContent';
import ClosingOverheadTabContent from '@/components/pages/closing/ClosingOverheadTabContent';
import SalesReportTable from './sale/SalesReportTable';
import ClosingFinanceTabContent from '@/components/pages/closing/ClosingFinanceTabContent';
import SalesReportTable from '@/components/pages/closing/sale/SalesReportTable';
import HppExpeditionReportTable from './hpp-ekspedisi/HppExpeditionReportTable';
interface ClosingDetailProps {
id: number;
initialValue?: ClosingGeneralInformation;
salesData?: BaseClosingSales;
hppExpeditionData?: ClosingHppExpedition;
}
const ClosingDetail: React.FC<ClosingDetailProps> = ({
id,
initialValue,
salesData,
hppExpeditionData,
}) => {
const [activeTab, setActiveTab] = useState<string>('sapronak');
@@ -55,7 +60,7 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
{
id: 'hppEkspedisi',
label: 'HPP Ekspedisi',
content: 'HPP Ekspedisi',
content: <HppExpeditionReportTable initialValues={hppExpeditionData} />,
},
{
id: 'dataProduksi',
@@ -65,7 +70,7 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
{
id: 'keuangan',
label: 'Keuangan',
content: 'Keuangan',
content: <ClosingFinanceTabContent projectFlockId={id} />,
},
];
@@ -0,0 +1,17 @@
import ClosingFinanceTable from '@/components/pages/closing/ClosingFinanceTable';
const ClosingFinanceTabContent = ({
projectFlockId,
}: {
projectFlockId: number;
}) => {
return (
<div className='flex flex-col gap-4'>
{projectFlockId && (
<ClosingFinanceTable projectFlockId={projectFlockId} />
)}
</div>
);
};
export default ClosingFinanceTabContent;
@@ -0,0 +1,495 @@
import Card from '@/components/Card';
import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper';
import { formatCurrency, formatTitleCase } from '@/lib/helper';
import { ClosingApi } from '@/services/api/closing';
import {
DataSummarySubTotal,
HppPurchaseData,
ProfitLossDataAmount,
} from '@/types/api/closing';
import useSWR from 'swr';
type HppTableRow =
| (HppPurchaseData & {
group_name: string;
group_index: number;
isGroupHeader?: boolean;
})
| {
group_name: string;
group_index: number;
isGroupHeader: true;
type?: never;
budgeting?: never;
realization?: never;
};
type ProfitLossTableRow =
| (DataSummarySubTotal & {
type: string;
group_name: string;
group_index: number;
isGroupHeader?: boolean;
})
| {
group_name: string;
group_index: number;
isGroupHeader: true;
type?: never;
rp_per_bird?: never;
rp_per_kg?: never;
amount?: never;
};
const ClosingFinanceTable = ({
projectFlockId,
}: {
projectFlockId: number;
}) => {
const { data: finance, isLoading } = useSWR(
`/closing/finance/${projectFlockId}`,
() => ClosingApi.getFinance(projectFlockId)
);
const hppTableData: HppTableRow[] = isResponseSuccess(finance)
? finance.data.hpp_purchases.hpp.flatMap((hpp, groupIndex) => [
// Group header row
{
group_name: hpp.group_name,
group_index: groupIndex,
isGroupHeader: true as const,
},
// Data rows
...hpp.data.map((item) => ({
group_name: hpp.group_name,
group_index: groupIndex,
type: item.type,
budgeting: item.budgeting,
realization: item.realization,
isGroupHeader: false as const,
})),
])
: [];
const profitLossTableData: ProfitLossTableRow[] = isResponseSuccess(finance)
? [
// Pembelian group
...finance.data.profit_loss.data.pembelian.map((item) => ({
label: 'Pembelian',
group_name: 'Pembelian',
group_index: 1,
type: item.type,
rp_per_bird: item.rp_per_bird,
rp_per_kg: item.rp_per_kg,
amount: item.amount,
isGroupHeader: false as const,
})),
{
label: finance.data.profit_loss.data.summary.gross_profit.label,
group_name: 'Penjualan',
group_index: 0,
isGroupHeader: true as const,
type: finance.data.profit_loss.data.summary.gross_profit.label,
rp_per_bird:
finance.data.profit_loss.data.summary.gross_profit.rp_per_bird,
rp_per_kg:
finance.data.profit_loss.data.summary.gross_profit.rp_per_kg,
amount: finance.data.profit_loss.data.summary.gross_profit.amount,
},
// Penjualan group
...finance.data.profit_loss.data.penjualan.map((item) => ({
label: 'Penjualan',
group_name: 'Penjualan',
group_index: 0,
type: item.type,
rp_per_bird: item.rp_per_bird,
rp_per_kg: item.rp_per_kg,
amount: item.amount,
isGroupHeader: false as const,
})),
{
label: finance.data.profit_loss.data.summary.sub_total.label,
group_name: 'Pembelian',
group_index: 1,
isGroupHeader: true as const,
type: finance.data.profit_loss.data.summary.sub_total.label,
rp_per_bird:
finance.data.profit_loss.data.summary.sub_total.rp_per_bird,
rp_per_kg: finance.data.profit_loss.data.summary.sub_total.rp_per_kg,
amount: finance.data.profit_loss.data.summary.sub_total.amount,
},
]
: [];
return (
<div className='flex flex-col gap-4'>
<>
<Card
variant='bordered'
className={{
wrapper: 'w-full',
}}
>
<div className='grid grid-cols-2 gap-6'>
<div className='flex flex-col gap-1'>
<div>
{isResponseSuccess(finance)
? formatTitleCase(
finance.data.profit_loss.data.summary.gross_profit
.label || '-'
)
: 'Laba Rugi Brutto'}
</div>
<div className='text-lg font-bold'>
{isResponseSuccess(finance)
? formatCurrency(
finance.data.profit_loss.data.summary.gross_profit.amount
)
: '-'}
</div>
</div>
<div className='flex flex-col gap-1'>
<div>
{isResponseSuccess(finance)
? formatTitleCase(
finance.data.profit_loss.data.summary.net_profit.label ||
'-'
)
: 'Laba Rugi Netto'}
</div>
<div className='text-lg font-bold'>
{isResponseSuccess(finance)
? formatCurrency(
finance.data.profit_loss.data.summary.net_profit.amount
)
: '-'}
</div>
</div>
</div>
</Card>
<Card
title={
isResponseSuccess(finance)
? finance.data.hpp_purchases.title
: 'HPP Purchases'
}
variant='bordered'
collapsible
className={{
wrapper: 'w-full',
}}
>
<div className='mt-6 p-0 mb-0'>
<Table<HppTableRow>
data={hppTableData}
columns={[
{
header: 'No.',
enableSorting: false,
accessorFn: (item, index) => {
if (item.isGroupHeader) return '-';
const dataRowsBefore = hppTableData
.slice(0, index)
.filter((row) => !row.isGroupHeader).length;
return dataRowsBefore + 1;
},
footer: (props) => {
return 'HPP';
},
},
{
header: 'Type',
enableSorting: false,
accessorFn: (item) => formatTitleCase(item.type || '-'),
},
{
header: 'Budgeting',
enableSorting: false,
columns: [
{
header: 'Rp/Ekor',
id: 'budgeting_rp_per_bird',
enableSorting: false,
accessorFn: (item) =>
formatCurrency(item.budgeting?.rp_per_bird || 0),
footer: (props) => {
return props.column.id === 'budgeting_rp_per_bird' &&
isResponseSuccess(finance)
? formatCurrency(
finance.data.hpp_purchases.summary_hpp.budgeting
.rp_per_bird || 0
)
: '-';
},
},
{
header: 'Rp/Kg',
id: 'budgeting_rp_per_kg',
enableSorting: false,
accessorFn: (item) =>
formatCurrency(item.budgeting?.rp_per_kg || 0),
footer: (props) => {
return props.column.id === 'budgeting_rp_per_kg' &&
isResponseSuccess(finance)
? formatCurrency(
finance.data.hpp_purchases.summary_hpp.budgeting
.rp_per_kg || 0
)
: '-';
},
},
{
header: 'Jumlah (Rp)',
id: 'budgeting_amount',
enableSorting: false,
accessorFn: (item) =>
formatCurrency(item.budgeting?.amount || 0),
footer: (props) => {
return props.column.id === 'budgeting_amount' &&
isResponseSuccess(finance)
? formatCurrency(
finance.data.hpp_purchases.summary_hpp.budgeting
.amount || 0
)
: '-';
},
},
],
},
{
header: 'Realization',
enableSorting: false,
columns: [
{
header: 'Rp/Ekor',
id: 'realization_rp_per_bird',
enableSorting: false,
accessorFn: (item) =>
formatCurrency(item.realization?.rp_per_bird || 0),
footer: (props) => {
return props.column.id === 'realization_rp_per_bird' &&
isResponseSuccess(finance)
? formatCurrency(
finance.data.hpp_purchases.summary_hpp.realization
.rp_per_bird || 0
)
: '-';
},
},
{
header: 'Rp/Kg',
id: 'realization_rp_per_kg',
enableSorting: false,
accessorFn: (item) =>
formatCurrency(item.realization?.rp_per_kg || 0),
footer: (props) => {
return props.column.id === 'realization_rp_per_kg' &&
isResponseSuccess(finance)
? formatCurrency(
finance.data.hpp_purchases.summary_hpp.realization
.rp_per_kg || 0
)
: '-';
},
},
{
header: 'Jumlah (Rp)',
id: 'realization_amount',
enableSorting: false,
accessorFn: (item) =>
formatCurrency(item.realization?.amount || 0),
footer: (props) => {
return props.column.id === 'realization_amount' &&
isResponseSuccess(finance)
? formatCurrency(
finance.data.hpp_purchases.summary_hpp.realization
.amount || 0
)
: '-';
},
},
],
},
]}
renderCustomRow={(row) => {
const rowData = row.original;
if (rowData.isGroupHeader) {
return (
<tr
key={row.id}
className={TABLE_DEFAULT_STYLING.bodyRowClassName}
>
<td
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
></td>
<td
colSpan={7}
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
>
<div className='font-bold'>
{formatTitleCase(rowData.group_name ?? '-')}
</div>
</td>
</tr>
);
}
return null;
}}
renderFooter={isResponseSuccess(finance)}
/>
</div>
</Card>
<Card
title={
isResponseSuccess(finance)
? finance.data.profit_loss.title
: 'Profit/Loss'
}
variant='bordered'
collapsible
className={{
wrapper: 'w-full',
}}
>
<div className='mt-6 p-0 mb-0'>
<Table<ProfitLossTableRow>
data={profitLossTableData}
columns={[
{
header: 'Jenis',
enableSorting: false,
accessorFn: (item) => item.type,
cell: (item) => (
<div className=''>
{formatTitleCase(item.row.original.type || '-')}
</div>
),
footer: (item) => (
<div className='font-bold uppercase'>
{isResponseSuccess(finance)
? formatTitleCase(
finance.data.profit_loss.data.summary.net_profit
.label || '-'
)
: '-'}
</div>
),
},
{
header: 'Rp/Ekor',
enableSorting: false,
accessorFn: (item) => formatCurrency(item.rp_per_bird || 0),
footer: (item) => (
<div className='font-bold'>
{isResponseSuccess(finance)
? formatCurrency(
finance.data.profit_loss.data.summary.net_profit
.rp_per_bird || 0
)
: formatCurrency(0)}
</div>
),
},
{
header: 'Rp/Kg',
enableSorting: false,
accessorFn: (item) => formatCurrency(item.rp_per_kg || 0),
footer: (item) => (
<div className='font-bold'>
{isResponseSuccess(finance)
? formatCurrency(
finance.data.profit_loss.data.summary.net_profit
.rp_per_kg || 0
)
: formatCurrency(0)}
</div>
),
},
{
header: 'Jumlah (Rp)',
enableSorting: false,
accessorFn: (item) => formatCurrency(item.amount || 0),
footer: (item) => (
<div className='font-bold'>
{isResponseSuccess(finance)
? formatCurrency(
finance.data.profit_loss.data.summary.net_profit
.amount || 0
)
: formatCurrency(0)}
</div>
),
},
]}
renderCustomRow={(row) => {
const rowData = row.original;
if (rowData.isGroupHeader) {
if (rowData.amount) {
return (
<tr
key={row.id}
className={TABLE_DEFAULT_STYLING.footerRowClassName}
>
<td
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
>
<div className='font-bold ps-6 uppercase'>
{formatTitleCase(rowData.label ?? '-')}
</div>
</td>
<td
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
>
<div className='font-bold'>
{formatCurrency(rowData.rp_per_bird ?? 0)}
</div>
</td>
<td
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
>
<div className='font-bold'>
{formatCurrency(rowData.rp_per_kg ?? 0)}
</div>
</td>
<td
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
>
<div className='font-bold'>
{formatCurrency(rowData.amount ?? 0)}
</div>
</td>
</tr>
);
}
return (
<tr
key={row.id}
className={TABLE_DEFAULT_STYLING.bodyRowClassName}
>
<td
colSpan={4}
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
>
<div className='font-bold'>
{formatTitleCase(rowData.group_name ?? '-')}
</div>
</td>
</tr>
);
}
return null;
}}
className={{
paginationClassName: 'hidden',
}}
renderFooter={isResponseSuccess(finance)}
/>
</div>
</Card>
</>
</div>
);
};
export default ClosingFinanceTable;
@@ -33,7 +33,7 @@ const ClosingProductionDataTabContent = ({
);
}
const { purchase, sales, performance, variance } = productionData.data;
const { purchase, sales, performance } = productionData.data;
// Helper for consistent row styling
const DataRow = ({
@@ -58,39 +58,6 @@ const ClosingProductionDataTabContent = ({
</div>
);
// Helper for rows with two values (e.g., Deplesi: Ekor & %)
const DoubleDataRow = ({
label,
value1,
unit1,
value2,
unit2,
value1ClassName = 'font-bold text-gray-800',
value2ClassName = 'font-bold text-blue-500',
}: {
label: string;
value1: string | number;
unit1: string;
value2: string | number;
unit2: string;
value1ClassName?: string;
value2ClassName?: string;
}) => (
<div className='flex justify-between items-center py-1'>
<span className='text-gray-500 text-sm font-medium w-1/2'>{label}</span>
<div className='flex gap-2 w-1/2 justify-end items-center'>
<div className='flex gap-2 items-center min-w-[5rem] justify-end'>
<span className={value1ClassName}>{value1}</span>
<span className='text-gray-500 w-8 text-right'>{unit1}</span>
</div>
<div className='flex gap-2 items-center min-w-[4rem] justify-end ml-2'>
<span className={value2ClassName}>{value2}</span>
<span className='text-gray-500 w-4 text-right'>{unit2}</span>
</div>
</div>
</div>
);
return (
<div className='w-full rounded-xl p-8 shadow-sm'>
<h2 className='text-lg font-bold mb-8 text-gray-800'>Data Produksi</h2>
@@ -121,17 +88,17 @@ const ClosingProductionDataTabContent = ({
/>
<DataRow
label='Pakan Masuk'
value={formatNumber(purchase.feed_in_kg)}
value={formatNumber(purchase.feed_in)}
unit='Kg'
/>
<DataRow
label='Pakan Terpakai'
value={formatNumber(purchase.feed_used_kg)}
value={formatNumber(purchase.feed_used)}
unit='Kg'
/>
<DataRow
label='Pakan Terpakai per Ekor'
value={formatNumber(purchase.feed_used_per_head_kg)}
value={formatNumber(purchase.feed_used_per_head)}
unit='Kg'
/>
</div>
@@ -142,27 +109,61 @@ const ClosingProductionDataTabContent = ({
<h3 className='font-bold text-gray-700 mb-4 text-base'>
Penjualan
</h3>
<div className='space-y-1'>
<DataRow
label='Penjualan (Kg)'
value={formatNumber(sales.sales_kg)}
unit='Kg'
/>
<DataRow
label='Penjualan (Ekor)'
value={formatNumber(sales.sales_head)}
unit='Ekor'
/>
<DataRow
label='Bobot Rata-Rata'
value={formatNumber(sales.average_weight_kg)}
unit='Kg/Ekor'
/>
<DataRow
label='Harga Jual Rata-Rata'
value={formatNumber(sales.average_price_per_kg)}
unit='Rupiah'
/>
<div className='space-y-4'>
{/* Chicken Sales */}
<div className='space-y-1'>
<DataRow
label='Penjualan (Ekor)'
value={formatNumber(sales.chicken.sales_population)}
unit='Ekor'
/>
<DataRow
label='Penjualan (Kg)'
value={formatNumber(sales.chicken.sales_weight)}
unit='Kg'
/>
<DataRow
label='Bobot Rata-Rata'
value={formatNumber(sales.chicken.average_weight)}
unit='Kg/Ekor'
/>
<DataRow
label='Harga Jual Rata-Rata'
value={formatNumber(
sales.chicken.chicken_average_selling_price
)}
unit='Rupiah'
/>
</div>
{/* Egg Sales (if available) */}
{sales.egg && (
<>
<div className='h-px bg-gray-100 my-2' />
<div className='space-y-1'>
<DataRow
label='Telur (Butir)'
value={formatNumber(sales.egg.egg_pieces)}
unit='Butir'
/>
<DataRow
label='Telur (Kg)'
value={formatNumber(sales.egg.egg_mass_kg)}
unit='Kg'
/>
<DataRow
label='Berat Telur Rata-Rata'
value={formatNumber(sales.egg.average_egg_weight_kg)}
unit='Kg'
/>
<DataRow
label='Harga Jual Telur Rata-Rata'
value={formatNumber(sales.egg.egg_average_selling_price)}
unit='Rupiah'
/>
</div>
</>
)}
</div>
</section>
</div>
@@ -178,24 +179,20 @@ const ClosingProductionDataTabContent = ({
Performance
</h3>
<div className='space-y-1'>
<DoubleDataRow
<DataRow
label='Deplesi'
value1={formatNumber(performance.depletion_head)}
unit1='Ekor'
value2={formatNumber(performance.depletion_percentage)}
unit2='%'
value={formatNumber(performance.depletion)}
unit='Ekor'
/>
<DataRow
label='Umur'
value={formatNumber(performance.age_days)}
value={formatNumber(performance.age_day)}
unit='Hari'
// Aligning 'Hari' with the first unit column of double row
unitClassName='text-gray-500 w-8 text-right mr-[4.5rem]'
/>
<DataRow
label='Mortalitas Std'
value={formatNumber(performance.mortality_std)}
unitClassName='hidden' // No unit shown in screenshot
unitClassName='hidden'
/>
<DataRow
label='Mortalitas Act'
@@ -223,40 +220,10 @@ const ClosingProductionDataTabContent = ({
unitClassName='hidden'
/>
<DataRow
label='ADG'
value={formatNumber(performance.adg)}
label='AWG'
value={formatNumber(performance.awg)}
unit='Gr/Hari'
/>
<DataRow
label='IP'
value={formatNumber(performance.ip)}
unitClassName='hidden'
/>
</div>
</section>
{/* Variance Section (Pushed to bottom) */}
<section className='mt-auto pt-4'>
<h3 className='font-bold text-gray-700 mb-4 text-base'>Selisih</h3>
<div className='space-y-1'>
<DataRow
label='Selisih Ayam'
value={formatNumber(variance.variance_head)}
unit='Ekor'
valueClassName='font-bold text-red-500'
/>
<DataRow
label='% Selisih Ayam'
value={formatNumber(variance.variance_head_percentage)}
unit='%'
valueClassName='font-bold text-red-500'
/>
<DataRow
label='Selisih Pakan'
value={formatNumber(variance.variance_feed_kg)}
unit='Kg'
valueClassName='font-bold text-red-500'
/>
</div>
</section>
</div>
@@ -154,66 +154,74 @@ const ClosingSapronakCalculationTable = ({
return (
<div className='flex flex-col gap-4'>
{isResponseSuccess(sapronakCalculation) && (
<>
<Card
title='DOC Broiler'
collapsible
defaultCollapsed={false}
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<Table<RowSapronakCalculation>
data={sapronakCalculation.data?.doc_broiler.rows ?? []}
columns={docBroilerColumns}
className={{
containerClassName: 'my-4',
}}
renderFooter
/>
</Card>
<Card
title='DOC Broiler'
collapsible
defaultCollapsed={false}
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<Table<RowSapronakCalculation>
data={
isResponseSuccess(sapronakCalculation)
? (sapronakCalculation.data?.doc_broiler.rows ?? [])
: []
}
columns={docBroilerColumns}
className={{
containerClassName: 'my-4',
}}
renderFooter={isResponseSuccess(sapronakCalculation)}
/>
</Card>
<Card
title='OVK'
variant='bordered'
collapsible
defaultCollapsed={true}
className={{
wrapper: 'w-full',
}}
>
<Table<RowSapronakCalculation>
data={sapronakCalculation.data?.ovk.rows ?? []}
columns={ovkColumns}
className={{
containerClassName: 'my-4',
}}
renderFooter
/>
</Card>
<Card
title='OVK'
variant='bordered'
collapsible
defaultCollapsed={true}
className={{
wrapper: 'w-full',
}}
>
<Table<RowSapronakCalculation>
data={
isResponseSuccess(sapronakCalculation)
? (sapronakCalculation.data?.ovk.rows ?? [])
: []
}
columns={ovkColumns}
className={{
containerClassName: 'my-4',
}}
renderFooter={isResponseSuccess(sapronakCalculation)}
/>
</Card>
<Card
title='Pakan'
variant='bordered'
collapsible
defaultCollapsed={true}
className={{
wrapper: 'w-full',
}}
>
<Table<RowSapronakCalculation>
data={sapronakCalculation.data?.pakan.rows ?? []}
columns={pakanColumns}
className={{
containerClassName: 'my-4',
}}
renderFooter
/>
</Card>
</>
)}
<Card
title='Pakan'
variant='bordered'
collapsible
defaultCollapsed={true}
className={{
wrapper: 'w-full',
}}
>
<Table<RowSapronakCalculation>
data={
isResponseSuccess(sapronakCalculation)
? (sapronakCalculation.data?.pakan.rows ?? [])
: []
}
columns={pakanColumns}
className={{
containerClassName: 'my-4',
}}
renderFooter={isResponseSuccess(sapronakCalculation)}
/>
</Card>
</div>
);
};
@@ -0,0 +1,110 @@
'use client';
import React, { useMemo } from 'react';
import { ColumnDef } from '@tanstack/react-table';
import Table from '@/components/Table';
import Card from '@/components/Card';
import { formatCurrency } from '@/lib/helper';
import { BaseHppExpedition, BaseExpeditionCost } from '@/types/api/closing';
interface HppExpeditionReportTableProps {
type?: 'detail';
initialValues?: BaseHppExpedition;
}
const HppExpeditionReportTable = ({
type = 'detail',
initialValues,
}: HppExpeditionReportTableProps) => {
const costOfRevenueExpeditionData: BaseExpeditionCost[] = useMemo(() => {
return initialValues?.expedition_costs || [];
}, [initialValues]);
const totals = useMemo(() => {
const totalHpp = initialValues?.total_hpp_amount || 0;
return {
totalHpp,
};
}, [initialValues]);
const costOfRevenueExpeditionColumns: ColumnDef<BaseExpeditionCost>[] =
useMemo(
() => [
{
id: 'id',
accessorKey: 'id',
header: 'No',
cell: (props) => {
return <div>{props.row.index + 1}</div>;
},
footer: () => (
<div className='font-semibold text-gray-900'>
Total HPP Ekspedisi
</div>
),
},
{
id: 'expedition_vendor_name',
accessorKey: 'expedition_vendor_name',
header: 'Nama Ekspedisi',
cell: (props) => props.getValue() || '-',
},
{
id: 'hpp_amount',
accessorKey: 'hpp_amount',
header: 'HPP Ekspedisi',
cell: (props) => {
const value = props.getValue() as number;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(totals.totalHpp)}
</div>
),
},
],
[totals]
);
return (
<>
<section className='w-full'>
<div className='p-4'>
<h2 className='text-xl font-semibold mb-4'>HPP Ekspedisi</h2>
<Card
className={{
wrapper: 'w-full bg-base-100',
body: 'p-0',
}}
>
<Table
data={costOfRevenueExpeditionData}
columns={costOfRevenueExpeditionColumns}
renderFooter={costOfRevenueExpeditionData.length > 0}
className={{
tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end whitespace-nowrap',
bodyRowClassName:
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
tableFooterClassName:
'bg-gray-100 font-semibold border border-gray-200',
footerRowClassName: 'border-t-2 border-gray-300',
footerColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
}}
/>
</Card>
</div>
</section>
</>
);
};
export default HppExpeditionReportTable;
@@ -1,5 +1,6 @@
'use client';
import Badge from '@/components/Badge';
import Button from '@/components/Button';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import Table from '@/components/Table';
@@ -77,46 +78,39 @@ const InventoryAdjustmentTable = () => {
year: 'numeric',
}),
},
{
id: 'before_quantity',
header: 'Stok Sebelum',
accessorFn: (row) => formatNumber(String(row.before_quantity)),
},
{
id: 'after_quantity',
header: 'Stok Sesudah',
accessorFn: (row) => formatNumber(String(row.after_quantity)),
},
// {
// id: 'before_quantity',
// header: 'Stok Sebelum',
// accessorFn: (row) =>
// formatNumber(String(row.product_warehouse?.quantity)),
// },
// {
// id: 'after_quantity',
// header: 'Stok Sesudah',
// accessorFn: (row) =>
// formatNumber(String(row.product_warehouse?.quantity)),
// },
{
id: 'quantity',
header: 'Kuantitas',
accessorFn: (row) => formatNumber(String(row.quantity)),
accessorFn: (row) => formatNumber(String(row.increase + row.decrease)),
},
{
id: 'transaction_type',
header: 'Tipe Transaksi',
accessorFn: (row) => {
if (row.transaction_type === 'INCREASE') return 'Peningkatan';
if (row.transaction_type === 'DECREASE') return 'Penurunan';
if (row.increase > 0) return 'Peningkatan';
if (row.decrease > 0) return 'Penurunan';
return '-';
},
cell: (props) => {
const type = props.row.original.transaction_type;
const label =
type === 'INCREASE'
? 'Peningkatan'
: type === 'DECREASE'
? 'Penurunan'
: '-';
const type = props.row.original.increase;
const label = type > 0 ? 'Peningkatan' : type <= 0 ? 'Penurunan' : '-';
return (
<div
className={`small mx-auto badge badge-soft ${
type === 'INCREASE' ? 'badge-success' : 'badge-error'
}`}
>
<Badge variant='soft' color={type > 0 ? 'success' : 'error'}>
{label}
</div>
</Badge>
);
},
},
@@ -76,7 +76,7 @@ const InventoryAdjustmentForm = ({
product_category: undefined,
product: undefined,
warehouse: undefined,
quantity: initialValues?.quantity ?? 0,
quantity: initialValues?.increase ?? initialValues?.decrease ?? 0,
transaction_type: undefined,
note: initialValues?.note ?? '',
};
@@ -214,16 +214,8 @@ const InventoryAdjustmentForm = ({
'quantity',
initialValues.product_warehouse.quantity
);
formik.setFieldValue(
'transaction_type',
initialValues.transaction_type.toLowerCase()
);
formik.setFieldValue('note', initialValues.note);
}
if (initialValues?.transaction_type) {
const type = initialValues.transaction_type.toLowerCase();
setQuantityLabel(type === 'increase' ? 'Tambah Stok' : 'Kurangi Stok');
}
}, [
formik,
initialValues,
@@ -278,26 +270,6 @@ const InventoryAdjustmentForm = ({
className='w-full mt-8 flex flex-col gap-6'
>
<div className='flex flex-col gap-4'>
{/* Text Input Before Quantity */}
{type === 'detail' && initialValues && (
<>
<TextInput
label='Stok Sebelum'
name='before_quantity'
type='text'
value={formatNumber(String(initialValues.before_quantity))}
readOnly={true}
/>
<TextInput
label='Stok Setelah'
name='after_quantity'
type='text'
readOnly={true}
value={formatNumber(String(initialValues.after_quantity))}
/>
</>
)}
{/* Select Input Product Category */}
<SelectInput
required
@@ -13,8 +13,12 @@ const InventoryProductDetail = ({
}) => {
const stockLogs = useMemo(() => {
return (
inventoryProduct?.product_warehouses?.flatMap(
(warehouse) => warehouse.stock_logs || []
inventoryProduct?.product_warehouses?.flatMap((warehouse) =>
warehouse.stock_logs.map((log) => ({
...log,
warehouse_name: warehouse.warehouse_name,
warehouse_id: warehouse.warehouse_id,
}))
) || []
);
}, [inventoryProduct]);
@@ -3,7 +3,11 @@ import Table from '@/components/Table';
import { formatDate, formatNumber, formatTitleCase } from '@/lib/helper';
import { StockLog } from '@/types/api/inventory/product';
const StockLogTable = ({ stockLogs }: { stockLogs: StockLog[] }) => {
const StockLogTable = ({
stockLogs,
}: {
stockLogs: (StockLog & { warehouse_name: string; warehouse_id: number })[];
}) => {
return (
<Card
title='Informasi Stock Produk'
@@ -27,6 +31,10 @@ const StockLogTable = ({ stockLogs }: { stockLogs: StockLog[] }) => {
return formatDate(props.row.original.created_at, 'DD-MMM-yyyy');
},
},
{
header: 'Gudang',
accessorKey: 'warehouse_name',
},
{
header: 'Peningkatan',
accessorKey: 'increase',
@@ -21,6 +21,7 @@ import { useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import { ApprovalApi } from '@/services/api/approval';
const ProjectFlockClosingForm = ({
projectFlock,
@@ -31,7 +32,7 @@ const ProjectFlockClosingForm = ({
}) => {
const router = useRouter();
const closeModal = useModal();
const isCanClose = projectFlock.approval?.step_number <= 2;
const [isClosingLoading, setIsClosingLoading] = useState(false);
const { data: closingData, isLoading } = useSWR(
@@ -39,19 +40,35 @@ const ProjectFlockClosingForm = ({
() => ProjectFlockKandangApi.checkClosing(projectFlockKandang.id)
);
const { data: projectFlockKandangApprovals } = useSWR(
`${ApprovalApi.basePath}?module_name=PROJECT_FLOCK_KANDANGS&module_id=${projectFlockKandang.id}`,
() =>
ApprovalApi.getAllFetcher(
`${ApprovalApi.basePath}?module_name=PROJECT_FLOCK_KANDANGS&module_id=${projectFlockKandang.id}`
)
);
const isCanClose = useMemo(() => {
return isResponseSuccess(projectFlockKandangApprovals)
? projectFlockKandangApprovals?.data?.[0]?.step_number <= 2
: true;
}, [projectFlockKandangApprovals]);
const confirmationModalCloseClickHandler = async () => {
setIsClosingLoading(true);
const deleteProjectFlockRes = await ProjectFlockKandangApi.closing(
projectFlockKandang?.id as number,
{
closed_date: formatDate(new Date(), 'YYYY-MM-DD'),
closed_date: isCanClose ? formatDate(new Date(), 'YYYY-MM-DD') : '',
action: isCanClose ? 'close' : 'unclose',
}
);
if (isResponseSuccess(deleteProjectFlockRes)) {
toast.success(deleteProjectFlockRes?.message as string);
router.push(`/production/project-flock`);
router.push(
`/production/project-flock/detail?projectFlockId=${projectFlock.id}`
);
}
if (isResponseError(deleteProjectFlockRes)) {
toast.error(deleteProjectFlockRes?.message as string);
@@ -68,7 +68,7 @@ const ProjectFlockDetail = ({
latestApproval: projectFlock?.approval,
approvalLines: PROJECT_FLOCK_APPROVAL_LINE,
moduleName: 'PROJECT_FLOCKS',
moduleId: projectFlock?.id.toString() ?? '',
moduleId: projectFlock?.id?.toString() ?? '',
});
const { approvals: kandangApprovals, isLoading: kandangApprovalsLoading } =
@@ -47,9 +47,7 @@ import Card from '@/components/Card';
import ProjectFlockKandangTable from '@/components/pages/production/project-flock/form/ProjectFlockKandangTable';
import { Nonstock } from '@/types/api/master-data/nonstock';
import { useUiStore } from '@/stores/ui/ui.store';
import Link from 'next/link';
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
import { formatDate } from '@/lib/helper';
interface ProjectFlockFormProps {
formType?: 'add' | 'edit' | 'detail';
@@ -260,7 +258,9 @@ const ProjectFlockForm = ({
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('category', (val as OptionType)?.value);
formik.setFieldValue('category_option', val);
formik.setFieldTouched('category', true);
if (val == null) {
formik.setFieldTouched('category', true);
}
};
// Submit Handler
@@ -788,7 +788,7 @@ const ProjectFlockForm = ({
}
errorMessage={formik.errors.area_id as string}
isClearable
isDisabled={formType === 'detail'}
isDisabled={formType != 'add'}
/>
<SelectInput
required
@@ -807,7 +807,7 @@ const ProjectFlockForm = ({
}
errorMessage={formik.errors.location_id as string}
isClearable
isDisabled={formType === 'detail' || disabledLocation}
isDisabled={formType != 'add' || disabledLocation}
/>
<SelectInput
required
@@ -837,7 +837,7 @@ const ProjectFlockForm = ({
}
errorMessage={formik.errors.flock_name as string}
isClearable
isDisabled={formType === 'detail'}
isDisabled={formType != 'add'}
/>
<SelectInput
required
@@ -851,7 +851,7 @@ const ProjectFlockForm = ({
isError={formik.touched.fcr_id && Boolean(formik.errors.fcr_id)}
errorMessage={formik.errors.fcr_id as string}
isClearable
isDisabled={formType === 'detail'}
isDisabled={formType != 'add'}
/>
<SelectInput
required
@@ -864,7 +864,7 @@ const ProjectFlockForm = ({
}
errorMessage={formik.errors.category as string}
isClearable
isDisabled={formType === 'detail'}
isDisabled={formType != 'add'}
/>
<NumberInput
name='period'
@@ -4,6 +4,7 @@ import { JSX, useState } from 'react';
import Tabs from '@/components/Tabs';
import DailyMarketingReportContent from '@/components/pages/report/DailyMarketingReportContent';
import HppPerKandangTab from './sale/tab/HppPerKandangTab';
type MarketingReportTabType =
| 'daily'
@@ -21,6 +22,11 @@ const marketingReportTabs: {
label: 'Penjualan Harian',
content: <DailyMarketingReportContent />,
},
{
id: 'daily-hpp',
label: 'HPP Harian Kandang',
content: <HppPerKandangTab />,
},
];
const MarketingReportContent = () => {
@@ -0,0 +1,867 @@
import { useState, useMemo, useCallback } from 'react';
import { ChangeEventHandler } from 'react';
import useSWR from 'swr';
import Button from '@/components/Button';
import Card from '@/components/Card';
import DateInput from '@/components/input/DateInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge';
import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge';
import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
import { cn, formatCurrency, formatDate } from '@/lib/helper';
import { ReportExpense } from '@/types/api/report/report-expense';
import { Icon } from '@iconify/react';
import { ColumnDef } from '@tanstack/react-table';
import { ReportExpenseApi } from '@/services/api/report';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import Pagination from '@/components/Pagination';
import Dropdown from '@/components/dropdown/Dropdown';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import * as XLSX from 'xlsx';
import { generateReportExpensePDF } from './pdf/ReportExpenseExport';
import toast from 'react-hot-toast';
const ReportExpenseTable = () => {
// ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [pdfProgress, setPdfProgress] = useState(0);
const [excelProgress, setExcelProgress] = useState(0);
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
// ===== SUBMISSION STATE =====
const [isSubmitted, setIsSubmitted] = useState(false);
// ===== TABLE FILTER STATE =====
const {
state: filterState,
updateFilter,
setPage,
setPageSize,
reset: resetFilterState,
toQueryString,
} = useTableFilter({
initial: {
location_id: '',
supplier_id: '',
kandang_id: '',
nonstock_id: '',
realization_date: '',
category: '',
search: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
});
// ===== SELECT OPTIONS =====
const { options: optionsLocation, isLoadingOptions: isLoadingLocation } =
useSelect(`/master-data/locations`, 'id', 'name');
const { options: optionsSupplier, isLoadingOptions: isLoadingSupplier } =
useSelect(`/master-data/suppliers`, 'id', 'name');
const { options: optionsKandang, isLoadingOptions: isLoadingKandang } =
useSelect(`/master-data/kandangs`, 'id', 'name', '', {
location_id: filterState.location_id,
});
const { options: optionsNonstock, isLoadingOptions: isLoadingNonstock } =
useSelect(`/master-data/nonstocks`, 'id', 'name');
const categoryOptions = useMemo(
() => [
{ value: 'BOP', label: 'BOP' },
{ value: 'NON-BOP', label: 'Non BOP' },
],
[]
);
// Mendapatkan value option select dari filter state
const selectedLocation = useMemo(
() =>
optionsLocation.find(
(opt) => String(opt.value) === filterState.location_id
) || null,
[optionsLocation, filterState.location_id]
);
const selectedSupplier = useMemo(
() =>
optionsSupplier.find(
(opt) => String(opt.value) === filterState.supplier_id
) || null,
[optionsSupplier, filterState.supplier_id]
);
const selectedKandang = useMemo(
() =>
optionsKandang.find(
(opt) => String(opt.value) === filterState.kandang_id
) || null,
[optionsKandang, filterState.kandang_id]
);
const selectedNonstock = useMemo(
() =>
optionsNonstock.find(
(opt) => String(opt.value) === filterState.nonstock_id
) || null,
[optionsNonstock, filterState.nonstock_id]
);
const selectedCategory = useMemo(
() =>
categoryOptions.find((opt) => opt.value === filterState.category) || null,
[categoryOptions, filterState.category]
);
// ===== FILTER CHANGE HANDLERS =====
const locationChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const option = val as OptionType;
updateFilter('location_id', option ? String(option.value) : '');
updateFilter('kandang_id', '');
setIsSubmitted(false);
},
[updateFilter]
);
const kandangChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const option = val as OptionType;
updateFilter('kandang_id', option ? String(option.value) : '');
setIsSubmitted(false);
},
[updateFilter]
);
const supplierChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const option = val as OptionType;
updateFilter('supplier_id', option ? String(option.value) : '');
setIsSubmitted(false);
},
[updateFilter]
);
const nonstockChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const option = val as OptionType;
updateFilter('nonstock_id', option ? String(option.value) : '');
setIsSubmitted(false);
},
[updateFilter]
);
const categoryChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const option = val as OptionType;
updateFilter('category', option ? String(option.value) : '');
setIsSubmitted(false);
},
[updateFilter]
);
const realizationDateChangeHandler = useCallback<
ChangeEventHandler<HTMLInputElement>
>(
(e) => {
updateFilter('realization_date', e.target.value || '');
setIsSubmitted(false);
},
[updateFilter]
);
const searchChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('search', e.target.value);
setIsSubmitted(false);
},
[updateFilter]
);
// ===== RESET FILTERS =====
const resetFilters = useCallback(() => {
resetFilterState();
setIsSubmitted(false);
}, [resetFilterState]);
// ===== SUBMIT HANDLER =====
const handleSubmit = useCallback(() => {
setIsSubmitted(true);
setPage(1);
}, [setPage]);
// ===== DATA FETCHING FOR TABLE =====
const { data: reportExpenseResponse, isLoading } = useSWR(
isSubmitted
? () => {
return ['report-expense', toQueryString()];
}
: null,
([, query]) => {
const endpoint = `${ReportExpenseApi.basePath}${query}`;
return ReportExpenseApi.getAllFetcher(endpoint);
}
);
const data: ReportExpense[] = useMemo(
() =>
isResponseSuccess(reportExpenseResponse)
? (reportExpenseResponse?.data as ReportExpense[]) || []
: [],
[reportExpenseResponse]
);
const meta = useMemo(
() =>
isResponseSuccess(reportExpenseResponse) && reportExpenseResponse.meta
? reportExpenseResponse.meta
: null,
[reportExpenseResponse]
);
// ===== EXPORT DATA FETCHER =====
const reportExpenseExport = useCallback(async (): Promise<
ReportExpense[] | null
> => {
const params = new URLSearchParams(toQueryString().replace('?', ''));
params.set('limit', 'limit');
params.set('page', '1');
const endpoint = `${ReportExpenseApi.basePath}?${params.toString()}`;
const response = await ReportExpenseApi.getAllFetcher(endpoint);
return isResponseSuccess(response) ? response.data : null;
}, [toQueryString]);
// ===== EXPORT HANDLERS =====
const handleExportPdf = useCallback(async () => {
if (isPdfExportLoading) return;
setIsPdfExportLoading(true);
setPdfProgress(0);
await new Promise((resolve) =>
requestAnimationFrame(() => resolve(undefined))
);
try {
// Stage 1: Fetching data (0-20%)
setPdfProgress(10);
await new Promise((resolve) => setTimeout(resolve, 50));
const allData = await reportExpenseExport();
if (!allData || allData.length === 0) {
toast.error('Tidak ada data untuk diekspor.');
setIsPdfExportLoading(false);
setPdfProgress(0);
return;
}
// Stage 2: Data fetched - langsung loncat ke progress tinggi
setPdfProgress(30);
await new Promise((resolve) => setTimeout(resolve, 50));
const progressInterval = setInterval(() => {
setPdfProgress((prev) => {
// Increment kecil dan random antara 0.5-2%
const increment = Math.random() * 1.5 + 0.5;
const newProgress = Math.min(prev + increment, 50);
return newProgress;
});
}, 300); // Update setiap 300ms
const pdfParams = {
location_name: selectedLocation?.label,
supplier_name: selectedSupplier?.label,
kandang_name: selectedKandang?.label,
nonstock_name: selectedNonstock?.label,
category: selectedCategory?.label,
realization_date: filterState.realization_date,
search: filterState.search,
};
setDropdownOpen(false);
// Stage 3: Langsung loncat ke 80-85% untuk menghindari stuck
const baseProgress = 80 + Math.floor(Math.random() * 16); // Random 80-85%
setPdfProgress(baseProgress);
await new Promise((resolve) => setTimeout(resolve, 100));
// Stage 4: Berikan jeda untuk UI update
await new Promise((resolve) =>
requestAnimationFrame(() => resolve(undefined))
);
// Proses PDF yang sebenarnya
await generateReportExpensePDF(allData, pdfParams);
clearInterval(progressInterval);
// Stage 5: Finalizing (98-100%)
setPdfProgress(99);
await new Promise((resolve) => setTimeout(resolve, 100));
setPdfProgress(100);
toast.success('PDF berhasil dibuat dan diunduh.');
// Reset progress setelah selesai
setTimeout(() => setPdfProgress(0), 500);
} catch (error) {
console.error('PDF Export Error:', error);
toast.error('Gagal membuat PDF. Silakan coba lagi.');
setPdfProgress(0);
} finally {
setIsPdfExportLoading(false);
}
}, [
reportExpenseExport,
selectedLocation,
selectedSupplier,
selectedKandang,
selectedNonstock,
selectedCategory,
filterState.realization_date,
filterState.search,
]);
const handleExportExcel = useCallback(async () => {
if (isExcelExportLoading) return;
setIsExcelExportLoading(true);
setExcelProgress(0);
setDropdownOpen(false);
await new Promise((resolve) =>
requestAnimationFrame(() => resolve(undefined))
);
try {
// Stage 1: Fetching data (0-20%)
setExcelProgress(15);
await new Promise((resolve) => setTimeout(resolve, 50));
const allDataForExport = await reportExpenseExport();
if (!allDataForExport || allDataForExport.length === 0) {
toast.error('Tidak ada data untuk diekspor.');
setIsExcelExportLoading(false);
setExcelProgress(0);
return;
}
// Stage 2: Data fetched (20-40%)
setExcelProgress(30);
await new Promise((resolve) => setTimeout(resolve, 50));
// Stage 3: Grouping data (40-60%)
setExcelProgress(50);
const groupedBySupplier: Record<string, ReportExpense[]> = {};
allDataForExport.forEach((item) => {
const supplierName = item.supplier?.name || 'Unknown Supplier';
if (!groupedBySupplier[supplierName]) {
groupedBySupplier[supplierName] = [];
}
groupedBySupplier[supplierName].push(item);
});
await new Promise((resolve) => setTimeout(resolve, 50));
// Stage 4: Creating workbook (60-80%)
setExcelProgress(70);
const workbook = XLSX.utils.book_new();
const supplierEntries = Object.entries(groupedBySupplier);
const totalSuppliers = supplierEntries.length;
for (let i = 0; i < supplierEntries.length; i++) {
const [supplierName, supplierData] = supplierEntries[i];
// Update progress per supplier
const progressIncrement = (20 / totalSuppliers) * (i + 1);
setExcelProgress(70 + progressIncrement);
const totals = supplierData.reduce(
(acc, item) => ({
qty_pengajuan: acc.qty_pengajuan + (item.pengajuan?.qty || 0),
total_pengajuan:
acc.total_pengajuan +
(item.pengajuan?.qty || 0) * (item.pengajuan?.price || 0),
qty_realisasi: acc.qty_realisasi + (item.realisasi?.qty || 0),
total_realisasi:
acc.total_realisasi +
(item.realisasi?.qty || 0) * (item.realisasi?.price || 0),
}),
{
qty_pengajuan: 0,
total_pengajuan: 0,
qty_realisasi: 0,
total_realisasi: 0,
}
);
const excelData = supplierData.map((item, index) => ({
No: index + 1,
'No. PO': item.po_number || '',
'No. Referensi': item.reference_number || '',
'Tanggal Realisasi': item.realization_date
? formatDate(item.realization_date, 'DD MMM YYYY')
: '',
'Tanggal Transaksi': item.transaction_date
? formatDate(item.transaction_date, 'DD MMM YYYY')
: '',
Kategori: item.category || '',
Produk: item.pengajuan?.nonstock?.name || '',
Lokasi: item.kandang?.location?.name || '',
Kandang: item.kandang?.name || '',
'Qty Pengajuan': item.pengajuan?.qty || 0,
'Harga Pengajuan': item.pengajuan?.price || 0,
'Total Pengajuan':
(item.pengajuan?.qty || 0) * (item.pengajuan?.price || 0),
'Qty Realisasi': item.realisasi?.qty || 0,
'Harga Realisasi': item.realisasi?.price || 0,
'Total Realisasi':
(item.realisasi?.qty || 0) * (item.realisasi?.price || 0),
'Status Pencairan': item.latest_approval?.step_name || '',
}));
excelData.push({
No: 'Total' as unknown as number,
'No. PO': '',
'No. Referensi': '',
'Tanggal Realisasi': '',
'Tanggal Transaksi': '',
Kategori: '',
Produk: '',
Lokasi: '',
Kandang: '',
'Qty Pengajuan': totals.qty_pengajuan,
'Harga Pengajuan': 0,
'Total Pengajuan': totals.total_pengajuan,
'Qty Realisasi': totals.qty_realisasi,
'Harga Realisasi': 0,
'Total Realisasi': totals.total_realisasi,
'Status Pencairan': '',
});
const worksheet = XLSX.utils.json_to_sheet(excelData);
const colWidths = [
{ wch: 5 }, // No
{ wch: 20 }, // No. PO
{ wch: 20 }, // No. Referensi
{ wch: 15 }, // Tanggal Realisasi
{ wch: 15 }, // Tanggal Transaksi
{ wch: 15 }, // Kategori
{ wch: 30 }, // Produk
{ wch: 20 }, // Lokasi
{ wch: 15 }, // Kandang
{ wch: 15 }, // Qty Pengajuan
{ wch: 15 }, // Harga Pengajuan
{ wch: 20 }, // Total Pengajuan
{ wch: 15 }, // Qty Realisasi
{ wch: 15 }, // Harga Realisasi
{ wch: 20 }, // Total Realisasi
{ wch: 20 }, // Status Pencairan
];
worksheet['!cols'] = colWidths;
const sheetName = supplierName.slice(0, 31);
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
// Small delay to allow UI update
if (i < supplierEntries.length - 1) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
// Stage 5: Writing file (90-100%)
setExcelProgress(95);
await new Promise((resolve) => setTimeout(resolve, 50));
const filename = `Laporan-BOP-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.xlsx`;
XLSX.writeFile(workbook, filename);
setExcelProgress(100);
toast.success('Excel berhasil dibuat dan diunduh.');
// Reset progress
setTimeout(() => setExcelProgress(0), 500);
} catch (error) {
console.error('Excel Export Error:', error);
toast.error('Gagal membuat Excel. Silakan coba lagi.');
setExcelProgress(0);
} finally {
setIsExcelExportLoading(false);
}
}, [isExcelExportLoading, reportExpenseExport]);
// ===== PAGINATION HANDLERS =====
const handlePageChange = (page: number) => {
setPage(page);
};
const handleRowChange = (pageSize: number) => {
setPageSize(pageSize);
};
const handleNextPage = () => {
if (meta && filterState.page < meta.total_pages) {
setPage(filterState.page + 1);
}
};
const handlePrevPage = () => {
if (filterState.page > 1) {
setPage(filterState.page - 1);
}
};
// ===== TABLE COLUMNS DEFINITION =====
const columns = useMemo((): ColumnDef<ReportExpense>[] => {
return [
{
header: 'No',
accessorFn: (_, index) =>
(filterState.page - 1) * filterState.pageSize + index + 1,
},
{
header: 'No. PO',
accessorKey: 'po_number',
},
{
header: 'No. Referensi',
accessorKey: 'reference_number',
},
{
header: 'Tanggal Realisasi',
accessorKey: 'realization_date',
cell: ({ row }) => {
return formatDate(row.original?.realization_date, 'DD MMM, YYYY');
},
},
{
header: 'Tanggal Transaksi',
accessorKey: 'transaction_date',
cell: ({ row }) => {
return formatDate(row.original?.transaction_date, 'DD MMM, YYYY');
},
},
{
header: 'Kategori',
accessorKey: 'category',
},
{
header: 'Produk',
accessorFn: (row) => row.pengajuan?.nonstock?.name,
},
{
header: 'Supplier',
accessorFn: (row) => row.supplier?.name,
},
{
header: 'Lokasi',
accessorFn: (row) => row.kandang?.location?.name,
},
{
header: 'Kandang',
accessorFn: (row) => row.kandang?.name,
},
{
header: 'Pengajuan',
columns: [
{
header: 'Qty',
id: 'qty_pengajuan',
accessorFn: (row) => row.pengajuan?.qty,
cell: ({ row }) =>
row.original.pengajuan?.qty?.toLocaleString('id-ID') || '0',
},
{
header: 'Harga',
id: 'harga_pengajuan',
accessorFn: (row) => row.pengajuan?.price,
cell: ({ row }) =>
formatCurrency(row.original.pengajuan?.price || 0),
},
{
header: 'Total',
id: 'total_pengajuan',
accessorFn: (row) =>
(row.pengajuan?.qty || 0) * (row.pengajuan?.price || 0),
cell: ({ row }) => {
const total =
(row.original.pengajuan?.qty || 0) *
(row.original.pengajuan?.price || 0);
return formatCurrency(total);
},
},
],
},
{
header: 'Realisasi',
columns: [
{
header: 'Qty',
id: 'qty_realisasi',
accessorFn: (row) => row.realisasi?.qty,
cell: ({ row }) =>
row.original.realisasi?.qty?.toLocaleString('id-ID') || '0',
},
{
header: 'Harga',
id: 'harga_realisasi',
accessorFn: (row) => row.realisasi?.price,
cell: ({ row }) =>
formatCurrency(row.original.realisasi?.price || 0),
},
{
header: 'Total',
id: 'total_realisasi',
accessorFn: (row) =>
(row.realisasi?.qty || 0) * (row.realisasi?.price || 0),
cell: ({ row }) => {
const total =
(row.original.realisasi?.qty || 0) *
(row.original.realisasi?.price || 0);
return formatCurrency(total);
},
},
],
},
{
header: 'Status Pencairan',
cell: (props) => (
<RealizationStatusBadge
approval={props.row.original?.latest_approval}
/>
),
},
{
header: 'Status BOP',
cell: (props) => (
<ExpenseStatusBadge approval={props.row.original?.latest_approval} />
),
},
];
}, [filterState.page, filterState.pageSize]);
// ===== RENDER =====
return (
<div className='flex flex-col gap-4'>
{isAnyExportLoading && (
<div className='flex flex-col gap-2'>
<progress
className='progress progress-primary w-full'
value={
isPdfExportLoading
? pdfProgress
: isExcelExportLoading
? excelProgress
: 0
}
max='100'
></progress>
{((isPdfExportLoading && pdfProgress > 0) ||
(isExcelExportLoading && excelProgress > 0)) && (
<div className='text-sm text-center text-gray-600'>
<div className='font-semibold'>
{(() => {
const currentProgress = isPdfExportLoading
? pdfProgress
: excelProgress;
const exportType = isPdfExportLoading ? 'PDF' : 'Excel';
if (currentProgress < 20)
return 'Mengambil data dari server...';
if (currentProgress < 30) return 'Memproses data laporan...';
if (currentProgress < 40)
return `Menyiapkan struktur dokumen ${exportType}...`;
if (currentProgress < 50)
return 'Mengelompokkan data per supplier...';
if (currentProgress < 70)
return 'Merender tabel dan kalkulasi...';
if (currentProgress < 96)
return `Memformat dokumen ${exportType}...`;
if (currentProgress < 100)
return 'Menyelesaikan dan mengunduh...';
return 'Selesai!';
})()}{' '}
{Math.round(isPdfExportLoading ? pdfProgress : excelProgress)}%
</div>
{((isPdfExportLoading && pdfProgress >= 35 && pdfProgress < 90) ||
(isExcelExportLoading &&
excelProgress >= 35 &&
excelProgress < 90)) && (
<div className='text-xs text-gray-500 mt-1'>
{(isPdfExportLoading ? pdfProgress : excelProgress) < 96
? 'Proses ini membutuhkan waktu lebih lama untuk data dalam jumlah besar. Mohon bersabar...'
: 'Sedang memproses baris data. Hampir selesai...'}
</div>
)}
</div>
)}
</div>
)}
<Card
title='Laporan Biaya Operasional'
variant='bordered'
className={{
wrapper: 'w-full',
}}
footer={
<div className='flex flex-col gap-6'>
<div className='flex flex-row items-center justify-end gap-2'>
<div className='flex flex-row items-center gap-2'>
<Button className='min-w-24' onClick={handleSubmit}>
Cari
</Button>
<Button
className='min-w-24'
color='warning'
onClick={resetFilters}
>
Reset
</Button>
</div>
<div>
<Dropdown
trigger={
<Button
color='success'
className='min-w-24'
isLoading={isAnyExportLoading}
onClick={() => {
setDropdownOpen(!dropdownOpen);
}}
>
Export
</Button>
}
align='end'
direction='bottom'
open={dropdownOpen}
>
<Menu className='w-32'>
<MenuItem title='Excel' onClick={handleExportExcel} />
<MenuItem title='PDF' onClick={handleExportPdf} />
</Menu>
</Dropdown>
</div>
</div>
</div>
}
>
<div className='grid grid-cols-2 md:grid-cols-4 gap-4'>
<SelectInput
isClearable
label='Lokasi'
options={optionsLocation}
isLoading={isLoadingLocation}
placeholder='Lokasi'
value={selectedLocation}
onChange={locationChangeHandler}
/>
<SelectInput
isClearable
label='Kandang'
options={optionsKandang}
isLoading={isLoadingKandang}
placeholder='Kandang'
value={selectedKandang}
onChange={kandangChangeHandler}
/>
<SelectInput
isClearable
label='Supplier'
options={optionsSupplier}
isLoading={isLoadingSupplier}
placeholder='Supplier'
value={selectedSupplier}
onChange={supplierChangeHandler}
/>
<SelectInput
isClearable
label='Produk'
options={optionsNonstock}
isLoading={isLoadingNonstock}
placeholder='Produk'
value={selectedNonstock}
onChange={nonstockChangeHandler}
/>
<SelectInput
isClearable
label='Kategori'
options={categoryOptions}
placeholder='Kategori'
value={selectedCategory}
onChange={categoryChangeHandler}
/>
<DateInput
label='Tanggal Realisasi'
value={filterState.realization_date}
onChange={realizationDateChangeHandler}
name='realization_date'
placeholder='Tanggal Realisasi'
/>
<DebouncedTextInput
label='Cari'
name='search'
value={filterState.search}
onChange={searchChangeHandler}
placeholder='Cari'
startAdornment={<Icon icon='mdi:magnify' width={24} height={24} />}
/>
</div>
</Card>
{/* ===== TABLE CONTENT ===== */}
{!isSubmitted ? (
<div className='mt-6 text-center text-gray-500'>
Silakan pilih filter dan klik tombol Cari untuk menampilkan data.
</div>
) : isLoading ? (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
) : data.length === 0 ? (
<div className='mt-6 text-center text-gray-500'>
Tidak ada data yang dapat ditampilkan...
</div>
) : (
<>
<Table<ReportExpense>
columns={columns}
data={data}
pageSize={10}
className={{
containerClassName: 'mb-0',
headerRowClassName: cn(
TABLE_DEFAULT_STYLING,
'whitespace-nowrap'
),
bodyRowClassName: cn(TABLE_DEFAULT_STYLING, 'whitespace-nowrap'),
paginationClassName: 'hidden',
}}
/>
{meta && (
<div className='mt-6'>
<Pagination
currentPage={meta.page}
totalItems={meta.total_results}
onPageChange={handlePageChange}
onRowChange={handleRowChange}
onNextPage={handleNextPage}
onPrevPage={handlePrevPage}
rowOptions={[10, 25, 50, 100]}
itemsPerPage={meta.limit}
/>
</div>
)}
</>
)}
</div>
);
};
export default ReportExpenseTable;
@@ -0,0 +1,218 @@
import { ReportExpense } from '@/types/api/report/report-expense';
import { formatCurrency, formatDate } from '@/lib/helper';
import jsPDF from 'jspdf';
import autoTable, { UserOptions } from 'jspdf-autotable';
interface jsPDFWithAutoTable extends jsPDF {
lastAutoTable: {
finalY: number;
};
}
export interface PDFParams {
location_name?: string;
supplier_name?: string;
realization_date?: string;
}
const getStatusColor = (action?: string): [number, number, number] => {
switch (action) {
case 'APPROVED':
case 'Selesai': // Berdasarkan data sumber
return [220, 252, 231]; // Hijau muda (#dcfce7)
case 'REJECTED':
return [254, 226, 226]; // Merah muda (#fee2e2)
case 'Realisasi': // Berdasarkan data sumber
return [254, 243, 199]; // Kuning/Amber muda (#fef3c7)
default:
return [255, 255, 255]; // Putih
}
};
export const generateReportExpensePDF = async (
data: ReportExpense[],
params: PDFParams
): Promise<void> => {
// Inisialisasi dokumen dengan tipe yang sudah diekstensi
const doc = new jsPDF('l', 'mm', 'a4') as jsPDFWithAutoTable;
const pageWidth: number = doc.internal.pageSize.getWidth();
const marginX: number = 14;
// --- HEADER SECTION ---
doc.setFont('helvetica', 'bold');
doc.setFontSize(18);
doc.setTextColor(31, 116, 191); // #1f74bf sesuai style
doc.text('PT LUMBUNG TELUR INDONESIA', marginX, 20);
doc.setFont('helvetica', 'normal');
doc.setFontSize(7);
doc.setTextColor(102, 102, 102);
doc.text(
'SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel. Cipedes, Kec. Sukajadi, Kota Bandung 40162',
marginX,
25
);
doc.setDrawColor(0);
doc.line(marginX, 28, pageWidth - marginX, 28);
// --- TITLE & INFO SECTION ---
doc.setFontSize(18);
doc.setTextColor(31, 116, 191);
doc.text('LAPORAN BIAYA OPERASIONAL', marginX, 38);
doc.setFontSize(7);
doc.setTextColor(0);
const infoX: number = pageWidth - marginX;
doc.text(
`Tanggal Cetak: ${formatDate(new Date(), 'DD MMM YYYY')}`,
infoX,
35,
{ align: 'right' }
);
doc.text(`Total Data: ${data.length} transaksi`, infoX, 40, {
align: 'right',
});
// --- GROUPING LOGIC ---
const groupedBySupplier = data.reduce(
(acc, item) => {
const supplierName: string = item.supplier?.name || 'Unknown Supplier';
if (!acc[supplierName]) acc[supplierName] = [];
acc[supplierName].push(item);
return acc;
},
{} as Record<string, ReportExpense[]>
);
let currentY: number = 50;
// --- RENDER TABLES PER SUPPLIER ---
Object.entries(groupedBySupplier).forEach(([supplierName, items]) => {
// Cek sisa ruang halaman sebelum cetak judul supplier
if (currentY > 180) {
doc.addPage();
currentY = 20;
}
doc.setFontSize(14);
doc.setTextColor(31, 116, 191);
doc.text(supplierName, marginX, currentY);
currentY += 5;
const tableOptions: UserOptions = {
startY: currentY,
head: [
[
{ content: 'No', rowSpan: 2 },
{ content: 'No. PO', rowSpan: 2 },
{ content: 'No. Referensi', rowSpan: 2 },
{ content: 'Tgl Realisasi', rowSpan: 2 },
{ content: 'Tgl Transaksi', rowSpan: 2 },
{ content: 'Kategori', rowSpan: 2 },
{ content: 'Produk', rowSpan: 2 },
{ content: 'Lokasi', rowSpan: 2 },
{ content: 'Kandang', rowSpan: 2 },
{ content: 'Pengajuan', colSpan: 3, styles: { halign: 'center' } },
{ content: 'Realisasi', colSpan: 3, styles: { halign: 'center' } },
{ content: 'Status BOP', rowSpan: 2 },
],
['Qty', 'Harga', 'Total', 'Qty', 'Harga', 'Total'],
],
body: items.map((item, index) => {
const pQty: number = item.pengajuan?.qty || 0;
const pPrice: number = item.pengajuan?.price || 0;
const rQty: number = item.realisasi?.qty || 0;
const rPrice: number = item.realisasi?.price || 0;
return [
index + 1,
item.po_number || '-',
item.reference_number || '-',
formatDate(item.realization_date, 'DD MMM YY'),
formatDate(item.transaction_date, 'DD MMM YY'),
item.category?.replace('-', ' ') || '-',
item.pengajuan?.nonstock?.name || '-',
item.kandang?.location?.name || '-',
item.kandang?.name || '-',
pQty.toLocaleString('id-ID'),
formatCurrency(pPrice),
formatCurrency(pQty * pPrice),
rQty.toLocaleString('id-ID'),
formatCurrency(rPrice),
formatCurrency(rQty * rPrice),
item.latest_approval?.step_name || '-',
];
}),
theme: 'grid',
styles: { fontSize: 6, cellPadding: 1.5, overflow: 'linebreak' },
headStyles: {
fillColor: [245, 245, 245],
textColor: 0,
fontStyle: 'bold',
lineWidth: 0.1,
},
// HOOK UNTUK BADGE:
didParseCell: (dataCell) => {
// Index kolom 15 adalah Status BOP (berdasarkan array di atas)
if (dataCell.section === 'body' && dataCell.column.index === 15) {
const statusText = dataCell.cell.raw as string;
// Berikan warna latar belakang sel sesuai status
dataCell.cell.styles.fillColor = getStatusColor(statusText);
dataCell.cell.styles.textColor = [0, 0, 0]; // Teks hitam agar terbaca
dataCell.cell.styles.fontStyle = 'bold';
dataCell.cell.styles.halign = 'center';
}
},
margin: { left: marginX, right: marginX },
};
autoTable(doc, tableOptions);
currentY = doc.lastAutoTable.finalY + 10;
});
// --- GRAND TOTAL SECTION ---
const grandTotals = data.reduce(
(acc, item) => {
const pTotal = (item.pengajuan?.qty || 0) * (item.pengajuan?.price || 0);
const rTotal = (item.realisasi?.qty || 0) * (item.realisasi?.price || 0);
return {
pengajuan: acc.pengajuan + pTotal,
realisasi: acc.realisasi + rTotal,
};
},
{ pengajuan: 0, realisasi: 0 }
);
if (currentY > 250) {
doc.addPage();
currentY = 20;
}
autoTable(doc, {
startY: currentY,
body: [
['GRAND TOTAL PENGAJUAN', formatCurrency(grandTotals.pengajuan)],
['GRAND TOTAL REALISASI', formatCurrency(grandTotals.realisasi)],
],
styles: { fontSize: 8, fontStyle: 'bold' },
columnStyles: {
0: { cellWidth: 50, fillColor: [245, 245, 245] },
1: { cellWidth: 50 },
},
theme: 'grid',
margin: { left: marginX },
});
// --- FOOTER ---
const finalY: number = doc.lastAutoTable.finalY + 20;
doc.setFontSize(14);
doc.setTextColor(31, 116, 191);
doc.text('PT LUMBUNG TELUR INDONESIA', pageWidth - marginX, finalY, {
align: 'right',
});
// Download File
const fileName: string = `Laporan-BOP-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.pdf`;
doc.save(fileName);
};
@@ -0,0 +1,365 @@
import { StyleSheet } from '@react-pdf/renderer';
const pdfStyles = StyleSheet.create({
page: {
fontSize: 18,
fontFamily: 'Helvetica',
padding: 20,
backgroundColor: '#FFFFFF',
},
header: {
marginBottom: 20,
},
logo: {
width: 120,
height: 30,
marginBottom: 8,
},
companyInfo: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 4,
color: '#1f74bf',
},
address: {
fontSize: 7,
color: '#666666',
maxWidth: 400,
marginBottom: 10,
},
divider: {
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
marginBottom: 15,
},
titleSection: {
flexDirection: 'row',
marginBottom: 20,
justifyContent: 'space-between',
alignItems: 'flex-start',
},
title: {
fontSize: 18,
fontWeight: 'bold',
flex: 3,
color: '#1f74bf',
},
poInfo: {
flex: 1,
fontSize: 7,
textAlign: 'right',
},
sectionTitle: {
fontSize: 14,
fontWeight: 'bold',
marginBottom: 8,
color: '#1f74bf',
},
table: {
borderWidth: 1,
borderColor: '#000000',
marginBottom: 15,
},
tableRow: {
flexDirection: 'row',
},
tableHeader: {
backgroundColor: '#F5F5F5',
},
tableCell: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 3,
fontSize: 7,
},
tableCellLast: {
flex: 1,
padding: 3,
fontSize: 7,
},
tableCellHeader: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 3,
fontSize: 7,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
},
tableCellHeaderLast: {
flex: 1,
padding: 3,
fontSize: 7,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
},
tableCellRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 3,
fontSize: 7,
textAlign: 'right',
},
tableCellRightLast: {
flex: 1,
padding: 3,
fontSize: 7,
textAlign: 'right',
},
tableCellNarrow: {
width: '1%',
minWidth: 20,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 3,
fontSize: 7,
textAlign: 'center',
},
tableCellNarrowHeader: {
width: '1%',
minWidth: 20,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 3,
fontSize: 7,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
textAlign: 'center',
},
tableCellWrap: {
flex: 1,
maxWidth: 80,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 3,
fontSize: 7,
flexWrap: 'wrap',
},
tableCellWrapHeader: {
flex: 1,
maxWidth: 80,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 3,
fontSize: 7,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
},
// Nested header styles
tableHeaderGroup: {
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
backgroundColor: '#F5F5F5',
},
tableHeaderGroupLast: {
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
backgroundColor: '#F5F5F5',
},
tableHeaderGroupTitle: {
padding: 3,
fontSize: 7,
fontWeight: 'bold',
textAlign: 'center',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
},
tableSubHeaderRow: {
flexDirection: 'row',
},
// Specific width columns
tableCellXSmall: {
width: 30,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 3,
fontSize: 7,
},
tableCellXSmallHeader: {
width: 30,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 3,
fontSize: 7,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
},
tableCellSmall: {
width: 40,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 3,
fontSize: 7,
},
tableCellSmallHeader: {
width: 40,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 3,
fontSize: 7,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
},
tableCellMedium: {
width: 60,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 3,
fontSize: 7,
},
tableCellMediumHeader: {
width: 60,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 3,
fontSize: 7,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
},
tableCellRightXSmall: {
width: 30,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 3,
fontSize: 7,
textAlign: 'right',
},
tableCellRightSmall: {
width: 40,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 3,
fontSize: 7,
textAlign: 'right',
},
tableCellRightMedium: {
width: 60,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 3,
fontSize: 7,
textAlign: 'right',
},
tableBorderBottom: {
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
},
grandTotalRow: {
flexDirection: 'row',
borderTopWidth: 1,
borderTopColor: '#000000',
borderTopStyle: 'solid',
},
grandTotalLabel: {
flex: 3,
padding: 3,
fontSize: 7,
fontWeight: 'bold',
textAlign: 'right',
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
},
grandTotalValue: {
flex: 1,
padding: 3,
fontSize: 7,
fontWeight: 'bold',
textAlign: 'right',
borderRightWidth: 0,
},
allocationSection: {
marginBottom: 8,
},
allocationTable: {
borderWidth: 1,
borderColor: '#000000',
},
innerTable: {
marginTop: 5,
borderWidth: 1,
borderColor: '#000000',
},
innerRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
},
innerCell: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 3,
fontSize: 7,
},
innerCellLast: {
flex: 1,
padding: 3,
fontSize: 7,
},
innerCellRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 3,
fontSize: 7,
textAlign: 'right',
},
innerCellRightLast: {
flex: 1,
padding: 3,
fontSize: 7,
textAlign: 'right',
},
footer: {
marginTop: 30,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
},
footerCompany: {
fontSize: 18,
fontWeight: 'bold',
textAlign: 'right',
flex: 1,
color: '#1f74bf',
},
specialInstructionTable: {
width: '60%',
maxWidth: 300,
borderWidth: 1,
borderColor: '#000000',
flex: 1,
},
});
export default pdfStyles;
@@ -0,0 +1,32 @@
'use client';
import Tabs from '@/components/Tabs';
import PurchasesPerSupplierTab from '@/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab';
const LogisticStockTabs = () => {
const tabs = [
{
id: '1',
label: 'Rekapitulasi Pembelian Per Supplier',
content: <PurchasesPerSupplierTab />,
},
// {
// id: '2',
// label: 'Rekapitulasi Pemakaian Barang',
// content: 'Rekapitulasi Pemakaian Barang Tab',
// },
// {
// id: '3',
// label: 'Rekapitulasi Stock Persediaan Barang',
// content: 'Rekapitulasi Stock Persediaan Barang Tab',
// },
];
return (
<section className='w-full p-4'>
<Tabs tabs={tabs} variant='lifted' />
</section>
);
};
export default LogisticStockTabs;
@@ -0,0 +1,404 @@
'use client';
import {
Page,
Text,
View,
Document,
StyleSheet,
Font,
pdf,
} from '@react-pdf/renderer';
import { LogisticPurchasePerSupplierReport } from '@/types/api/report/logistic-stock';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
Font.register({
family: 'Helvetica',
src: 'helvetica',
});
const pdfStyles = StyleSheet.create({
page: {
fontSize: 10,
fontFamily: 'Helvetica',
padding: 20,
backgroundColor: '#FFFFFF',
},
titleSection: {
marginBottom: 10,
},
mainTitle: {
fontSize: 14,
fontWeight: 'bold',
marginBottom: 5,
color: '#1f74bf',
},
supplierTitle: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 8,
color: '#1f74bf',
},
table: {
borderWidth: 1,
borderColor: '#000000',
marginBottom: 15,
},
tableRow: {
flexDirection: 'row',
},
tableHeader: {
backgroundColor: '#F5F5F5',
},
tableCell: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
textAlign: 'left',
},
tableCellNo: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
textAlign: 'center',
},
tableCellLast: {
flex: 1,
padding: 4,
fontSize: 8,
},
tableCellHeader: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
paddingVertical: 12,
textAlign: 'center',
},
tableCellHeaderRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
textAlign: 'right',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
paddingVertical: 12,
},
tableCellHeaderLast: {
flex: 1,
padding: 4,
fontSize: 8,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
paddingVertical: 12,
textAlign: 'center',
},
tableCellRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
textAlign: 'right',
},
tableCellCenter: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
textAlign: 'center',
},
tableCellCenterLast: {
flex: 1,
padding: 4,
fontSize: 8,
textAlign: 'center',
},
tableBorderBottom: {
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
},
supplierSection: {
marginBottom: 10,
},
supplierSectionBreak: {
marginBottom: 15,
},
badge: {
backgroundColor: '#1f74bf',
color: '#FFFFFF',
padding: 2,
borderRadius: 2,
fontSize: 7,
fontWeight: 'bold',
alignSelf: 'center',
marginRight: 4,
},
parameterBadge: {
backgroundColor: '#F5F5F5',
color: '#333333',
padding: 4,
borderRadius: 4,
fontSize: 8,
marginRight: 8,
marginBottom: 4,
},
parameterContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: 8,
},
});
interface PurchasesPerSupplierExportParams {
data: LogisticPurchasePerSupplierReport[];
params: {
area_name?: string;
supplier_name?: string;
product_name?: string;
product_category_name?: string;
received_date?: string;
po_date?: string;
start_date?: string;
end_date?: string;
sort_by?: string;
filter_by?: string;
};
}
const getParameterText = (
params: PurchasesPerSupplierExportParams['params']
) => {
const paramsText = [];
if (params.supplier_name) {
paramsText.push(`Supplier: ${params.supplier_name}`);
} else {
paramsText.push('Semua Supplier');
}
if (params.start_date && params.end_date) {
const startDate = formatDate(params.start_date, 'DD MMM YYYY');
const endDate = formatDate(params.end_date, 'DD MMM YYYY');
paramsText.push(`Periode: ${startDate} - ${endDate}`);
} else if (params.start_date) {
const startDate = formatDate(params.start_date, 'DD MMM YYYY');
paramsText.push(`Tanggal: ${startDate}`);
}
const currentDate = formatDate(new Date(), 'DD MMM YYYY HH:mm');
paramsText.push(`Dicetak: ${currentDate}`);
return paramsText;
};
const createPDFDocument = (
supplierReports: LogisticPurchasePerSupplierReport[],
params: PurchasesPerSupplierExportParams['params']
) => (
<Document>
<Page size='A3' orientation='landscape' style={pdfStyles.page}>
{/* Title and Parameters */}
<View style={pdfStyles.titleSection}>
<Text style={pdfStyles.mainTitle}>
Laporan &gt; Rekapitulasi Pembelian Per Supplier
</Text>
<View style={pdfStyles.parameterContainer}>
<View style={pdfStyles.parameterBadge}>
<Text>
Jenis Tanggal:{' '}
{params.filter_by === 'received_date'
? 'Tanggal Terima'
: 'Tanggal PO'}
</Text>
</View>
{getParameterText(params).map((param, index) => (
<View key={index} style={pdfStyles.parameterBadge}>
<Text>{param}</Text>
</View>
))}
</View>
</View>
{/* Supplier Sections */}
{supplierReports.map(
(
supplierReport: LogisticPurchasePerSupplierReport,
supplierIndex: number
) => {
return (
<View
key={supplierReport.supplier.id}
style={[
pdfStyles.supplierSection,
supplierIndex < supplierReports.length - 1
? pdfStyles.supplierSectionBreak
: {},
]}
>
<Text style={pdfStyles.supplierTitle}>
{supplierReport.supplier.name}
</Text>
<View style={pdfStyles.table}>
{/* Table Header */}
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={[pdfStyles.tableCellHeader, { flex: 0.5 }]}>
<Text>No</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Tanggal Terima</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Tanggal PO</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Referensi</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Produk</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Tujuan</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Qty</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Harga Beli</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.5 }]}>
<Text>Nilai Pembelian</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Biaya Transport</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.5 }]}>
<Text>Total</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>Armada</Text>
</View>
<View style={pdfStyles.tableCellHeaderLast}>
<Text>Surat Jalan</Text>
</View>
</View>
{/* Table Body */}
{supplierReport.rows.map(
(
item: LogisticPurchasePerSupplierReport['rows'][number],
index: number
) => (
<View
key={index}
style={[
pdfStyles.tableRow,
index < supplierReport.rows.length - 1
? pdfStyles.tableBorderBottom
: {},
]}
>
<View style={[pdfStyles.tableCellNo, { flex: 0.5 }]}>
<Text>{index + 1}</Text>
</View>
<View style={pdfStyles.tableCell}>
<Text>
{formatDate(item.receive_date, 'DD-MMM-YYYY')}
</Text>
</View>
<View style={pdfStyles.tableCell}>
<Text>{formatDate(item.po_date, 'DD-MMM-YYYY')}</Text>
</View>
<View style={pdfStyles.tableCell}>
<Text>{item.po_number || '-'}</Text>
</View>
<View style={pdfStyles.tableCell}>
<Text>{item.product?.name || '-'}</Text>
</View>
<View style={pdfStyles.tableCell}>
<Text>{item.warehouse?.name || '-'}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.qty || 0)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.unit_price || 0)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.5 }]}>
<Text>{formatCurrency(item.purchase_value || 0)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>
{formatCurrency(item.transport_unit_price || 0)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.5 }]}>
<Text>{formatCurrency(item.total_amount || 0)}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<View style={pdfStyles.badge}>
<Text>{item.expedition || '-'}</Text>
</View>
</View>
<View style={pdfStyles.tableCellLast}>
<Text>{item.delivery_number || '-'}</Text>
</View>
</View>
)
)}
</View>
</View>
);
}
)}
</Page>
</Document>
);
export const generatePurchasesPerSupplierPDF = async (
data: LogisticPurchasePerSupplierReport[],
params: PurchasesPerSupplierExportParams['params']
): Promise<void> => {
const PDFDocument = createPDFDocument(data, params);
try {
const blob = await pdf(PDFDocument).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `laporan-pembelian-per-supplier-dicetak-pada-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
throw error;
}
};
@@ -0,0 +1,934 @@
import { useState, useMemo, useCallback } from 'react';
import { ChangeEventHandler } from 'react';
import useSWR from 'swr';
import Card from '@/components/Card';
import SelectInput, {
useSelect,
OptionType,
} from '@/components/input/SelectInput';
import DateInput from '@/components/input/DateInput';
import { AreaApi } from '@/services/api/master-data';
import { SupplierApi } from '@/services/api/master-data';
import { ProductApi } from '@/services/api/master-data';
import { ProductCategoryApi } from '@/services/api/master-data';
import { LogisticApi } from '@/services/api/report/logistic-stock';
import Table from '@/components/Table';
import { ColumnDef } from '@tanstack/react-table';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import {
LogisticPurchasePerSupplierReport,
LogisticPurchasePerSupplierSummary,
} from '@/types/api/report/logistic-stock';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import Pagination from '@/components/Pagination';
import Button from '@/components/Button';
import Dropdown from '@/components/Dropdown';
import MenuItem from '@/components/menu/MenuItem';
import Menu from '@/components/menu/Menu';
import { generatePurchasesPerSupplierPDF } from '@/components/pages/report/logistic-stock/export/PurchasesPerSupplierExport';
import toast from 'react-hot-toast';
import * as XLSX from 'xlsx';
const PurchasesPerSupplierTab = () => {
// ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
// ===== PAGINATION STATE =====
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
// ===== SUBMISSION STATE =====
const [isSubmitted, setIsSubmitted] = useState(false);
// ===== TABLE FILTER STATE =====
const { state: tableFilterState, updateFilter } = useTableFilter({
initial: {
area_id: [] as string[],
supplier_id: [] as string[],
product_id: [] as string[],
product_category_id: [] as string[],
received_date: '',
po_date: '',
start_date: '',
end_date: '',
sort_by: '',
filter_by: 'received_date',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
});
const { options: areaOptions, isLoadingOptions: isLoadingAreas } = useSelect(
AreaApi.basePath,
'id',
'name',
'search'
);
const { options: supplierOptions, isLoadingOptions: isLoadingSuppliers } =
useSelect(SupplierApi.basePath, 'id', 'name', 'search', {
category: 'SAPRONAK',
});
const { options: productOptions, isLoadingOptions: isLoadingProducts } =
useSelect(ProductApi.basePath, 'id', 'name', 'search');
const {
options: productCategoryOptions,
isLoadingOptions: isLoadingProductCategories,
} = useSelect(ProductCategoryApi.basePath, 'id', 'name', 'search');
const dataTypeOptions = useMemo(
() => [
{ value: 'received_date', label: 'Tanggal Terima' },
{ value: 'po_date', label: 'Tanggal PO' },
],
[]
);
const sortByOptions = useMemo(
() => [
{ value: 'ASC', label: 'Ascending' },
{ value: 'DESC', label: 'Descending' },
],
[]
);
const areaChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const arr = Array.isArray(val) ? val : val ? [val] : [];
updateFilter(
'area_id',
arr.map((v) => String((v as OptionType).value))
);
setIsSubmitted(false);
},
[updateFilter]
);
const supplierChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const arr = Array.isArray(val) ? val : val ? [val] : [];
updateFilter(
'supplier_id',
arr.map((v) => String((v as OptionType).value))
);
setIsSubmitted(false);
},
[updateFilter]
);
const productChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const arr = Array.isArray(val) ? val : val ? [val] : [];
updateFilter(
'product_id',
arr.map((v) => String((v as OptionType).value))
);
setIsSubmitted(false);
},
[updateFilter]
);
const productCategoryChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const arr = Array.isArray(val) ? val : val ? [val] : [];
updateFilter(
'product_category_id',
arr.map((v) => String((v as OptionType).value))
);
setIsSubmitted(false);
},
[updateFilter]
);
const dataTypeChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
const filterValue =
(newVal?.value as 'received_date' | 'po_date') || 'received_date';
updateFilter('filter_by', filterValue);
updateFilter('received_date', '');
updateFilter('po_date', '');
setIsSubmitted(false);
},
[updateFilter]
);
const sortByHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
const sortValue = (newVal?.value as 'ASC' | 'DESC') || 'ASC';
updateFilter('sort_by', sortValue);
setIsSubmitted(false);
},
[updateFilter]
);
const startDateChangeHandler = useCallback<
ChangeEventHandler<HTMLInputElement>
>(
(e) => {
const val = e.target.value;
updateFilter('start_date', val || '');
setIsSubmitted(false);
},
[updateFilter]
);
const endDateChangeHandler = useCallback<
ChangeEventHandler<HTMLInputElement>
>(
(e) => {
const val = e.target.value;
updateFilter('end_date', val || '');
setIsSubmitted(false);
},
[updateFilter]
);
const resetFilters = useCallback(() => {
updateFilter('area_id', []);
updateFilter('supplier_id', []);
updateFilter('product_id', []);
updateFilter('product_category_id', []);
updateFilter('received_date', '');
updateFilter('po_date', '');
updateFilter('start_date', '');
updateFilter('end_date', '');
updateFilter('sort_by', '');
updateFilter('filter_by', 'received_date');
setIsSubmitted(false);
}, [updateFilter]);
const handleSubmit = useCallback(() => {
setIsSubmitted(true);
setCurrentPage(1);
}, []);
// ===== DATA FETCHING =====
const { data: purchasePerSupplier, isLoading } = useSWR(
isSubmitted
? () => {
const params = {
area_id:
tableFilterState.area_id.length > 0
? tableFilterState.area_id.join(',')
: undefined,
supplier_id:
tableFilterState.supplier_id.length > 0
? tableFilterState.supplier_id.join(',')
: undefined,
product_id:
tableFilterState.product_id.length > 0
? tableFilterState.product_id.join(',')
: undefined,
product_category_id:
tableFilterState.product_category_id.length > 0
? tableFilterState.product_category_id.join(',')
: undefined,
received_date:
tableFilterState.filter_by === 'received_date'
? tableFilterState.start_date || undefined
: undefined,
po_date:
tableFilterState.filter_by === 'po_date'
? tableFilterState.start_date || undefined
: undefined,
start_date: tableFilterState.start_date || undefined,
end_date: tableFilterState.end_date || undefined,
sort_by: tableFilterState.sort_by || undefined,
filter_by: tableFilterState.filter_by || undefined,
page: currentPage,
limit: pageSize,
};
return ['logistic-purchase-report', params];
}
: null,
([, params]) =>
LogisticApi.getLogisticPurchasePerSupplierReport(
params.area_id,
params.supplier_id,
params.product_id,
params.product_category_id,
params.received_date,
params.po_date,
params.start_date,
params.end_date,
params.sort_by,
params.filter_by,
params.page,
params.limit
)
);
const data: LogisticPurchasePerSupplierReport[] = useMemo(
() =>
isResponseSuccess(purchasePerSupplier)
? (purchasePerSupplier?.data as unknown as LogisticPurchasePerSupplierReport[]) ||
[]
: [],
[purchasePerSupplier]
);
const meta =
isResponseSuccess(purchasePerSupplier) && purchasePerSupplier?.meta
? purchasePerSupplier.meta
: null;
// ===== EXPORT DATA FETCHER =====
const logisticPurchasePerSupplierExport = useCallback(async (): Promise<
LogisticPurchasePerSupplierReport[] | null
> => {
const params = {
area_id:
tableFilterState.area_id.length > 0
? tableFilterState.area_id.join(',')
: undefined,
supplier_id:
tableFilterState.supplier_id.length > 0
? tableFilterState.supplier_id.join(',')
: undefined,
product_id:
tableFilterState.product_id.length > 0
? tableFilterState.product_id.join(',')
: undefined,
product_category_id:
tableFilterState.product_category_id.length > 0
? tableFilterState.product_category_id.join(',')
: undefined,
received_date:
tableFilterState.filter_by === 'received_date'
? tableFilterState.start_date || undefined
: undefined,
po_date:
tableFilterState.filter_by === 'po_date'
? tableFilterState.start_date || undefined
: undefined,
start_date: tableFilterState.start_date || undefined,
end_date: tableFilterState.end_date || undefined,
sort_by: tableFilterState.sort_by || undefined,
filter_by: tableFilterState.filter_by || undefined,
limit: 100,
page: 1,
};
const response = await LogisticApi.getLogisticPurchasePerSupplierReport(
params.area_id,
params.supplier_id,
params.product_id,
params.product_category_id,
params.received_date,
params.po_date,
params.start_date,
params.end_date,
params.sort_by,
params.filter_by,
params.page,
params.limit
);
return isResponseSuccess(response)
? (response.data as unknown as LogisticPurchasePerSupplierReport[])
: null;
}, [tableFilterState]);
// ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
const allDataForExport = await logisticPurchasePerSupplierExport();
if (
!allDataForExport ||
!Array.isArray(allDataForExport) ||
allDataForExport.length === 0
) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
const workbook = XLSX.utils.book_new();
allDataForExport.forEach((supplierReport) => {
const supplierData = supplierReport.rows;
const supplierName =
supplierReport.supplier?.name || 'Unknown Supplier';
const excelData: { [key: string]: string | number }[] =
supplierData.map((item, index) => ({
No: index + 1,
'Tanggal Terima': item.receive_date
? formatDate(item.receive_date, 'DD MMM YYYY')
: '',
'Tanggal PO': item.po_date
? formatDate(item.po_date, 'DD MMM YYYY')
: '',
'No. Referensi': item.po_number || '',
'Nama Produk': item.product?.name || '',
Tujuan: item.warehouse?.name || '',
QTY: item.qty || 0,
'Harga Beli (Rp)': item.unit_price || 0,
'Value Harga Beli (Rp)': item.purchase_value || 0,
'Transport (Rp)': item.transport_unit_price || 0,
'Value Transport (Rp)': item.transport_value || 0,
'Jumlah (Rp)': item.total_amount || 0,
Ekspedisi: item.expedition || '',
'Surat Jalan': item.delivery_number || '',
}));
if (supplierReport.summary) {
excelData.push({
No: 'Total',
'Tanggal Terima': '',
'Tanggal PO': '',
'No. Referensi': '',
'Nama Produk': '',
Tujuan: '',
QTY: supplierReport.summary.total_qty || 0,
'Harga Beli (Rp)': '',
'Value Harga Beli (Rp)':
supplierReport.summary.total_purchase_value || 0,
'Transport (Rp)': '',
'Value Transport (Rp)':
supplierReport.summary.total_transport_value || 0,
'Jumlah (Rp)': supplierReport.summary.total_amount || 0,
Ekspedisi: '',
'Surat Jalan': '',
});
}
const worksheet = XLSX.utils.json_to_sheet(excelData);
const colWidths = [
{ wch: 5 }, // No
{ wch: 15 }, // Tanggal Terima
{ wch: 15 }, // Tanggal PO
{ wch: 15 }, // No. Referensi
{ wch: 30 }, // Nama Produk
{ wch: 20 }, // Tujuan
{ wch: 10 }, // QTY
{ wch: 18 }, // Harga Beli
{ wch: 20 }, // Value Harga Beli
{ wch: 15 }, // Transport
{ wch: 20 }, // Value Transport
{ wch: 18 }, // Jumlah
{ wch: 15 }, // Ekspedisi
{ wch: 15 }, // Surat Jalan
];
worksheet['!cols'] = colWidths;
const sheetName =
supplierName.length > 31
? supplierName.substring(0, 31)
: supplierName;
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
});
const filename = `laporan-pembelian-per-supplier-dicetak-pada-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.xlsx`;
XLSX.writeFile(workbook, filename);
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally {
setIsExcelExportLoading(false);
}
}, [
logisticPurchasePerSupplierExport,
tableFilterState,
areaOptions,
supplierOptions,
]);
const handleExportPdf = useCallback(async () => {
setIsPdfExportLoading(true);
try {
const allDataForExport = await logisticPurchasePerSupplierExport();
if (
!allDataForExport ||
!Array.isArray(allDataForExport) ||
allDataForExport.length === 0
) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
const areaName =
tableFilterState.area_id.length > 0
? tableFilterState.area_id
.map(
(id) =>
areaOptions.find((opt) => opt.value === Number(id))?.label
)
.filter(Boolean)
.join(', ') || 'Semua Area'
: 'Semua Area';
const supplierName =
tableFilterState.supplier_id.length > 0
? tableFilterState.supplier_id
.map(
(id) =>
supplierOptions.find((opt) => opt.value === Number(id))?.label
)
.filter(Boolean)
.join(', ') || 'Semua Supplier'
: 'Semua Supplier';
const productName =
tableFilterState.product_id.length > 0
? tableFilterState.product_id
.map(
(id) =>
productOptions.find((opt) => opt.value === Number(id))?.label
)
.filter(Boolean)
.join(', ') || 'Semua Produk'
: 'Semua Produk';
const productCategoryName =
tableFilterState.product_category_id.length > 0
? tableFilterState.product_category_id
.map(
(id) =>
productCategoryOptions.find((opt) => opt.value === Number(id))
?.label
)
.filter(Boolean)
.join(', ') || 'Semua Kategori Produk'
: 'Semua Kategori Produk';
const exportParams = {
area_name: areaName,
supplier_name: supplierName,
product_name: productName,
product_category_name: productCategoryName,
filter_by: tableFilterState.filter_by || 'received_date',
start_date: tableFilterState.start_date || '',
end_date: tableFilterState.end_date || '',
};
await generatePurchasesPerSupplierPDF(allDataForExport, exportParams);
toast.success('PDF berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat PDF. Silakan coba lagi.');
} finally {
setIsPdfExportLoading(false);
}
}, [
logisticPurchasePerSupplierExport,
tableFilterState,
areaOptions,
supplierOptions,
productOptions,
productCategoryOptions,
]);
// ===== PAGINATION HANDLERS =====
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const handleRowChange = (pageSize: number) => {
setPageSize(pageSize);
};
const handleNextPage = () => {
if (meta && currentPage < meta.total_pages) {
setCurrentPage(currentPage + 1);
}
};
const handlePrevPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};
const getTableColumns = (
summary: LogisticPurchasePerSupplierSummary
): ColumnDef<LogisticPurchasePerSupplierReport['rows'][0]>[] => {
const tableColumns: ColumnDef<
LogisticPurchasePerSupplierReport['rows'][0]
>[] = [
{
id: 'no',
header: 'No',
cell: (props) => props.row.index + 1,
footer: () => <div className='font-semibold text-gray-900'>Total</div>,
},
{
id: 'received_date',
header: 'Tanggal Terima',
accessorKey: 'receive_date',
cell: (props) => {
const value = props.row.original.receive_date;
return formatDate(value, 'DD MMM YYYY');
},
},
{
id: 'po_date',
header: 'Tanggal PO',
accessorKey: 'po_date',
cell: (props) => {
const value = props.row.original.po_date;
return formatDate(value, 'DD MMM YYYY');
},
},
{
id: 'po_number',
header: 'No. Referensi',
accessorKey: 'po_number',
cell: (props) => {
const value = props.row.original.po_number;
return value || '-';
},
},
{
id: 'product_name',
header: 'Nama Produk',
accessorKey: 'product.name',
cell: (props) => {
const product = props.row.original.product;
return product?.name || '-';
},
},
{
id: 'destination_warehouse',
header: 'Tujuan',
accessorKey: 'warehouse.name',
cell: (props) => {
const warehouse = props.row.original.warehouse;
return warehouse?.name || '-';
},
},
{
id: 'qty',
header: 'QTY',
accessorKey: 'qty',
cell: (props) => {
const value = props.row.original.qty;
return <div className='text-right'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatNumber(summary.total_qty) || '-'}
</div>
),
},
{
id: 'price',
header: 'Harga Beli (Rp)',
accessorKey: 'unit_price',
cell: (props) => {
const value = props.row.original.unit_price;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary.total_unit_price) || '-'}
</div>
),
},
{
id: 'purchase_amount',
header: 'Value Harga Beli (Rp)',
accessorKey: 'purchase_value',
cell: (props) => {
const value = props.row.original.purchase_value;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary.total_purchase_value) || '-'}
</div>
),
},
{
id: 'transport',
header: 'Transport (Rp)',
accessorKey: 'transport_unit_price',
cell: (props) => {
const value = props.row.original.transport_unit_price;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary.total_transport_unit_price) || '-'}
</div>
),
},
{
id: 'value_transport',
header: 'Value Transport (Rp)',
accessorKey: 'transport_value',
cell: (props) => {
const value = props.row.original.transport_value;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary.total_transport_value) || '-'}
</div>
),
},
{
id: 'total',
header: 'Jumlah (Rp)',
accessorKey: 'total_amount',
cell: (props) => {
const value = props.row.original.total_amount;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary.total_amount) || '-'}
</div>
),
},
{
id: 'expedition_vendor_name',
header: 'Ekspedisi',
accessorKey: 'expedition',
cell: (props) => {
const value = props.row.original.expedition;
return value || '-';
},
},
{
id: 'travel_number',
header: 'Surat Jalan',
accessorKey: 'delivery_number',
cell: (props) => {
const value = props.row.original.delivery_number;
return value || '-';
},
},
];
return tableColumns;
};
return (
<div className='w-full p-0 sm:p-4'>
<Card
subtitle='Laporan > Rekapitulasi Pembelian Per Supplier'
className={{ wrapper: 'w-full', body: 'p-1!' }}
>
<div className='mb-4 flex justify-end gap-2 [&_button]:px-4'>
<Button color='primary' onClick={handleSubmit}>
Cari
</Button>
<Button color='warning' onClick={resetFilters}>
Reset
</Button>
<Dropdown
trigger={
<Button color='success' isLoading={isAnyExportLoading}>
Export
</Button>
}
align='end'
>
<Menu className='w-32'>
<MenuItem title='Excel' onClick={handleExportExcel} />
<MenuItem title='PDF' onClick={handleExportPdf} />
</Menu>
</Dropdown>
</div>
<div className='grid md:grid-cols-3 grid-cols-1 gap-4'>
<SelectInput
label='Area'
placeholder='Pilih Area'
isMulti
options={areaOptions}
value={areaOptions.filter((opt) =>
(tableFilterState.area_id || [])
.map(String)
.includes(String(opt.value))
)}
onChange={areaChangeHandler}
isLoading={isLoadingAreas}
isClearable
/>
<SelectInput
label='Supplier'
placeholder='Pilih Supplier'
isMulti
options={supplierOptions}
value={supplierOptions.filter((opt) =>
(tableFilterState.supplier_id || [])
.map(String)
.includes(String(opt.value))
)}
onChange={supplierChangeHandler}
isLoading={isLoadingSuppliers}
isClearable
/>
<SelectInput
label='Produk'
placeholder='Pilih Produk'
isMulti
options={productOptions}
value={productOptions.filter((opt) =>
(tableFilterState.product_id || [])
.map(String)
.includes(String(opt.value))
)}
onChange={productChangeHandler}
isLoading={isLoadingProducts}
isClearable
/>
</div>
<div className='grid md:grid-cols-3 grid-cols-1 gap-4'>
<SelectInput
label='Kategori Produk'
placeholder='Pilih Kategori Produk'
isMulti
options={productCategoryOptions}
value={productCategoryOptions.filter((opt) =>
(tableFilterState.product_category_id || [])
.map(String)
.includes(String(opt.value))
)}
onChange={productCategoryChangeHandler}
isLoading={isLoadingProductCategories}
isClearable
/>
<div className='md:flex md:flex-row grid grid-cols-1 gap-4'>
<SelectInput
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
options={dataTypeOptions}
value={
dataTypeOptions?.find(
(option) => option.value === tableFilterState.filter_by
) || null
}
onChange={dataTypeChangeHandler}
isLoading={false}
isClearable={false}
/>
<SelectInput
label='Urutkan Berdasarkan'
placeholder='Urutkan Berdasarkan'
options={sortByOptions}
value={
sortByOptions?.find(
(option) => option.value === tableFilterState.sort_by
) || null
}
onChange={sortByHandler}
isLoading={false}
isClearable={false}
/>
</div>
<div className='md:flex md:flex-row grid grid-cols-1 gap-4'>
<DateInput
label='Tanggal Awal'
name='start_date'
placeholder='Pilih Tanggal Awal'
value={tableFilterState.start_date}
onChange={startDateChangeHandler}
/>
<DateInput
label='Tanggal Akhir'
name='end_date'
placeholder='Pilih Tanggal Akhir'
value={tableFilterState.end_date}
onChange={endDateChangeHandler}
/>
</div>
</div>
{!isSubmitted ? (
<div className='mt-6 text-center text-gray-500'>
Silakan pilih filter dan klik tombol Submit untuk menampilkan data.
</div>
) : isLoading ? (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
) : data.length === 0 ? (
<div className='mt-6 text-center text-gray-500'>
Tidak ada data yang dapat ditampilkan...
</div>
) : (
data.map((supplierReport) => {
const summary = supplierReport.summary || {
total_qty: 0,
total_unit_price: 0,
total_purchase_value: 0,
total_transport_unit_price: 0,
total_transport_value: 0,
total_amount: 0,
};
const totalPurchase = summary.total_amount;
const tableColumns = getTableColumns(summary);
return (
<Card
key={supplierReport.supplier.id}
title={supplierReport.supplier.name}
subtitle={`Total Pembelian: ${formatCurrency(totalPurchase)}`}
className={{ wrapper: 'w-full' }}
variant='bordered'
collapsible={true}
>
<Table
data={supplierReport.rows}
columns={tableColumns}
pageSize={10}
renderFooter={supplierReport.rows.length > 0}
className={{
containerClassName: 'w-full',
tableWrapperClassName: 'overflow-x-auto mt-4',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200',
bodyRowClassName:
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
tableFooterClassName:
'bg-gray-100 font-semibold border border-gray-200',
footerRowClassName: 'border-t-2 border-gray-300',
footerColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
paginationClassName: 'hidden',
}}
/>
</Card>
);
})
)}
</Card>
{meta && data.length > 0 && (
<div className='mt-6'>
<Pagination
currentPage={meta.page}
totalItems={meta.total_results}
onPageChange={handlePageChange}
onRowChange={handleRowChange}
onNextPage={handleNextPage}
onPrevPage={handlePrevPage}
rowOptions={[10, 25, 50, 100]}
itemsPerPage={meta.limit}
/>
</div>
)}
</div>
);
};
export default PurchasesPerSupplierTab;
@@ -0,0 +1,37 @@
'use client';
import Tabs from '@/components/Tabs';
import HppPerKandangTab from '@/components/pages/report/sale/tab/HppPerKandangTab';
const SaleReportTabs = () => {
const tabs = [
// {
// id: '1',
// label: 'Penjualan Harian',
// content: 'Penjualan Harian Tab',
// },
// {
// id: '2',
// label: 'Transaksi Penjualan DO',
// content: 'Transaksi Penjualan DO Tab',
// },
// {
// id: '3',
// label: 'Perbandingan HPP Per Rentang BW',
// content: 'Perbandingan HPP Per Rentang BW Tab',
// },
{
id: '4',
label: 'HPP Harian Kandang',
content: <HppPerKandangTab />,
},
];
return (
<section className='w-full p-4'>
<Tabs tabs={tabs} variant='lifted' />
</section>
);
};
export default SaleReportTabs;
@@ -0,0 +1,497 @@
'use client';
import {
Page,
Text,
View,
Document,
StyleSheet,
Font,
pdf,
} from '@react-pdf/renderer';
import {
HppPerKandangReport,
HppPerKandangRow,
HppPerKandangPerWeightRange,
} from '@/types/api/report/hpp-per-kandang';
import { formatDate, formatNumber, formatCurrency } from '@/lib/helper';
Font.register({
family: 'Helvetica',
src: 'helvetica',
});
const pdfStyles = StyleSheet.create({
page: {
fontSize: 10,
fontFamily: 'Helvetica',
padding: 20,
backgroundColor: '#FFFFFF',
},
titleSection: {
marginBottom: 10,
},
mainTitle: {
fontSize: 14,
fontWeight: 'bold',
marginBottom: 5,
color: '#1f74bf',
},
supplierTitle: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 8,
color: '#1f74bf',
},
table: {
borderWidth: 1,
borderColor: '#000000',
marginBottom: 15,
},
tableRow: {
flexDirection: 'row',
},
tableHeader: {
backgroundColor: '#F5F5F5',
},
tableCell: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
textAlign: 'left',
},
tableCellHeader: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
paddingVertical: 12,
textAlign: 'center',
},
tableCellHeaderRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
textAlign: 'right',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
paddingVertical: 12,
},
tableCellRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
textAlign: 'right',
},
tableCellCenter: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
textAlign: 'center',
},
tableBorderBottom: {
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
},
supplierSection: {
marginBottom: 10,
},
supplierSectionBreak: {
marginBottom: 15,
},
parameterBadge: {
backgroundColor: '#F5F5F5',
color: '#333333',
padding: 4,
borderRadius: 4,
fontSize: 8,
marginRight: 8,
marginBottom: 4,
},
parameterContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: 8,
},
});
interface HppPerKandangExportParams {
data: HppPerKandangReport;
params: {
area_name?: string;
location_name?: string;
kandang_name?: string;
period?: string;
weight_min?: string;
weight_max?: string;
show_unrecorded?: string;
sort_by?: string;
};
}
const getParameterText = (params: HppPerKandangExportParams['params']) => {
const paramsText = [];
if (params.area_name && params.area_name !== 'Semua Area') {
paramsText.push(`Area: ${params.area_name}`);
}
if (params.location_name && params.location_name !== 'Semua Lokasi') {
paramsText.push(`Lokasi: ${params.location_name}`);
}
if (params.kandang_name && params.kandang_name !== 'Semua Kandang') {
paramsText.push(`Kandang: ${params.kandang_name}`);
}
if (params.period) {
const formattedDate = formatDate(params.period, 'DD MMM YYYY');
paramsText.push(`Tanggal: ${formattedDate}`);
}
if (params.weight_min || params.weight_max) {
const weightRange =
params.weight_min && params.weight_max
? `${params.weight_min} - ${params.weight_max} kg`
: params.weight_min
? `${params.weight_min} kg`
: `${params.weight_max} kg`;
paramsText.push(`Rentang Bobot: ${weightRange}`);
}
if (params.show_unrecorded === 'true') {
paramsText.push('Tampilkan: Tanpa Recording');
}
const currentDate = formatDate(new Date().toISOString(), 'DD MMM YYYY HH:mm');
paramsText.push(`Dicetak: ${currentDate}`);
return paramsText;
};
const createPDFDocument = (
data: HppPerKandangExportParams['data'],
params: HppPerKandangExportParams['params']
) => {
const rekapitulasiByWeightRange = data.summary?.per_weight_range || [];
return (
<Document>
<Page size='A3' orientation='landscape' style={pdfStyles.page}>
{/* Title and Parameters */}
<View style={pdfStyles.titleSection}>
<Text style={pdfStyles.mainTitle}>
Laporan &gt; HPP Harian Kandang
</Text>
<View style={pdfStyles.parameterContainer}>
{getParameterText(params).map((param, index) => (
<View key={index} style={pdfStyles.parameterBadge}>
<Text>{param}</Text>
</View>
))}
</View>
</View>
{/* Rekapitulasi Section */}
<View style={pdfStyles.supplierSection}>
<Text style={pdfStyles.supplierTitle}>Rekapitulasi</Text>
<View style={pdfStyles.table}>
{/* Table Header */}
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>Rentang BW</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Sisa Ekor</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Sisa Kg</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Rata-Rata Bobot (Kg)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Produksi Telur (Butir)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Produksi Telur (Kg)</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.5 }]}>
<Text>Feed (Supplier)</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>DOC (Supplier)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Rata-Rata Harga DOC</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Nilai Nominal Telur</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>HPP Ayam</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>HPP Telur (RP/KG)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Nominal Sisa</Text>
</View>
</View>
{/* Table Body - Rekapitulasi */}
{rekapitulasiByWeightRange.map(
(group: HppPerKandangPerWeightRange, index: number) => (
<View
key={index}
style={[
pdfStyles.tableRow,
index < rekapitulasiByWeightRange.length - 1
? pdfStyles.tableBorderBottom
: {},
]}
>
<View style={[pdfStyles.tableCellCenter, { flex: 1.2 }]}>
<Text>{group.label}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatNumber(group.remaining_chicken_birds)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>
{formatNumber(group.remaining_chicken_weight_kg)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatNumber(group.avg_weight_kg)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatNumber(group.egg_production_pieces)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatNumber(group.egg_production_kg)}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text>
{group.feed_suppliers
?.map(
(s: { alias?: string; name: string }) =>
s.alias || s.name
)
.join(' | ') || '-'}
</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<Text>
{group.doc_suppliers
?.map(
(s: { alias?: string; name: string }) =>
s.alias || s.name
)
.join(' | ') || '-'}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(group.average_doc_price_rp)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(group.egg_value_rp)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatCurrency(group.hpp_rp)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(group.egg_hpp_rp_per_kg)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(group.remaining_value_rp)}</Text>
</View>
</View>
)
)}
</View>
</View>
{/* Detail Per Kandang Section */}
<View style={pdfStyles.supplierSectionBreak}>
<Text style={pdfStyles.supplierTitle}>Detail Per Kandang</Text>
<View style={pdfStyles.table}>
{/* Table Header */}
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={[pdfStyles.tableCellHeader, { flex: 0.5 }]}>
<Text>No</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.5 }]}>
<Text>Kandang</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>Rentang BW</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Rata-Rata Bobot (Kg)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Sisa Ekor</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Sisa Kg (Ayam)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Produksi Telur (Butir)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Produksi Telur (Kg)</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>Feed (Supplier)</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>DOC (Supplier)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Rata-Rata Harga DOC</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Nilai Nominal Telur</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>HPP Ayam</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>HPP Telur (RP/KG)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Nominal Sisa</Text>
</View>
</View>
{/* Table Body - Detail Per Kandang */}
{data.rows.map((item: HppPerKandangRow, index: number) => (
<View
key={index}
style={[
pdfStyles.tableRow,
index < data.rows.length - 1
? pdfStyles.tableBorderBottom
: {},
]}
>
<View style={[pdfStyles.tableCellCenter, { flex: 0.5 }]}>
<Text>{index + 1}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text>{item.kandang?.name || '-'}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text>
{item.weight_range.weight_min.toFixed(2)} -{' '}
{item.weight_range.weight_max.toFixed(2)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatNumber(item.avg_weight_kg)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.remaining_chicken_birds)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.remaining_chicken_weight_kg)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.egg_production_pieces)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.egg_production_kg)}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<Text>
{item.feed_suppliers
?.map(
(s: { alias?: string; name: string }) =>
s.alias || s.name
)
.join(' | ')}
</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text>
{item.doc_suppliers
?.map(
(s: { alias?: string; name: string }) =>
s.alias || s.name
)
.join(' | ')}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.average_doc_price_rp)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.egg_value_rp)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatCurrency(item.hpp_rp)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatCurrency(item.egg_hpp_rp_per_kg)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.remaining_value_rp)}</Text>
</View>
</View>
))}
</View>
</View>
</Page>
</Document>
);
};
export const generateHppPerKandangPDF = async (
data: HppPerKandangExportParams['data'],
params: HppPerKandangExportParams['params']
): Promise<void> => {
const PDFDocument = createPDFDocument(data, params);
try {
const blob = await pdf(PDFDocument).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
const period = params.period || formatDate(new Date(), 'YYYY-MM-DD');
link.download = `laporan-hpp-harian-kandang-periode-${period}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
throw error;
}
};
@@ -0,0 +1,959 @@
import { useState, useMemo, useCallback } from 'react';
import { ChangeEventHandler } from 'react';
import useSWR from 'swr';
import Card from '@/components/Card';
import SelectInput, {
useSelect,
OptionType,
} from '@/components/input/SelectInput';
import DateInput from '@/components/input/DateInput';
import NumberInput from '@/components/input/NumberInput';
import { AreaApi } from '@/services/api/master-data';
import { LocationApi } from '@/services/api/master-data';
import { KandangApi } from '@/services/api/master-data';
import { SaleReportApi } from '@/services/api/report/marketing-sale';
import Table from '@/components/Table';
import { ColumnDef, Row, flexRender } from '@tanstack/react-table';
import { formatCurrency, formatNumber } from '@/lib/helper';
import {
HppPerKandangReport,
HppPerKandangRow,
HppPerKandangPerWeightRange,
} from '@/types/api/report/hpp-per-kandang';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import Button from '@/components/Button';
import Dropdown from '@/components/Dropdown';
import MenuItem from '@/components/menu/MenuItem';
import Menu from '@/components/menu/Menu';
import { generateHppPerKandangPDF } from '../export/HppPerkandangExport';
import toast from 'react-hot-toast';
import * as XLSX from 'xlsx';
import { Icon } from '@iconify/react';
const HppPerKandangTab = () => {
// ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
// ===== SUBMISSION STATE =====
const [isSubmitted, setIsSubmitted] = useState(false);
// ===== TABLE FILTER STATE =====
const { state: tableFilterState, updateFilter } = useTableFilter({
initial: {
area_id: [] as string[],
location_id: [] as string[],
kandang_id: [] as string[],
weight_min: '',
weight_max: '',
period: '',
sort_by: '',
show_unrecorded: false,
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
});
const { options: areaOptions, isLoadingOptions: isLoadingAreas } = useSelect(
AreaApi.basePath,
'id',
'name',
'search'
);
const { options: locationOptions, isLoadingOptions: isLoadingLocations } =
useSelect(LocationApi.basePath, 'id', 'name', 'search');
const { options: kandangOptions, isLoadingOptions: isLoadingKandangs } =
useSelect(KandangApi.basePath, 'id', 'name', 'search');
const showUnrecordedOptions: OptionType[] = [
{ value: 'false', label: 'Sembunyikan' },
{ value: 'true', label: 'Tampilkan' },
];
const areaChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const arr = Array.isArray(val) ? val : val ? [val] : [];
updateFilter(
'area_id',
arr.map((v) => String((v as OptionType).value))
);
setIsSubmitted(false);
},
[updateFilter]
);
const locationChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const arr = Array.isArray(val) ? val : val ? [val] : [];
updateFilter(
'location_id',
arr.map((v) => String((v as OptionType).value))
);
setIsSubmitted(false);
},
[updateFilter]
);
const kandangChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const arr = Array.isArray(val) ? val : val ? [val] : [];
updateFilter(
'kandang_id',
arr.map((v) => String((v as OptionType).value))
);
setIsSubmitted(false);
},
[updateFilter]
);
const weightMinChangeHandler = useCallback<
ChangeEventHandler<HTMLInputElement>
>(
(e) => {
const val = e.target.value;
updateFilter('weight_min', val ? String(parseFloat(val) || 0) : '');
setIsSubmitted(false);
},
[updateFilter]
);
const weightMaxChangeHandler = useCallback<
ChangeEventHandler<HTMLInputElement>
>(
(e) => {
const val = e.target.value;
updateFilter('weight_max', val ? String(parseFloat(val) || 0) : '');
setIsSubmitted(false);
},
[updateFilter]
);
const periodChangeHandler = useCallback<ChangeEventHandler<HTMLInputElement>>(
(e) => {
const val = e.target.value;
updateFilter('period', val || '');
setIsSubmitted(false);
},
[updateFilter]
);
const showUnrecordedChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
updateFilter('show_unrecorded', newVal?.value === 'true');
setIsSubmitted(false);
},
[updateFilter]
);
const resetFilters = useCallback(() => {
updateFilter('area_id', []);
updateFilter('location_id', []);
updateFilter('kandang_id', []);
updateFilter('weight_min', '');
updateFilter('weight_max', '');
updateFilter('period', '');
updateFilter('sort_by', '');
updateFilter('show_unrecorded', false);
setIsSubmitted(false);
}, [updateFilter]);
const handleSubmit = useCallback(() => {
if (!tableFilterState.period) {
toast.error('Periode wajib diisi');
return;
}
setIsSubmitted(true);
}, [tableFilterState.period]);
// ===== DATA FETCHING =====
const { data: hppPerKandang, isLoading } = useSWR(
isSubmitted
? () => {
const params = {
area_id:
tableFilterState.area_id.length > 0
? tableFilterState.area_id.join(',')
: undefined,
location_id:
tableFilterState.location_id.length > 0
? tableFilterState.location_id.join(',')
: undefined,
kandang_id:
tableFilterState.kandang_id.length > 0
? tableFilterState.kandang_id.join(',')
: undefined,
weight_min: tableFilterState.weight_min || undefined,
weight_max: tableFilterState.weight_max || undefined,
period: tableFilterState.period || undefined,
sort_by: tableFilterState.sort_by || undefined,
show_unrecorded: tableFilterState.show_unrecorded,
};
return ['hpp-per-kandang-report', params];
}
: null,
([, params]) =>
SaleReportApi.getHppPerKandangReport(
params.area_id,
params.location_id,
params.kandang_id,
params.weight_min,
params.weight_max,
params.period,
params.sort_by,
params.show_unrecorded
)
);
const data: HppPerKandangReport['rows'] = useMemo(
() =>
isResponseSuccess(hppPerKandang)
? (hppPerKandang?.data?.rows as HppPerKandangReport['rows']) || []
: [],
[hppPerKandang]
);
const summaryTotal =
isResponseSuccess(hppPerKandang) && hppPerKandang?.data?.summary?.total
? hppPerKandang.data.summary.total
: undefined;
const perWeightRangeSummary = useMemo(
() =>
isResponseSuccess(hppPerKandang) &&
hppPerKandang?.data?.summary?.per_weight_range
? hppPerKandang.data.summary.per_weight_range
: [],
[hppPerKandang]
);
const period =
isResponseSuccess(hppPerKandang) && hppPerKandang?.data?.period
? hppPerKandang.data.period
: undefined;
// ===== EXPORT DATA FETCHER =====
const hppPerKandangExport =
useCallback(async (): Promise<HppPerKandangReport | null> => {
const params = {
area_id:
tableFilterState.area_id.length > 0
? tableFilterState.area_id.join(',')
: undefined,
location_id:
tableFilterState.location_id.length > 0
? tableFilterState.location_id.join(',')
: undefined,
kandang_id:
tableFilterState.kandang_id.length > 0
? tableFilterState.kandang_id.join(',')
: undefined,
weight_min: tableFilterState.weight_min || undefined,
weight_max: tableFilterState.weight_max || undefined,
period: tableFilterState.period || undefined,
sort_by: tableFilterState.sort_by || undefined,
show_unrecorded: tableFilterState.show_unrecorded,
limit: 10000,
page: 1,
};
const response = await SaleReportApi.getHppPerKandangReport(
params.area_id,
params.location_id,
params.kandang_id,
params.weight_min,
params.weight_max,
params.period,
params.sort_by,
params.show_unrecorded
);
return isResponseSuccess(response) ? response.data : null;
}, [tableFilterState]);
// ===== TABLE COLUMNS DEFINITION =====
const allFeedSuppliers = useMemo(() => {
const suppliers = new Set<string>();
data.forEach((item: HppPerKandangRow) => {
item.feed_suppliers?.forEach((s: { alias?: string; name: string }) => {
suppliers.add(s.alias || s.name);
});
});
return Array.from(suppliers).join(' | ');
}, [data]);
const allDocSuppliers = useMemo(() => {
const suppliers = new Set<string>();
data.forEach((item: HppPerKandangRow) => {
item.doc_suppliers?.forEach((s: { alias?: string; name: string }) => {
suppliers.add(s.alias || s.name);
});
});
return Array.from(suppliers).join(' | ');
}, [data]);
// ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
const allDataForExport = await hppPerKandangExport();
if (
!allDataForExport ||
!allDataForExport?.rows ||
allDataForExport.rows.length === 0
) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
const allExportData =
allDataForExport.rows as HppPerKandangReport['rows'];
const summaryTotal = allDataForExport.summary.total;
const excelData: { [key: string]: string | number }[] = allExportData.map(
(item: HppPerKandangRow, index: number) => ({
No: index + 1,
Kandang: item.kandang?.name || '',
'Rentang Bobot': item.weight_range
? `${formatNumber(item.weight_range.weight_min)} - ${formatNumber(item.weight_range.weight_max)}`
: '',
'Rata-Rata Bobot (KG)': item.avg_weight_kg || 0,
'Sisa Ayam (Ekor)': item.remaining_chicken_birds || 0,
'Sisa Ayam (KG)': item.remaining_chicken_weight_kg || 0,
'Produksi Telur (Butir)': item.egg_production_pieces || 0,
'Produksi Telur (KG)': item.egg_production_kg || 0,
'Feed (Supplier)':
item.feed_suppliers
?.map((s: { alias?: string; name: string }) => s.alias || s.name)
.join(' | ') || '',
'DOC (Supplier)':
item.doc_suppliers
?.map((s: { alias?: string; name: string }) => s.alias || s.name)
.join(' | ') || '',
'Rata-Rata Harga DOC (RP)': item.average_doc_price_rp || 0,
'Nilai Nominal Telur (RP)': item.egg_value_rp || 0,
'HPP Ayam (RP)': item.hpp_rp || 0,
'HPP Telur (RP/KG)': item.egg_hpp_rp_per_kg || 0,
'Nilai Nominal Sisa Ayam (RP)': item.remaining_value_rp || 0,
})
);
excelData.push({
No: 'TOTAL',
Kandang: 'ALL',
'Rentang Bobot': '-',
'Rata-Rata Bobot (KG)': summaryTotal?.average_weight_kg || 0,
'Sisa Ayam (Ekor)': summaryTotal?.total_remaining_chicken_birds || 0,
'Sisa Ayam (KG)': summaryTotal?.total_remaining_chicken_weight_kg || 0,
'Produksi Telur (Butir)':
summaryTotal?.total_egg_production_pieces || 0,
'Produksi Telur (KG)': summaryTotal?.total_egg_production_kg || 0,
'Feed (Supplier)': allFeedSuppliers,
'DOC (Supplier)': allDocSuppliers,
'Rata-Rata Harga DOC (RP)':
summaryTotal?.total_average_doc_price_rp || 0,
'Nilai Nominal Telur (RP)': summaryTotal?.total_egg_value_rp || 0,
'HPP Ayam (RP)': summaryTotal?.total_hpp_rp || 0,
'HPP Telur (RP/KG)': summaryTotal?.average_egg_hpp_rp_per_kg || 0,
'Nilai Nominal Sisa Ayam (RP)':
summaryTotal?.total_remaining_value_rp || 0,
});
const worksheet = XLSX.utils.json_to_sheet(excelData);
const colWidths = [
{ wch: 5 }, // No
{ wch: 30 }, // Kandang
{ wch: 15 }, // Rentang Bobot
{ wch: 18 }, // Rata-Rata Bobot (KG)
{ wch: 15 }, // Sisa Ayam (Ekor)
{ wch: 15 }, // Sisa Ayam (KG)
{ wch: 18 }, // Produksi Telur (Butir)
{ wch: 18 }, // Produksi Telur (KG)
{ wch: 20 }, // Feed (Supplier)
{ wch: 20 }, // DOC (Supplier)
{ wch: 20 }, // Rata-Rata Harga DOC (RP)
{ wch: 20 }, // Nilai Nominal Telur (RP)
{ wch: 15 }, // HPP Ayam (RP)
{ wch: 18 }, // HPP Telur (RP/KG)
{ wch: 25 }, // Nilai Nominal Sisa Ayam (RP)
];
worksheet['!cols'] = colWidths;
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'HPP Per Kandang');
const filename = `laporan-hpp-harian-kandang-periode-${tableFilterState.period}.xlsx`;
XLSX.writeFile(workbook, filename);
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally {
setIsExcelExportLoading(false);
}
}, [
hppPerKandangExport,
tableFilterState,
areaOptions,
locationOptions,
kandangOptions,
]);
const handleExportPDF = useCallback(async () => {
setIsPdfExportLoading(true);
try {
const allDataForExport = await hppPerKandangExport();
if (
!allDataForExport ||
!allDataForExport?.rows ||
allDataForExport.rows.length === 0
) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
const areaName =
tableFilterState.area_id.length > 0
? tableFilterState.area_id
.map(
(id) =>
areaOptions.find((opt) => opt.value === Number(id))?.label
)
.filter(Boolean)
.join(', ') || 'Semua Area'
: 'Semua Area';
const locationName =
tableFilterState.location_id.length > 0
? tableFilterState.location_id
.map(
(id) =>
locationOptions.find((opt) => opt.value === Number(id))?.label
)
.filter(Boolean)
.join(', ') || 'Semua Lokasi'
: 'Semua Lokasi';
const kandangName =
tableFilterState.kandang_id.length > 0
? tableFilterState.kandang_id
.map(
(id) =>
kandangOptions.find((opt) => opt.value === Number(id))?.label
)
.filter(Boolean)
.join(', ') || 'Semua Kandang'
: 'Semua Kandang';
await generateHppPerKandangPDF(allDataForExport, {
area_name: areaName,
location_name: locationName,
kandang_name: kandangName,
period: tableFilterState.period,
weight_min: tableFilterState.weight_min,
weight_max: tableFilterState.weight_max,
show_unrecorded: tableFilterState.show_unrecorded.toString(),
sort_by: tableFilterState.sort_by,
});
toast.success('PDF berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat PDF. Silakan coba lagi.');
} finally {
setIsPdfExportLoading(false);
}
}, [
hppPerKandangExport,
tableFilterState,
areaOptions,
locationOptions,
kandangOptions,
]);
const getTableColumns = (): ColumnDef<HppPerKandangReport['rows'][0]>[] => {
const tableColumns: ColumnDef<HppPerKandangReport['rows'][0]>[] = [
{
id: 'no',
header: 'No',
cell: (props) => props.row.index + 1,
footer: () => <div className='font-semibold text-gray-900'>TOTAL</div>,
},
{
id: 'kandang_name',
header: 'Kandang',
accessorKey: 'kandang.name',
cell: (props) => {
const kandang = props.row.original.kandang;
return kandang?.name || '-';
},
footer: () => <div className='font-semibold text-gray-900'>ALL</div>,
},
{
id: 'weight_range',
header: 'Rentang Bobot',
accessorKey: 'weight_range',
cell: (props) => {
const weightRange = props.row.original.weight_range;
return weightRange
? `${formatNumber(weightRange.weight_min)} - ${formatNumber(weightRange.weight_max)}`
: '-';
},
footer: () => <div className='font-semibold text-gray-900'>-</div>,
},
{
id: 'avg_weight_kg',
header: 'Rata-Rata Bobot (KG)',
accessorKey: 'avg_weight_kg',
cell: (props) => {
const value = props.row.original.avg_weight_kg;
return <div className='text-right'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatNumber(summaryTotal?.average_weight_kg || 0)}
</div>
),
},
{
id: 'remaining_chicken_birds',
header: 'Sisa Ayam (Ekor)',
accessorKey: 'remaining_chicken_birds',
cell: (props) => {
const value = props.row.original.remaining_chicken_birds;
return <div className='text-right'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatNumber(summaryTotal?.total_remaining_chicken_birds || 0)}
</div>
),
},
{
id: 'remaining_chicken_weight_kg',
header: 'Sisa Ayam (KG)',
accessorKey: 'remaining_chicken_weight_kg',
cell: (props) => {
const value = props.row.original.remaining_chicken_weight_kg;
return <div className='text-right'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatNumber(summaryTotal?.total_remaining_chicken_weight_kg || 0)}
</div>
),
},
{
id: 'egg_production_pieces',
header: 'Produksi Telur (Butir)',
accessorKey: 'egg_production_pieces',
cell: (props) => {
const value = props.row.original.egg_production_pieces;
return <div className='text-right'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatNumber(summaryTotal?.total_egg_production_pieces || 0)}
</div>
),
},
{
id: 'egg_production_kg',
header: 'Produksi Telur (KG)',
accessorKey: 'egg_production_kg',
cell: (props) => {
const value = props.row.original.egg_production_kg;
return <div className='text-right'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatNumber(summaryTotal?.total_remaining_chicken_weight_kg || 0)}
</div>
),
},
{
id: 'feed_suppliers',
header: 'Feed (Supplier)',
accessorKey: 'feed_suppliers',
cell: (props) => {
const suppliers = props.row.original.feed_suppliers;
return (
suppliers
?.map((s: { alias?: string; name: string }) => s.alias || s.name)
.join(' | ') || '-'
);
},
footer: () => (
<div className='font-semibold text-gray-900'>
{allFeedSuppliers || '-'}
</div>
),
},
{
id: 'doc_suppliers',
header: 'DOC (Supplier)',
accessorKey: 'doc_suppliers',
cell: (props) => {
const suppliers = props.row.original.doc_suppliers;
return (
suppliers
?.map((s: { alias?: string; name: string }) => s.alias || s.name)
.join(' | ') || '-'
);
},
footer: () => (
<div className='font-semibold text-gray-900'>
{allDocSuppliers || '-'}
</div>
),
},
{
id: 'average_doc_price_rp',
header: 'Rata-Rata Harga DOC (RP)',
accessorKey: 'average_doc_price_rp',
cell: (props) => {
const value = props.row.original.average_doc_price_rp;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summaryTotal?.total_average_doc_price_rp || 0)}
</div>
),
},
{
id: 'egg_value_rp',
header: 'Nilai Nominal Telur (RP)',
accessorKey: 'egg_value_rp',
cell: (props) => {
const value = props.row.original.egg_value_rp;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summaryTotal?.total_egg_value_rp || 0)}
</div>
),
},
{
id: 'hpp_rp',
header: 'HPP Ayam (RP)',
accessorKey: 'hpp_rp',
cell: (props) => {
const value = props.row.original.hpp_rp;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summaryTotal?.total_hpp_rp || 0)}
</div>
),
},
{
id: 'egg_hpp_rp_per_kg',
header: 'HPP Telur (RP/KG)',
accessorKey: 'egg_hpp_rp_per_kg',
cell: (props) => {
const value = props.row.original.egg_hpp_rp_per_kg;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summaryTotal?.average_egg_hpp_rp_per_kg || 0)}
</div>
),
},
{
id: 'remaining_value_rp',
header: 'Nilai Nominal Sisa Ayam (RP)',
accessorKey: 'remaining_value_rp',
cell: (props) => {
const value = props.row.original.remaining_value_rp;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summaryTotal?.total_remaining_value_rp || 0)}
</div>
),
},
];
return tableColumns;
};
// ===== CUSTOM ROW RENDERER =====
const renderCustomRow = useCallback(
(row: Row<HppPerKandangReport['rows'][0]>) => {
if (row.index === data.length - 1) {
const defaultRow = (
<tr
key={row.id}
className='hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200'
>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap border-gray-200'
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
);
const customRows = [
<tr
className='border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200'
key={'rekapitulasi-row'}
>
<td
colSpan={15}
className='px-4 py-3 text-gray-900 text-center font-semibold'
>
Rekapitulasi per rentang bobot
</td>
</tr>,
];
if (perWeightRangeSummary.length > 0) {
perWeightRangeSummary.forEach(
(item: HppPerKandangPerWeightRange, index = 0) => {
customRows.push(
<tr
key={`summary-${item.id}`}
className='hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200 [&_td]:px-4 [&_td]:py-3 [&_td]:text-xs [&_td]:text-gray-900 [&_td]:whitespace-nowrap'
>
<td className=''>{index + 1}</td>
<td className=''>ALL</td>
<td className=''>{item.label}</td>
<td className='text-right'>
{formatNumber(item.avg_weight_kg)}
</td>
<td className='text-right'>
{formatNumber(item.remaining_chicken_birds)}
</td>
<td className='text-right'>
{formatNumber(item.remaining_chicken_weight_kg)}
</td>
<td className='text-right'>
{formatNumber(item.egg_production_pieces)}
</td>
<td className='text-right'>
{formatNumber(item.egg_production_kg)}
</td>
<td className=''>
{item.feed_suppliers
?.map((s) => s.alias || s.name)
.join(' | ') || '-'}
</td>
<td className=''>
{item.doc_suppliers
?.map((s) => s.alias || s.name)
.join(' | ') || '-'}
</td>
<td className='text-right'>
{formatCurrency(item.average_doc_price_rp)}
</td>
<td className='text-right'>
{formatCurrency(item.egg_value_rp)}
</td>
<td className='text-right'>{formatCurrency(item.hpp_rp)}</td>
<td className='text-right'>
{formatCurrency(item.egg_hpp_rp_per_kg)}
</td>
<td className='text-right'>
{formatCurrency(item.remaining_value_rp)}
</td>
</tr>
);
}
);
}
return [defaultRow, ...customRows];
}
return null;
},
[data, perWeightRangeSummary]
);
return (
<div className='w-full p-0 sm:p-4'>
<Card
subtitle={
period
? `Laporan > HPP Harian Kandang (${period})`
: 'Laporan > HPP Harian Kandang'
}
className={{ wrapper: 'w-full', body: 'p-1!' }}
>
<div className='grid md:grid-cols-3 grid-cols-1 gap-4'>
<SelectInput
label='Area'
placeholder='Pilih Area'
isMulti
options={areaOptions}
value={areaOptions.filter((opt) =>
(tableFilterState.area_id || [])
.map(String)
.includes(String(opt.value))
)}
onChange={areaChangeHandler}
isLoading={isLoadingAreas}
isClearable
/>
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
isMulti
options={locationOptions}
value={locationOptions.filter((opt) =>
(tableFilterState.location_id || [])
.map(String)
.includes(String(opt.value))
)}
onChange={locationChangeHandler}
isLoading={isLoadingLocations}
isClearable
/>
<SelectInput
label='Kandang'
placeholder='Pilih Kandang'
isMulti
options={kandangOptions}
value={kandangOptions.filter((opt) =>
(tableFilterState.kandang_id || [])
.map(String)
.includes(String(opt.value))
)}
onChange={kandangChangeHandler}
isLoading={isLoadingKandangs}
isClearable
/>
</div>
<div className='grid md:grid-cols-3 grid-cols-1 gap-4'>
<div className='flex flex-row gap-4'>
<NumberInput
label='Rentang Bobot Min (Kg)'
name='weight_min'
placeholder='Masukkan bobot minimum'
value={tableFilterState.weight_min}
onChange={weightMinChangeHandler}
/>
<NumberInput
label='Rentang Bobot Max (Kg)'
name='weight_max'
placeholder='Masukkan bobot maximum'
value={tableFilterState.weight_max}
onChange={weightMaxChangeHandler}
/>
</div>
<DateInput
label='Periode'
name='period'
placeholder='Pilih Periode'
value={tableFilterState.period}
onChange={periodChangeHandler}
required
/>
<SelectInput
label='Tampilkan Kandang Tanpa Recording'
placeholder='Pilih Opsi'
options={showUnrecordedOptions}
value={
tableFilterState.show_unrecorded
? showUnrecordedOptions.find((opt) => opt.value === 'true') ||
null
: showUnrecordedOptions.find((opt) => opt.value === 'false') ||
null
}
onChange={showUnrecordedChangeHandler}
/>
</div>
<div className='mt-4 flex justify-end gap-2 [&_button]:px-4'>
<Button color='primary' onClick={handleSubmit}>
<Icon icon='heroicons:magnifying-glass' width={20} height={20} />
Cari
</Button>
<Button color='warning' onClick={resetFilters}>
<Icon icon='heroicons-outline:refresh' width={20} height={20} />
Reset
</Button>
<Dropdown
trigger={
<Button color='success' isLoading={isAnyExportLoading}>
Export
<Icon
icon='heroicons-outline:download'
width={20}
height={20}
/>
</Button>
}
align='end'
>
<Menu className='w-32'>
<MenuItem title='Excel' onClick={handleExportExcel} />
<MenuItem title='PDF' onClick={handleExportPDF} />
</Menu>
</Dropdown>
</div>
<div className='divider'></div>
{!isSubmitted ? (
<div className='mt-6 text-center text-gray-500'>
Silakan pilih filter dan klik tombol Cari untuk menampilkan data.
</div>
) : isLoading ? (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
) : data.length === 0 ? (
<div className='mt-6 text-center text-gray-500'>
Tidak ada data yang dapat ditampilkan...
</div>
) : (
<Table
data={data}
columns={getTableColumns()}
renderFooter={data.length > 0}
renderCustomRow={renderCustomRow}
className={{
containerClassName: 'w-full mt-6',
tableWrapperClassName: 'overflow-x-auto mt-4',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200',
bodyRowClassName:
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
tableFooterClassName:
'bg-gray-100 font-semibold border border-gray-200',
footerRowClassName: 'border-t-2 border-gray-300',
footerColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
}}
/>
)}
</Card>
</div>
);
};
export default HppPerKandangTab;
+4 -4
View File
@@ -16,10 +16,10 @@ export const PROJECT_FLOCK_APPROVAL_LINE: ApprovalLine = [
] as const;
export const PROJECT_FLOCK_KANDANGS_APPROVAL_LINE: ApprovalLine = [
{
step_number: 1,
step_name: 'Pengajuan',
},
// {
// step_number: 1,
// step_name: 'Pengajuan',
// },
{
step_number: 2,
step_name: 'Disetujui',
+10 -2
View File
@@ -48,8 +48,16 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
{
text: 'Laporan',
link: '/report',
icon: 'heroicons-outline:document-text',
icon: 'mdi:chart-box-outline',
submenu: [
{
text: 'Logistik & Persediaan',
link: '/report/logistic-stock',
},
{
text: 'Biaya Operasional',
link: '/report/expense',
},
{
text: 'Penjualan',
link: '/report/marketing',
@@ -62,7 +70,7 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
icon: 'heroicons-outline:folder',
submenu: [
{
text: 'Produk',
text: 'Stok Produk',
link: '/inventory/product',
},
{
+39
View File
@@ -83,6 +83,7 @@ import {
ClosingIncomingSapronak,
ClosingOutgoingSapronak,
ClosingOverhead,
ClosingProductionData,
ClosingSapronakCalculation,
} from '@/types/api/closing';
import { CreatedUser, BaseApiResponse } from '@/types/api/api-general';
@@ -1134,3 +1135,41 @@ export const dummyGetOverhead = async (
data: dummyOverhead,
};
};
export const dummyClosingProductionData: ClosingProductionData = {
purchase: {
initial_population: 12000,
claim_culling: 150,
final_population: 11850,
feed_in: 24000,
feed_used: 22500,
feed_used_per_head: 1.9,
},
sales: {
chicken: {
sales_population: 10500,
sales_weight: 21000,
average_weight: 2.0,
chicken_average_selling_price: 28500,
},
egg: {
egg_pieces: 185000,
egg_mass_kg: 9250,
average_egg_weight_kg: 0.05,
egg_average_selling_price: 1800,
},
},
performance: {
depletion: 150,
age_day: 35,
mortality_std: 3.5,
mortality_act: 4.2,
deff_mortality: 0.7,
fcr_std: 1.6,
fcr_act: 1.72,
deff_fcr: 0.12,
awg: 60,
},
};
-388
View File
@@ -1,388 +0,0 @@
import { format } from 'date-fns';
import { Area } from '@/types/api/master-data/area';
import { Location } from '@/types/api/master-data/location';
import { Kandang } from '@/types/api/master-data/kandang';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import {
BaseMarketing,
Marketing,
BaseSalesOrder,
BaseDeliveryOrder,
BaseDelivery,
} from '@/types/api/marketing/marketing';
import {
CreatedUser,
BaseApproval,
BaseMetadata,
} from '@/types/api/api-general';
import { Product } from '@/types/api/master-data/product';
import { Customer } from '@/types/api/master-data/customer';
import { Uom } from '@/types/api/master-data/uom';
import { ProductCategory } from '@/types/api/master-data/product-category';
import { Supplier } from '@/types/api/master-data/supplier';
// Waktu saat ini untuk created_at/updated_at
const now = format(new Date(), 'yyyy-MM-dd HH:mm:ss');
const today = format(new Date(), 'yyyy-MM-dd');
const tomorrow = format(
new Date().setDate(new Date().getDate() + 1),
'yyyy-MM-dd'
);
// ======================
// 👤 Created User & Helper Data
// ======================
export const createdUser: CreatedUser = {
id: 1,
id_user: 1,
email: 'admin@example.com',
name: 'Admin Utama',
};
const dummyProductBase: Product = {
id: 101,
name: 'Pakan Ayam Premium',
brand: 'Brand Hebat',
sku: 'PAK-001',
product_price: 15000,
selling_price: 18000,
tax: 0.1,
expiry_period: 365,
uom: { id: 1, name: 'Sak' } as Uom,
product_category: { id: 1, name: 'Pakan' } as ProductCategory,
suppliers: [{ id: 1, name: 'Supplier A' } as Supplier],
flags: ['PAKAN'],
created_user: createdUser,
created_at: now,
updated_at: now,
};
// ======================
// 📍 Area Dummy
// ======================
export const dummyAreas: Area[] = [
{
id: 1,
name: 'Bandung Barat',
created_user: createdUser,
created_at: now,
updated_at: now,
},
{
id: 2,
name: 'Cimahi Utara',
created_user: createdUser,
created_at: now,
updated_at: now,
},
];
// ======================
// 🏢 Location Dummy
// ======================
export const dummyLocations: Location[] = [
{
id: 1,
name: 'Gudang A',
address: 'Jl. Sukajadi No. 12',
area: dummyAreas[0],
created_user: createdUser,
created_at: now,
updated_at: now,
},
{
id: 2,
name: 'Gudang B',
address: 'Jl. Setiabudi No. 45',
area: dummyAreas[1],
created_user: createdUser,
created_at: now,
updated_at: now,
},
];
// ======================
// 🐔 Kandang Dummy
// ======================
export const dummyKandangs: Kandang[] = [
{
id: 1,
name: 'Kandang Ayam Layer 1',
status: 'AKTIF',
capacity: 500,
location: dummyLocations[0],
pic: createdUser,
created_user: createdUser,
created_at: now,
updated_at: now,
},
{
id: 2,
name: 'Kandang Ayam Broiler 2',
status: 'NONAKTIF',
capacity: 300,
location: dummyLocations[1],
pic: createdUser,
created_user: createdUser,
created_at: now,
updated_at: now,
},
];
// ======================
// 🏭 Warehouse Dummy
// ======================
export const dummyWarehouses: Warehouse[] = [
{
id: 1,
type: 'AREA',
name: 'Gudang Wilayah Bandung Barat',
area: dummyAreas[0],
created_user: createdUser,
created_at: now,
updated_at: now,
} as Warehouse,
{
id: 2,
type: 'LOKASI',
name: 'Gudang Produksi Sukajadi',
area: dummyAreas[0],
location: { ...dummyLocations[0], area: dummyAreas[0] },
created_user: createdUser,
created_at: now,
updated_at: now,
} as Warehouse,
{
id: 3,
type: 'KANDANG',
name: 'Gudang Kandang Layer 1',
area: dummyAreas[0],
location: { ...dummyLocations[0], area: dummyAreas[0] },
kandang: {
...dummyKandangs[0],
location: dummyLocations[0],
pic: createdUser,
},
created_user: createdUser,
created_at: now,
updated_at: now,
} as Warehouse,
];
// ======================
// 📦 Product Warehouse Dummy
// ======================
export const dummyProductWarehouses: ProductWarehouse[] = [
{
id: 1,
product_id: 101,
warehouse_id: 1,
quantity: 1000,
product: dummyProductBase,
warehouse: dummyWarehouses[0],
created_user: createdUser,
created_at: now,
updated_at: now,
},
{
id: 2,
product_id: 102,
warehouse_id: 2,
quantity: 500,
product: {
...dummyProductBase,
id: 102,
name: 'Vitamin Ayam Super',
sku: 'VIT-002',
flags: ['VITAMIN'],
selling_price: 25000,
},
warehouse: dummyWarehouses[1],
created_user: createdUser,
created_at: now,
updated_at: now,
},
];
// ======================
// 💼 Marketing Dummy
// ======================
// Helper untuk Sales Order (SO) Item
const soItem1: BaseSalesOrder = {
vehicle_number: 'B 1234 ABC',
id: 101,
marketing_id: 1,
product_warehouse_id: 1,
qty: 100,
unit_price: 18000, // Harga jual
avg_weight: 1.0,
total_weight: 100 * 1.0,
total_price: 100 * 18000,
product_warehouse: dummyProductWarehouses[0] as ProductWarehouse,
};
const soItem2: BaseSalesOrder = {
vehicle_number: 'D 5678 EFG',
id: 102,
marketing_id: 2,
product_warehouse_id: 2,
qty: 50,
unit_price: 25000,
avg_weight: 0.5,
total_weight: 50 * 0.5,
total_price: 50 * 25000,
product_warehouse: dummyProductWarehouses[1] as ProductWarehouse,
};
// Helper untuk Delivery Item (DO) Detail
const doDelivery1: BaseDelivery[] = [
{
product_warehouse: dummyProductWarehouses[0] as ProductWarehouse,
qty: soItem1.qty,
unit_price: soItem1.unit_price,
total_weight: soItem1.total_weight,
avg_weight: soItem1.avg_weight,
total_price: soItem1.total_price,
vehicle_number: 'B 1234 ABC',
},
];
const doDelivery2: BaseDelivery[] = [
{
product_warehouse: dummyProductWarehouses[1] as ProductWarehouse,
qty: soItem2.qty,
unit_price: soItem2.unit_price,
total_weight: soItem2.total_weight,
avg_weight: soItem2.avg_weight,
total_price: soItem2.total_price,
vehicle_number: 'D 5678 EFG',
},
];
// Helper untuk Delivery Order (DO) Header
const deliveryOrder1: BaseDeliveryOrder[] = [
{
id: 1,
marketing_id: 3,
do_number: 'DO-003-2025',
delivery_date: tomorrow,
warehouse: dummyWarehouses[0],
deliveries: doDelivery1,
},
];
export const dummyMarketings: Marketing[] = [
// 1. Pengajuan Order (Langkah Pertama/Awal)
{
id: 1,
status: 'DRAFT',
// name: 'SO-001-2025', // `name` is not part of BaseMarketing
so_number: 'SO-001-2025',
so_date: today,
customer: {
id: 1,
name: 'PT Maju Jaya',
pic_id: 1,
pic: createdUser,
type: 'Distributor',
address: 'Jl. Merdeka No. 1',
phone: '081212121212',
email: 'contact@majujaya.com',
account_number: '1234567890',
created_user: createdUser,
created_at: now,
updated_at: now,
} as Customer,
sales_person: createdUser,
notes: 'Pengajuan Order Awal, menunggu persetujuan harga.',
latest_approval: {
step_number: 1,
step_name: 'Pengajuan Order',
action: 'CREATED',
action_by: createdUser,
action_at: now,
} as BaseApproval,
sales_order: [soItem1],
delivery_order: [],
created_user: createdUser,
created_at: now,
updated_at: now,
} as Marketing,
// 2. Sales Order (Disetujui dan Siap DO)
{
id: 2,
status: 'APPROVED',
// name: 'SO-002-2025', // `name` is not part of BaseMarketing
so_number: 'SO-002-2025',
so_date: today,
customer: {
id: 2,
name: 'CV Sumber Sehat',
pic_id: 2,
pic: createdUser,
type: 'Retail',
address: 'Jl. Cihampelas No. 5',
phone: '082222222222',
email: 'info@sumbersehat.com',
account_number: '9876543210',
created_user: createdUser,
created_at: now,
updated_at: now,
} as Customer,
sales_person: createdUser,
notes: 'Sales Order telah disetujui oleh Supervisor.',
latest_approval: {
id: 2,
step_number: 2,
step_name: 'Sales Order',
action: 'APPROVED',
action_by: createdUser,
action_at: now,
} as BaseApproval,
sales_order: [soItem2],
delivery_order: [], // Belum ada pengiriman (DO) yang dibuat
created_user: createdUser,
created_at: now,
updated_at: now,
} as Marketing,
// 3. Delivery Order (Proses Pengiriman telah dibuat)
{
id: 3,
status: 'DELIVERED', // Asumsi status DELIVERED berarti DO sudah selesai/terbuat
// name: 'SO-003-2025', // `name` is not part of BaseMarketing
so_number: 'SO-003-2025',
so_date: today,
customer: {
id: 3,
name: 'UD Ternak Sejahtera',
pic_id: 3,
pic: createdUser,
type: 'Reseller',
address: 'Jl. Pasteur No. 88',
phone: '083333333333',
email: 'halo@ternaksejahtera.com',
account_number: '1122334455',
created_user: createdUser,
created_at: now,
updated_at: now,
} as Customer,
sales_person: createdUser,
notes: 'Pengiriman barang telah berhasil dilakukan.',
latest_approval: {
id: 3,
step_number: 3,
step_name: 'Delivery Order',
action: 'COMPLETED',
action_by: createdUser,
action_at: now,
} as BaseApproval,
sales_order: [soItem1, soItem2],
delivery_order: deliveryOrder1, // DO sudah terbuat
created_user: createdUser,
created_at: now,
updated_at: now,
} as Marketing,
];
+53 -137
View File
@@ -3,14 +3,20 @@ import axios from 'axios';
import { BaseApiService } from '@/services/api/base';
import {
Closing,
ClosingFinance,
ClosingGeneralInformation,
ClosingIncomingSapronak,
ClosingOutgoingSapronak,
ClosingProductionData,
ClosingOverhead,
ClosingSapronakCalculation,
ClosingProductionData,
ClosingHppExpedition,
} from '@/types/api/closing';
import { BaseApiResponse } from '@/types/api/api-general';
import { httpClient, httpClientFetcher } from '@/services/http/client';
import { ClosingSales } from '@/types/api/closing';
// TODO: delete these dummy data later
import {
dummyGetAllFetcher,
dummyGetSingle,
@@ -19,93 +25,15 @@ import {
dummyGetGeneralInfo,
dummyGetPerhitunganSapronak,
dummyGetOverhead,
dummyClosingProductionData,
} from '@/dummy/closing.dummy';
import { httpClient, httpClientFetcher } from '@/services/http/client';
import { ClosingSales } from '@/types/api/closing';
import { sleep } from '@/lib/helper';
export const dummyClosingProductionResponse: BaseApiResponse<ClosingProductionData> =
{
code: 200,
status: 'success',
message: 'Closing production data fetched successfully',
data: {
purchase: {
initial_population: 10_000,
claim_culling: 150,
final_population: 9_850,
feed_in_kg: 18_000,
feed_used_kg: 17_200,
feed_used_per_head_kg: 1.75,
},
sales: {
sales_kg: 18_500,
sales_head: 9_600,
average_weight_kg: 1.93,
average_price_per_kg: 20_500,
},
performance: {
depletion_head: 400,
depletion_percentage: 4,
age_days: 35,
mortality_std: 3.5,
mortality_act: 4,
deff_mortality: 0.5,
fcr_std: 1.6,
fcr_act: 1.72,
deff_fcr: 0.12,
adg: 55,
ip: 320,
},
variance: {
variance_head: -250,
variance_head_percentage: -2.5,
variance_feed_kg: -800,
},
},
};
export class ClosingApiService extends BaseApiService<Closing, null, null> {
constructor(basePath: string) {
super(basePath);
}
async getAllFetcher(endpoint: string): Promise<BaseApiResponse<Closing[]>> {
// TODO: Remove this block when backend is ready
// return await dummyGetAllFetcher();
// Uncomment this when backend is ready
return await httpClientFetcher<BaseApiResponse<Closing[]>>(endpoint);
}
async getSingle(id: number): Promise<BaseApiResponse<Closing> | undefined> {
// TODO: Remove this block when backend is ready
// try {
// return await dummyGetSingle(id);
// } catch (error) {
// if (axios.isAxiosError<BaseApiResponse<Closing>>(error)) {
// return error.response?.data;
// }
// return undefined;
// }
// Uncomment this when backend is ready
try {
const getSinglePath = `${this.basePath}/${id}`;
const getSingleRes =
await httpClient<BaseApiResponse<Closing>>(getSinglePath);
return getSingleRes;
} catch (error) {
if (axios.isAxiosError<BaseApiResponse<Closing>>(error)) {
return error.response?.data;
}
return undefined;
}
}
async getPenjualan(
id: number
): Promise<BaseApiResponse<ClosingSales> | undefined> {
@@ -126,10 +54,6 @@ export class ClosingApiService extends BaseApiService<Closing, null, null> {
async getAllIncomingSapronakFetcher(
endpoint: string
): Promise<BaseApiResponse<ClosingIncomingSapronak[]>> {
// TODO: Remove this block when backend is ready
// return await dummyGetAllIncomingSapronakFetcher();
// Uncomment this when backend is ready
return await httpClientFetcher<BaseApiResponse<ClosingIncomingSapronak[]>>(
endpoint
);
@@ -138,31 +62,14 @@ export class ClosingApiService extends BaseApiService<Closing, null, null> {
async getAllOutgoingSapronakFetcher(
endpoint: string
): Promise<BaseApiResponse<ClosingOutgoingSapronak[]>> {
// TODO: Remove this block when backend is ready
return await dummyGetAllOutgoingSapronakFetcher();
// Uncomment this when backend is ready
// return await httpClientFetcher<BaseApiResponse<ClosingOutgoingSapronak[]>>(
// endpoint
// );
return await httpClientFetcher<BaseApiResponse<ClosingOutgoingSapronak[]>>(
endpoint
);
}
async getGeneralInfo(
id: number
): Promise<BaseApiResponse<ClosingGeneralInformation> | undefined> {
// TODO: Remove this block when backend is ready
// try {
// return await dummyGetGeneralInfo(id);
// } catch (error) {
// if (
// axios.isAxiosError<BaseApiResponse<ClosingGeneralInformation>>(error)
// ) {
// return error.response?.data;
// }
// return undefined;
// }
// Uncomment this when backend is ready
try {
const getGeneralInfoPath = `${this.basePath}/${id}`;
const getGeneralInfoRes =
@@ -180,17 +87,16 @@ export class ClosingApiService extends BaseApiService<Closing, null, null> {
}
}
async getProductionData(id: number) {
async getProductionData(
id: number
): Promise<BaseApiResponse<ClosingProductionData> | undefined> {
try {
// const getProductionDataPath = `${this.basePath}/${id}/production-data`;
// const getProductionDataRes = await httpClient<
// BaseApiResponse<ClosingProductionData>
// >(getProductionDataPath);
const getProductionDataPath = `${this.basePath}/${id}/production-data`;
const getProductionDataRes = await httpClient<
BaseApiResponse<ClosingProductionData>
>(getProductionDataPath);
// return getProductionDataRes;
await sleep(1000);
return dummyClosingProductionResponse;
return getProductionDataRes;
} catch (error) {
if (axios.isAxiosError<BaseApiResponse<ClosingProductionData>>(error)) {
return error.response?.data;
@@ -202,19 +108,6 @@ export class ClosingApiService extends BaseApiService<Closing, null, null> {
async getPerhitunganSapronak(
id: number
): Promise<BaseApiResponse<ClosingSapronakCalculation> | undefined> {
// TODO: Remove this block when backend is ready
// try {
// return await dummyGetPerhitunganSapronak(id);
// } catch (error) {
// if (
// axios.isAxiosError<BaseApiResponse<ClosingSapronakCalculation>>(error)
// ) {
// return error.response?.data;
// }
// return undefined;
// }
// Uncomment this when backend is ready
try {
const path = `${this.basePath}/${id}/perhitungan_sapronak`;
return await httpClient<BaseApiResponse<ClosingSapronakCalculation>>(
@@ -236,17 +129,6 @@ export class ClosingApiService extends BaseApiService<Closing, null, null> {
async getOverhead(
id: number
): Promise<BaseApiResponse<ClosingOverhead> | undefined> {
// TODO: Remove this block when backend is ready
// try {
// return await dummyGetOverhead(id);
// } catch (error) {
// if (axios.isAxiosError<BaseApiResponse<ClosingOverhead>>(error)) {
// return error.response?.data;
// }
// return undefined;
// }
// Uncomment this when backend is ready
try {
const path = `${this.basePath}/${id}/overhead`;
return await httpClient<BaseApiResponse<ClosingOverhead>>(path, {
@@ -259,6 +141,40 @@ export class ClosingApiService extends BaseApiService<Closing, null, null> {
return undefined;
}
}
async getFinance(
id: number
): Promise<BaseApiResponse<ClosingFinance> | undefined> {
try {
const path = `${this.basePath}/${id}/keuangan`;
return await httpClient<BaseApiResponse<ClosingFinance>>(path, {
method: 'GET',
});
} catch (error) {
if (axios.isAxiosError<BaseApiResponse<ClosingFinance>>(error)) {
return error.response?.data;
}
return undefined;
}
}
async getHppEkspedisi(
id: number
): Promise<BaseApiResponse<ClosingHppExpedition> | undefined> {
try {
const getHppEkspedisiPath = `${this.basePath}/${id}/expedition-hpp`;
const getHppEkspedisiRes =
await httpClient<BaseApiResponse<ClosingHppExpedition>>(
getHppEkspedisiPath
);
return getHppEkspedisiRes;
} catch (error) {
if (axios.isAxiosError<BaseApiResponse<ClosingHppExpedition>>(error)) {
return error.response?.data;
}
return undefined;
}
}
}
export const ClosingApi = new ClosingApiService('/closings');
-37
View File
@@ -1,5 +1,3 @@
import { dummyMarketings } from '@/dummy/marketing.dummy';
import { sleep } from '@/lib/helper';
import { BaseApiService } from '@/services/api/base';
import { httpClient } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
@@ -31,41 +29,6 @@ export class SalesOrderService extends BaseApiService<
super(basePath);
}
// /**
// * Override: Mengambil semua data Marketing dari dummyMarketings
// */
// async getAllFetcher(endpoint: string): Promise<BaseApiResponse<Marketing[]>> {
// // Simulasi delay jaringan
// await sleep(500);
// // Filter data marketing yang valid (jika menggunakan BaseMarketing[])
// const data = dummyMarketings as Marketing[];
// return createDummyResponse<Marketing[]>(data);
// }
// /**
// * Override: Mengambil satu data Marketing berdasarkan ID dari dummyMarketings
// */
// async getSingle(id: number): Promise<BaseApiResponse<Marketing> | undefined> {
// // Simulasi delay jaringan
// await sleep(300);
// const foundData = dummyMarketings.find((m) => m.id == id);
// if (foundData) {
// // Data ditemukan, kembalikan respons sukses
// return createDummyResponse<Marketing>(foundData as Marketing);
// } else {
// // Data tidak ditemukan, simulasi respons error
// return {
// code: 404,
// status: 'error',
// message: 'Marketing data not found (MOCK)',
// };
// }
// }
/**
* Approve single marketing data
*/
+23
View File
@@ -0,0 +1,23 @@
import { BaseApiService } from '@/services/api/base';
import { httpClient, httpClientFetcher } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import { ReportExpense } from '@/types/api/report/report-expense';
import axios from 'axios';
export class ReportExpenseApiService extends BaseApiService<
ReportExpense,
unknown,
unknown
> {
constructor(basePath: string) {
super(basePath);
}
async getAllFetcher(
endpoint: string
): Promise<BaseApiResponse<ReportExpense[]>> {
return await httpClientFetcher<BaseApiResponse<ReportExpense[]>>(endpoint);
}
}
export const ReportExpenseApi = new ReportExpenseApiService('/reports/expense');
+54
View File
@@ -0,0 +1,54 @@
import { BaseApiService } from '@/services/api/base';
import { BaseApiResponse } from '@/types/api/api-general';
import { LogisticPurchasePerSupplierReport } from '@/types/api/report/logistic-stock';
export class LogisticApiService extends BaseApiService<
LogisticPurchasePerSupplierReport,
unknown,
unknown
> {
constructor(basePath: string) {
super(basePath);
}
async getLogisticPurchasePerSupplierReport(
area_id?: string,
supplier_id?: string,
product_id?: string,
product_category_id?: string,
received_date?: string,
po_date?: string,
start_date?: string,
end_date?: string,
sort_by?: string,
filter_by?: string,
page?: number,
limit?: number
): Promise<BaseApiResponse<LogisticPurchasePerSupplierReport> | undefined> {
return await this.customRequest<
BaseApiResponse<LogisticPurchasePerSupplierReport>
>(`purchase-supplier`, {
method: 'GET',
params: {
area_id: area_id,
supplier_id: supplier_id,
product_id: product_id,
product_category_id: product_category_id,
received_date: received_date,
po_date: po_date,
start_date: start_date,
end_date: end_date,
sort_by: sort_by,
filter_by: filter_by,
page: page,
limit: limit,
},
});
}
}
export const LogisticApi = new LogisticApiService('reports');
// export const LogisticApi = new LogisticApiService(
// 'http://localhost:4010/api/reports/logistics'
// );
+53
View File
@@ -0,0 +1,53 @@
import { BaseApiService } from '@/services/api/base';
import { BaseApiResponse } from '@/types/api/api-general';
import { HppPerKandangReport } from '@/types/api/report/hpp-per-kandang';
export class MarketingSaleReportService extends BaseApiService<
HppPerKandangReport,
unknown,
unknown
> {
constructor(basePath: string) {
super(basePath);
}
async getHppPerKandangReport(
area_id?: string,
location_id?: string,
kandang_id?: string,
weight_min?: string,
weight_max?: string,
period?: string,
sort_by?: string,
show_unrecorded?: boolean,
page?: number,
limit?: number
): Promise<BaseApiResponse<HppPerKandangReport> | undefined> {
return await this.customRequest<BaseApiResponse<HppPerKandangReport>>(
`hpp-per-kandang`,
{
method: 'GET',
params: {
area_id: area_id,
location_id: location_id,
kandang_id: kandang_id,
weight_min: weight_min,
weight_max: weight_max,
period: period,
sort_by: sort_by,
show_unrecorded: show_unrecorded,
page: page,
limit: limit,
},
}
);
}
}
export const SaleReportApi = new MarketingSaleReportService(
'reports/marketings'
);
// export const SaleReportApi = new MarketingSaleReportService(
// 'http://localhost:4010/api/reports/marketings'
// );
+136 -17
View File
@@ -23,6 +23,33 @@ export type BaseSales = {
payment_status: string;
};
export type BaseClosingSales = {
project_type: string;
flock_id: number;
period: number;
sales: BaseSales[];
};
import { Kandang } from '@/types/api/master-data/kandang';
import { Product } from '@type/api/master-data/product';
import { Customer } from '@type/api/master-data/customer';
import { BaseMetadata } from '@/types/api/api-general';
export type BaseSales = {
id: number;
realization_date: string;
age: number;
do_number: string;
product: Product;
customer: Customer;
qty: number;
weight: number;
avg_weight: number;
price: number;
total_price: number;
kandang: Kandang;
payment_status: string;
};
export type BaseClosingSales = {
project_type: string;
flock_id: number;
@@ -84,33 +111,36 @@ export type ClosingProductionData = {
initial_population: number;
claim_culling: number;
final_population: number;
feed_in_kg: number;
feed_used_kg: number;
feed_used_per_head_kg: number;
feed_in: number;
feed_used: number;
feed_used_per_head: number;
};
sales: {
sales_kg: number;
sales_head: number;
average_weight_kg: number;
average_price_per_kg: number;
chicken: {
sales_population: number;
sales_weight: number;
average_weight: number;
chicken_average_selling_price: number;
};
egg?: {
egg_pieces: number;
egg_mass_kg: number;
average_egg_weight_kg: number;
egg_average_selling_price: number;
};
};
performance: {
depletion_head: number;
depletion_percentage: number;
age_days: number;
depletion: number;
age_day: number;
mortality_std: number;
mortality_act: number;
deff_mortality: number;
fcr_std: number;
fcr_act: number;
deff_fcr: number;
adg: number;
ip: number;
};
variance: {
variance_head: number;
variance_head_percentage: number;
variance_feed_kg: number;
awg: number;
};
};
@@ -176,4 +206,93 @@ export type OverheadTotal = {
actual_total_amount: number;
cost_per_bird: number;
};
export type ClosingSales = BaseMetadata & BaseClosingSales;
// ====== FINANCE ======
export interface ClosingFinance {
project_flock_id: number;
period: number;
project_type: string;
volume_base: ClosingFinanceVolumeBase;
hpp_purchases: ClosingFinanceHppPurchases;
profit_loss: ClosingFinanceProfitLoss;
}
export interface ClosingFinanceProfitLoss {
title: string;
data: ProfitLossData;
}
export interface ClosingFinanceHppPurchases {
title: string;
hpp: GroupHppPurchase[];
summary_hpp: HppPurchasesSummary;
}
export interface ClosingFinanceVolumeBase {
total_birds: number;
total_weight_kg: number;
}
export interface ProfitLossData {
penjualan: ProfitLossDataAmount[];
pembelian: ProfitLossDataAmount[];
summary: ProfitLossDataSummary;
}
export interface GroupHppPurchase {
group_name: string;
data: HppPurchaseData[];
}
export interface ProfitLossDataSummary {
gross_profit: DataSummarySubTotal;
sub_total: DataSummarySubTotal;
net_profit: DataSummarySubTotal;
}
export interface ProfitLossDataAmount {
type: string;
rp_per_bird: number;
rp_per_kg: number;
amount: number;
}
export interface HppPurchasesSummary {
label: string;
budgeting: HppPurchaseDataAmount;
realization: HppPurchaseDataAmount;
}
export interface HppPurchaseData {
type: string;
budgeting: HppPurchaseDataAmount;
realization: HppPurchaseDataAmount;
}
export interface HppPurchaseDataAmount {
rp_per_bird: number;
rp_per_kg: number;
amount: number;
}
export interface DataSummarySubTotal {
label: string;
rp_per_bird: number;
rp_per_kg: number;
amount: number;
}
export type BaseExpeditionCost = {
id: number;
expedition_vendor_name: string;
hpp_amount: number;
};
export type BaseHppExpedition = {
expedition_costs: BaseExpeditionCost[];
total_hpp_amount: number;
};
export type ClosingHppExpedition = BaseMetadata & BaseHppExpedition;
+2 -4
View File
@@ -4,10 +4,8 @@ import { BaseMetadata } from '@/types/api/api-general';
export type BaseInventoryAdjustment = {
id: number;
transaction_type: string;
quantity: number;
before_quantity: number;
after_quantity: number;
increase: number;
decrease: number;
note: string;
product_warehouse_id: number;
product_warehouse: {
+69
View File
@@ -0,0 +1,69 @@
import { BaseMetadata } from '@types/api/base-metadata';
import { Supplier } from '@/types/api/master-data/supplier';
import { Kandang } from '@/types/api/master-data/kandang';
export type HppPerKandangRow = {
id: number;
kandang: Kandang;
weight_range: {
weight_min: number;
weight_max: number;
};
remaining_chicken_birds: number;
remaining_chicken_weight_kg: number;
avg_weight_kg: number;
egg_production_pieces: number;
egg_production_kg: number;
egg_hpp_rp_per_kg: number;
egg_value_rp: number;
feed_suppliers: Supplier[];
doc_suppliers: Supplier[];
average_doc_price_rp: number;
hpp_rp: number;
remaining_value_rp: number;
};
export type HppPerKandangSummaryTotal = {
total_remaining_chicken_birds: number;
total_remaining_chicken_weight_kg: number;
average_weight_kg: number;
total_remaining_value_rp: number;
total_egg_production_pieces: number;
total_egg_production_kg: number;
average_egg_hpp_rp_per_kg: number;
total_egg_value_rp: number;
total_hpp_rp: number;
total_average_doc_price_rp: number;
};
export type HppPerKandangPerWeightRange = {
id: number;
weight_range: {
weight_min: number;
weight_max: number;
};
label: string;
remaining_chicken_birds: number;
remaining_chicken_weight_kg: number;
avg_weight_kg: number;
egg_production_pieces: number;
egg_production_kg: number;
egg_hpp_rp_per_kg: number;
egg_value_rp: number;
feed_suppliers: Supplier[];
doc_suppliers: Supplier[];
average_doc_price_rp: number;
hpp_rp: number;
remaining_value_rp: number;
};
export type HppPerKandangSummary = {
per_weight_range: HppPerKandangPerWeightRange[];
total: HppPerKandangSummaryTotal;
};
export type HppPerKandangReport = BaseMetadata & {
period: string;
rows: HppPerKandangRow[];
summary: HppPerKandangSummary;
};
+35
View File
@@ -0,0 +1,35 @@
import { BaseMetadata } from '@/types/api/api-general';
import { Supplier } from '@/types/api/supplier/supplier';
import { Product } from '@/types/api/product/product';
import { Warehouse } from '@/types/api/warehouse/warehouse';
export type LogisticPurchasePerSupplierReportRow = {
receive_date: string;
po_date: string;
po_number: string;
product: Product;
warehouse: Warehouse;
qty: number;
unit_price: number;
purchase_value: number;
transport_unit_price: number;
transport_value: number;
total_amount: number;
expedition: string;
delivery_number: string;
};
export type LogisticPurchasePerSupplierSummary = {
total_qty: number;
total_unit_price: number;
total_purchase_value: number;
total_transport_unit_price: number;
total_transport_value: number;
total_amount: number;
};
export type LogisticPurchasePerSupplierReport = BaseMetadata & {
supplier: Supplier;
rows: LogisticPurchasePerSupplierReportRow[];
summary: LogisticPurchasePerSupplierSummary;
};
+55
View File
@@ -0,0 +1,55 @@
import { BaseApproval, CreatedUser } from '@/types/api/api-general';
import { Supplier } from '@/types/api/master-data/supplier';
import { Location } from '@/types/api/master-data/location';
import { Nonstock } from '@/types/api/master-data/nonstock';
import { Kandang } from '@/types/api/master-data/kandang';
export type Pengajuan = {
id: number;
expense_id: number;
project_flock_kandang_id: number;
kandang_id: number;
nonstock_id: number;
qty: number;
price: number;
notes: string;
nonstock: Nonstock;
created_at: string;
};
export type Realisasi = {
id: number;
expense_nonstock_id: number;
qty: number;
price: number;
notes: string;
nonstock: Nonstock;
created_at: string;
};
export type ReportExpense = {
id: number;
reference_number: string;
po_number: string;
category: string;
supplier: Supplier;
realization_date: string;
transaction_date: string;
pengajuan: Pengajuan;
realisasi: Realisasi;
kandang: Kandang;
created_at: string;
updated_at: string;
created_user: CreatedUser;
latest_approval: BaseApproval;
};
export type ReportExpenseSearchParams = {
locationId: string | null;
supplierId: string | null;
kandangId: string | null;
nonstockId: string | null;
realizationDate: string | null;
category: string | null;
search: string;
};