Compare commits

..

506 Commits

Author SHA1 Message Date
Sahil Khan
723ed07642 Merge branch 'hotfix' 2019-05-22 15:54:26 +05:30
Sahil Khan
86b531aea6 bumped to version 11.1.32 2019-05-22 16:14:26 +05:50
rohitwaghchaure
781a420593 Merge pull request #17702 from rohitwaghchaure/not_able_to_submit_sales_invoice_italy_localization
fix: italy localization, not able to submit sales invoice
2019-05-22 15:48:12 +05:30
Rohit Waghchaure
08f709c2a2 fix: italy localization, not able to submit sales invoice 2019-05-22 15:28:47 +05:30
Nabin Hait
a849f6e21e fix: update received qty in PO from PR and PI (#17692)
* fix: Don't set reqd date in PO based on MR, if less than today

* fix: update received qty in PO from PR

* fix: po status

* fix: set schedule date from MR to PO
2019-05-22 15:05:17 +05:30
rohitwaghchaure
6ff8387d56 Merge pull request #17679 from Anurag810/revert_change
fix(Revert): sales order status for order type 'Maintenance
2019-05-22 14:47:47 +05:30
rohitwaghchaure
2af9ff9c33 Merge pull request #17699 from rohitwaghchaure/italian_localization_issue_for_invoices
fix: removed translation for customer type and tax charge type
2019-05-22 13:21:20 +05:30
Rohit Waghchaure
01905cad2f fix: removed translation for customer type and tax charge type 2019-05-22 13:17:46 +05:30
Anurag Mishra
58260e4f0a Merge branch 'hotfix' into revert_change 2019-05-21 16:52:01 +05:30
Anurag Mishra
a01869bb20 style: refactor 2019-05-21 16:50:20 +05:30
Anurag Mishra
3a92615f23 Merge branch 'revert_change' of https://github.com/anurag810/erpnext into revert_change 2019-05-21 14:15:58 +05:30
Sahil Khan
eca8db7405 Merge branch 'hotfix' 2019-05-21 14:11:08 +05:30
Sahil Khan
58c4cfc0d7 bumped to version 11.1.31 2019-05-21 14:31:08 +05:50
Anurag Mishra
ab52a4db75 fix: refactor sales_order_list.js 2019-05-21 13:09:47 +05:30
Saurabh
c8d3a8c0f5 Merge pull request #17686 from rohitwaghchaure/set_default_warehouse_from_the_stock_settings
fix: while making the item, default warehouse not set even if the stock settings has the default warehouse
2019-05-21 12:52:51 +05:30
rohitwaghchaure
aab88eee3e Merge pull request #17644 from nabinhait/bom-item-rate
fix: BOM Item rate based on uom conversion factor and exchange rate
2019-05-21 12:25:19 +05:30
Rushabh Mehta
d9f4c83567 Merge pull request #17539 from adityahase/fix-training-feedback-hotfix
fix(hr): Use event_status instead of status
2019-05-21 12:20:29 +05:30
Rushabh Mehta
b96dd366ce Merge pull request #17641 from nabinhait/gle-opening-stock-reco
fix: GL Entry for opening stock reconciliation
2019-05-21 12:17:04 +05:30
rohitwaghchaure
597ec83af7 Merge pull request #17684 from rohitwaghchaure/item_group_not_disaplying_in_website
fix: item group not disaplying in the website if shopping cart is disabled
2019-05-21 12:15:39 +05:30
Rohit Waghchaure
e802bdd186 fix: while making the item, default warehouse not set even if the stock settings has the warehouse 2019-05-21 12:11:40 +05:30
Rohit Waghchaure
3a949bb298 fix: item group not disaplying in the website if shopping cart is disabled 2019-05-21 10:18:39 +05:30
rohitwaghchaure
1e920dd0d8 Merge pull request #17681 from rohitwaghchaure/expense_claim_reconciliation_issue
fix: payment reconciliation not update the status of the expense claim
2019-05-20 22:41:48 +05:30
Rohit Waghchaure
09536f402e fix: payment reconciliation not update the status of the expense claim 2019-05-20 17:01:22 +05:30
Anurag Mishra
581f26b7a0 Merge branch 'hotfix' into revert_change 2019-05-20 16:22:20 +05:30
Deepesh Garg
0418a2f70a Merge pull request #17677 from rohitwaghchaure/show_opening_entries_gl_report
feat: added checkbox in the filter of general ledger to show opening entries
2019-05-20 15:21:28 +05:30
Saurabh
5e3338744c Merge pull request #17675 from nabinhait/lead-from-email
fix: Person / Org name is not mandatory while creation of lead from email
2019-05-20 14:57:16 +05:30
Anurag Mishra
da3762700c fix(Revert): sales order status for order type 'Maintenance 2019-05-20 12:38:33 +05:30
Rohit Waghchaure
2c9fccd8ba feat: added checkbox in the filter of general ledger to show opening entries 2019-05-20 12:25:53 +05:30
Nabin Hait
286c4fa640 fix: Person / Org name is not mandatory while creation of lead from email 2019-05-20 12:16:34 +05:30
Deepesh Garg
a144e002db Merge pull request #17667 from deepeshgarg007/quotation-qty-fix
fix: Quotation to lead fix while changing item qty
2019-05-18 23:38:24 +05:30
deepeshgarg007
553dabaa08 fix: Pricing rule fix for lead 2019-05-18 21:31:58 +05:30
Anurag Mishra
55d0d32c77 fix: Allow return if delivery note or sales order is required in selling settings(v11) (#17632)
* fix: Allow retrun if delivery note or sales order is required in selling settings

* fix: combined condition

* style: refactor
2019-05-18 13:02:31 +05:30
Deepesh Garg
1f93745eef Merge pull request #17663 from deepeshgarg007/opportunity-list-fixes
fix: Opportunity List view fix
2019-05-17 19:36:08 +05:30
deepeshgarg007
e3a02dd5f6 Merge branch 'hotfix' of https://github.com/frappe/erpnext into opportunity-list-fixes 2019-05-17 19:11:13 +05:30
deepeshgarg007
153733414f fix: Opportunity List view fix 2019-05-17 19:08:24 +05:30
Deepesh Garg
ac535f0ce9 Merge pull request #17661 from deepeshgarg007/quotation_lead_fixes
fix: Quotation to lead fix
2019-05-17 18:35:49 +05:30
deepeshgarg007
3886529787 fix: Get lead details only if lead name entered 2019-05-17 18:10:13 +05:30
Deepesh Garg
d2cd713b89 Merge pull request #17657 from deepeshgarg007/territory_item_code_hotfix
fix: Inactive Sales Item report fixes
2019-05-17 15:41:50 +05:30
Deepesh Garg
6b378e1669 fix: Unable to create item variant based on manufacturer (#17651)
* fix: Unable to create item variant against manufacturer

* fix: Spacing fixes

* fix: Spacing fixes in item.js
2019-05-17 15:39:59 +05:30
deepeshgarg007
a4fc30bbe4 fix: Inactive Sales Item report fixes 2019-05-17 15:39:23 +05:30
rohitwaghchaure
b4b0e4424d Merge pull request #17655 from rohitwaghchaure/not_able_to_make_si_from_dn
fix: not able to make si from dn
2019-05-17 15:29:04 +05:30
Rohit Waghchaure
f3bdcc2a84 fix: not able to make si from dn 2019-05-17 15:28:12 +05:30
Nabin Hait
19901c14c9 fix: Removed unused variable 2019-05-17 14:30:45 +05:30
Deepesh Garg
714d686e50 Merge pull request #17650 from deepeshgarg007/gstr2_fix
fix: GSTR 2 report fix
2019-05-17 11:24:44 +05:30
rohitwaghchaure
094dc1dee6 Merge pull request #17646 from rohitwaghchaure/fixed_bank_reco_internal_transfer
fix: bank reconciliation for internal transfer is not working
2019-05-17 11:20:43 +05:30
deepeshgarg007
cdcf424ba5 Merge branch 'hotfix' of https://github.com/frappe/erpnext into gstr2_fix 2019-05-17 10:30:39 +05:30
deepeshgarg007
2a2b884e32 fix: Return taxable value in get_row_data_for_invoice 2019-05-17 10:20:14 +05:30
Rohit Waghchaure
c1e00f4daa fix: bank reconciliation for internal transfer is not working 2019-05-17 00:01:21 +05:30
Rohan
0a22aab6bb fix(tests): Save batch instead of submitting it (#17636) 2019-05-16 19:45:11 +05:30
Nabin Hait
43c6d1a518 fix: Gte lead details in opportunity (#17633) 2019-05-16 19:35:27 +05:30
Nabin Hait
090219814e fix: BOM Item rate based on uom conversion factor and exchange rate 2019-05-16 19:17:02 +05:30
Nabin Hait
510dc60bf0 fix: GL Entry for opening stock reconciliation 2019-05-16 17:28:39 +05:30
Saurabh
12d520a366 Merge pull request #17562 from fproldan/fix_bundlestock
fix: Available Stock for Packing Items error
2019-05-16 15:40:02 +05:30
Saurabh
a06a527fe6 Merge pull request #17629 from nabinhait/multi-fixes-33
fix: Multiple small fixes
2019-05-16 15:39:31 +05:30
Rushabh Mehta
9a7681535f Merge pull request #17634 from nabinhait/add-multiple-items
feat: Added 'Add Multiple' options in all sales / purchase transactions
2019-05-16 14:56:44 +05:30
Nabin Hait
8e1a612b3b feat: Added 'Add Multiple' options in all sales / purchase transactions 2019-05-16 14:48:10 +05:30
Deepesh Garg
21085bf2be fix: Query fixes in quotation and opportunity (#17619)
* fix: Query fixes for contact person and minor fixes

* fix: Change quotation to quotation_to
2019-05-16 14:11:21 +05:30
Anurag Mishra
8e9413829d fix: variant item description based on attribute (#17627)
* fix: variant item description based on attribute

* fix: requested changes

* style: removed print
2019-05-16 13:57:35 +05:30
Nabin Hait
8208f878ff fix: show tasks in project order by due date and status 2019-05-16 13:43:08 +05:30
Nabin Hait
a7130649cd fix: Get bank account on selection of payment mode in Loan 2019-05-16 13:43:07 +05:30
Nabin Hait
b2d9ffa4ca fix: To allow creation of sales invoice without customer 2019-05-16 13:43:07 +05:30
Himanshu
cc4f13a862 validate: check additional salary component exists (#17530) 2019-05-16 10:34:46 +05:30
Saurabh
60681baf80 Merge branch 'hotfix' into fix_bundlestock 2019-05-15 15:28:58 +05:30
Saurabh
ea6049078a Merge pull request #17620 from rohitwaghchaure/auto_set_the_barcode_if_item_has_one_barcode
fix: set barcode on selection of item if item has one barcode
2019-05-15 15:09:51 +05:30
Rohit Waghchaure
d83e8c56b2 fix: set barcode on selection of item if item has one barcode 2019-05-15 14:10:10 +05:30
Deepesh Garg
e0ade62e38 fix: Status updater fixes in PO and SO and test coverage for return (#17490)
* fix: Status updator fixes in sales and purchase cycle

* fix: Test cases for return in so and po
2019-05-15 12:18:12 +05:30
bghayad
c78b921412 Fix for Chart of Account sorting problem (#17563)
* First Commit from Master

* Fix for CoA sorting problem

* Fixing for CoA sorting problem

* Fix for Chart of Account Sorting Problem
2019-05-15 11:58:59 +05:30
Palash Jhabak
9e9e415c5f fix: Cancelled Lab Tests shouldnt show in SI (#17616)
Cancelled Lab Tests were also showing up in Get Items of SI

fixes issue #17607
2019-05-15 11:37:31 +05:30
Saurabh
0692e5eb78 Merge branch 'hotfix' 2019-05-15 08:07:29 +05:30
Saurabh
3fa5eec07b bumped to version 11.1.30 2019-05-15 08:37:29 +06:00
rohitwaghchaure
ab1bf1af19 fix: not able to create the sales invoice without item code (#17610) 2019-05-15 07:45:47 +05:30
sahil28297
15e0861e82 fix(patch): set attribute to none if it does not exist (#17605) 2019-05-15 07:44:45 +05:30
Prasad Ramesh
aa493a25f8 fix: made Sales Partner Comission report visible in Selling module (#17604) 2019-05-15 07:43:39 +05:30
Saurabh
1c2915f74b fix: reload docs before creating custom fields (#17585) 2019-05-15 07:43:16 +05:30
rohitwaghchaure
391b3b67cb fix: limit offset was missing in the get_delivery_notes_to_be_billed method (#17609) 2019-05-15 07:41:10 +05:30
Nabin Hait
0361c50503 refactor: Payroll processing and tax calculation (#17595)
* refactor: Payroll processing and tax calculation

* fix: payroll test cases

* fix: Codacy fixes

* fix: removed debug mode

* fix: payroll test cases

* Update payroll_period.py
2019-05-15 07:38:57 +05:30
Sahil Khan
517a3071cf Merge branch 'hotfix' 2019-05-14 16:48:26 +05:30
Sahil Khan
0e30e705c5 bumped to version 11.1.29 2019-05-14 17:08:26 +05:50
Deepesh Garg
940df7563b Merge pull request #17575 from deepeshgarg007/quotation-filter-fixes
fix: Standard filter and dashboard fixes in quotation and opportunity
2019-05-14 15:10:41 +05:30
deepeshgarg007
f390872944 Merge branch 'hotfix' of https://github.com/frappe/erpnext into quotation-filter-fixes 2019-05-14 14:58:22 +05:30
deepeshgarg007
cfd18d4e03 fix: Lead and customer dashboard fixes 2019-05-14 14:57:22 +05:30
Deepesh Garg
6c73439e7f Merge pull request #17598 from deepeshgarg007/lead-fixes
fix: Change lead to party_name in Lead
2019-05-14 14:41:49 +05:30
deepeshgarg007
2389e2c438 fix: Change lead to party_name 2019-05-14 14:40:18 +05:30
Deepesh Garg
c2341ca8e3 Merge pull request #17596 from deepeshgarg007/territory_group_fixes_hotfix
fix: Make territory filter mandatory
2019-05-14 14:35:26 +05:30
deepeshgarg007
3b810ea8da fix: Make territory filter mandatory 2019-05-14 14:32:36 +05:30
Deepesh Garg
f85d6aeecd Merge pull request #17589 from deepeshgarg007/inactive_items_row_hotfix
fix: Do not append row if item is sold within days since last ordered
2019-05-14 11:56:41 +05:30
rohitwaghchaure
a13762b05f Merge pull request #17581 from saurabh6790/quoation_creation_fix
fix: validate customer while pulling information
2019-05-14 11:51:15 +05:30
rohitwaghchaure
dd5a0a1f26 Merge pull request #17545 from rohitwaghchaure/incorrect_payment_amount_if_advance_amount_in_si
fix: incorrect payment amount in the payment terms if the sales invoice has the advance amount
2019-05-14 11:14:55 +05:30
deepeshgarg007
a6ad0b0ec8 fix: Do not append row if item is sold within days since last order 2019-05-14 08:49:24 +05:30
Saurabh
077e20f7ae fix: valiadte customer while pulling information 2019-05-13 15:21:25 +05:30
Saurabh
73c6d2e44c Merge pull request #17555 from Alchez/hotfix-lead-import-fix
feat(crm): Allow leads to be imported without person name
2019-05-13 13:07:04 +05:30
Saurabh
d7a13ceb10 Merge pull request #17568 from Mangesh-Khairnar/event-fix
feat(training-event): validate event period
2019-05-13 13:06:17 +05:30
Saurabh
6eadd6657d Merge pull request #17574 from rohitwaghchaure/delayed_orders_summary_report
feat: delayed order summary report
2019-05-13 13:05:40 +05:30
Saurabh
0c890a110d Merge branch 'hotfix' into hotfix-lead-import-fix 2019-05-13 12:07:33 +05:30
Saurabh
0f420baaba Merge branch 'hotfix' into event-fix 2019-05-13 11:46:29 +05:30
Aditya Hase
b32d96fc24 fix(accounts): Add missing field (#17571) 2019-05-13 10:21:41 +05:30
Suraj Shetty
cfc9e18749 Merge pull request #17532 from surajshetty3416/fix-rename-account
fix: Show "Merge Account" button only to users with write access
2019-05-13 10:03:16 +05:30
Deepesh Garg
25d6d08329 Merge pull request #17576 from deepeshgarg007/territory_items_hotfix
fix: Show all territories and items in inactive sales item report
2019-05-12 20:44:06 +05:30
Deepesh Garg
9bf4c754c8 Merge branch 'hotfix' into territory_items_hotfix 2019-05-12 18:40:54 +05:30
deepeshgarg007
79b02db156 fix: Indentation fixes 2019-05-12 18:27:52 +05:30
Rohit Waghchaure
e55dd7233d feat: delayed order summary report 2019-05-12 17:17:55 +05:30
rohitwaghchaure
3ba969ad58 Merge pull request #17573 from adityahase/fix-lead-make-quotation
fix(lead): Map lead name to quotation party_name
2019-05-12 17:10:14 +05:30
rohitwaghchaure
f93cb0a6ba Merge pull request #17567 from rohitwaghchaure/fixed_multiple_bank_reconciliation_issues
fix: fixed multiple bank reconciliation issues
2019-05-12 17:04:58 +05:30
Aditya Hase
e23cfd22ca fix(lead): Map lead name to quotation party_name 2019-05-12 16:20:31 +05:30
Mangesh-Khairnar
0d1b022ea0 feat(training-event): validate event period 2019-05-11 23:30:52 +05:30
deepeshgarg007
aca3772f7d fix: Show all territories and items in inactive sales item report 2019-05-11 21:12:51 +05:30
deepeshgarg007
569815b5ad fix: Standard filter and dashboard fixes in quotation and opportunity 2019-05-11 20:09:29 +05:30
Rohit Waghchaure
80b696ce25 fix: wrong journal entries are showing in the reconcilliation section 2019-05-11 14:58:25 +05:30
rohitwaghchaure
ab7570b7e5 Merge pull request #17558 from rohitwaghchaure/incorrect_stock_balance_showing_in
fix: incorrect stock balance showing in the warehouse tree view
2019-05-11 11:30:24 +05:30
NahuelOperto
c87f8c6f00 fix codecay 2019-05-10 10:46:41 -03:00
Rohit Waghchaure
c5c4de885b fix: bank reconciliation not showing as Settled instead of Reconciled 2019-05-10 19:08:41 +05:30
NahuelOperto
a970bcc56d fix identation 2019-05-10 09:17:52 -03:00
NahuelOperto
5e2d822509 fix error when the product bundle does not have the original item name 2019-05-10 09:00:46 -03:00
Rohit Waghchaure
6be1475882 fix: incorrect stock balance showing in the warehouse tree view 2019-05-10 17:19:50 +05:30
Rohan Bansal
43f05d1de1 feat(crm): Allow leads to be imported without person name 2019-05-10 16:03:16 +05:30
rohitwaghchaure
26bb028ec4 Merge pull request #17549 from sunhoww/pos_batch
fix: POS batch not set correctly
2019-05-10 14:14:44 +05:30
Rohit Waghchaure
1637f0aeaf fix: test cases 2019-05-10 13:42:38 +05:30
Saurabh
353f64caf8 Merge pull request #17541 from rohitwaghchaure/project_update_statue_emails_sending_frequestly
fix: project update status emails are sent frequently
2019-05-10 13:24:24 +05:30
Saurabh
5afb00a7f4 Merge branch 'hotfix' into fix-rename-account 2019-05-10 12:40:00 +05:30
Saurabh
b0ec0f545e Merge pull request #17540 from surajshetty3416/fix-slow-item-search
perf: Index show_in_website field for faster item search
2019-05-10 12:38:06 +05:30
rohitwaghchaure
7088513da9 Update project.py 2019-05-10 12:04:21 +05:30
Sun Howwrongbum
8133970944 fix: POS batch not set correctly 2019-05-09 20:45:19 +05:30
Rohit Waghchaure
dac7ede911 fix: incorrect payment amount in the payment terms if the sales invoice has the advance amount 2019-05-09 19:50:00 +05:30
Rohit Waghchaure
b052498348 fix: project update status emails sent frequently 2019-05-09 19:04:58 +05:30
Aditya Hase
d62de6c8e5 fix(hr): Use event_status instead of status
Training Feedback DocType has event_status field (not status)
This was broken since PR #10379, PR #17197 made this failure explicit.
2019-05-09 19:01:00 +05:30
Suraj Shetty
d37cc9a5d0 perf: Index show_in_website field for faster item search 2019-05-09 18:49:57 +05:30
Suraj Shetty
c7062d8be7 fix: Check permissions before renaming the account 2019-05-09 14:18:41 +05:30
Suraj Shetty
1435e30ad5 fix: Show "Merge Account" button only to users with write access 2019-05-09 14:16:49 +05:30
rohitwaghchaure
b2ff0bda32 Merge pull request #17526 from rohitwaghchaure/allow_data_import_for_salary_slip
fix: allow data import for salary slip
2019-05-08 22:20:20 +05:30
Rohit Waghchaure
ff42d7b5ed fix: allow data import for salary slip 2019-05-08 20:17:47 +05:30
Anurag Mishra
35a4cae2f6 feat: Adding action Stop or Warn for Quality inspection if it is not submitted
* feat: added action on quality inspection

* feat: added action on quality inspection

* feat: Allow the user to by pass inspection if action is warn ad stop when action is to warn

* patch: for setting default action to 'Stop'

* fix: resolve conflicts

* fix: travis

* Update stock_controller.py
2019-05-08 17:41:04 +05:30
rohitwaghchaure
a76cafecb0 Merge pull request #17521 from rohitwaghchaure/fixed_get_invoiced_item_gross_margin
fix: get totals of gross profit amount on call of the method get_invoiced_item_gross_margin
2019-05-08 16:17:11 +05:30
rohitwaghchaure
715ecc31c8 Merge pull request #17519 from rohitwaghchaure/added_validation_for_stock_entry_purpose
fix: job card issue, added validation in the stock entry
2019-05-08 16:16:39 +05:30
deepeshgarg007
09145674ec bumped to version 11.1.28 2019-05-08 15:58:50 +05:50
Rohit Waghchaure
11eebddb79 fix: get totals of gross profit amount on call of the method get_invoiced_item_gross_margin 2019-05-08 15:31:29 +05:30
Rohit Waghchaure
7d7417af29 fix: job card issue, added validation in the stock entry 2019-05-08 15:07:31 +05:30
rohitwaghchaure
6edb6e0b09 Merge pull request #17506 from Anurag810/rename_column
fix: renamed column avg. buying rate to valuation rate in gross profit report
2019-05-08 14:34:14 +05:30
rohitwaghchaure
e057575661 Merge pull request #17518 from rohitwaghchaure/customer_not_able_to_save
fix: not able to save customer if contribution is not set
2019-05-08 14:33:44 +05:30
Rohit Waghchaure
b7b5eeb92b fix: not able to save customer 2019-05-08 14:28:33 +05:30
rohitwaghchaure
85731053fd Merge pull request #17514 from rohitwaghchaure/pricing_rule_not_working_on_quotation
fix: pricing rule not working properly on the quotation
2019-05-08 11:14:34 +05:30
Rohit Waghchaure
9f3eb9e077 fix: pricing rule not working properly on the quotation 2019-05-08 11:13:56 +05:30
Sahil Khan
5ac8dbfceb Merge branch 'hotfix' 2019-05-07 17:55:28 +05:30
Sahil Khan
f4f2301e5b bumped to version 11.1.27 2019-05-07 18:15:28 +05:50
rohitwaghchaure
7888336524 Merge pull request #17512 from rohitwaghchaure/address_not_set_from_the_lead
fix: address not set on the quotation from the lead
2019-05-07 17:50:09 +05:30
Rohit Waghchaure
b8c2e02c67 fix: address not set on the quotation from the lead 2019-05-07 17:18:00 +05:30
Sahil Khan
a9e9efbd23 Merge branch 'hotfix' 2019-05-07 14:45:45 +05:30
Sahil Khan
2f175e6d85 bumped to version 11.1.26 2019-05-07 15:01:01 +05:50
rohitwaghchaure
df817a858b Merge pull request #17510 from rohitwaghchaure/fixed_lead_dashbord_quotation_redirect
fix: quotation link in the lead dashboard not working and filter from the dashboard was not set in the list view
2019-05-07 14:30:16 +05:30
Rohit Waghchaure
4a73059ed3 fix: quotation link in the lead dashboard not working and filter from the dashboard was not applying 2019-05-07 14:29:39 +05:30
rohitwaghchaure
552615525c Merge pull request #17511 from deepeshgarg007/quotation-refactor-fixes
fix: Dynamic Link fieldname fix in quotation
2019-05-07 14:25:38 +05:30
deepeshgarg007
25e408fd2a fix:Contact person query fix in quotation 2019-05-07 12:43:20 +05:30
rohitwaghchaure
375a6f67ae Merge pull request #17505 from rohitwaghchaure/customer_not_found_in_qtn
fix: not able to make sales order from the lead quotation
2019-05-06 19:26:24 +05:30
Anurag Mishra
01095799e8 fix: renamed column avg. buying rate to valuation rate 2019-05-06 17:41:28 +05:30
Rohit Waghchaure
ead8d82a84 fix: not able to make sales order from the lead quotation 2019-05-06 17:16:57 +05:30
Deepesh Garg
2c229c6403 Merge pull request #17495 from sagarvora/ewb_fixes
fix(regional): imporvements to e-Way Bill JSON generation
2019-05-06 12:31:31 +05:30
Sagar Vora
5fb78a375d fix(regional): imporvements to e-Way Bill JSON generation
- Set pincode correctly in 'Bill To - Ship To' scenario
- Python 2 compatibility (convert to int after rounding)
- Avoid execeeding max character limit in tax amounts by rounding the same to two digits
2019-05-06 11:32:37 +05:30
Tyler Matteson
8fcad571f5 Batch naming series hotfix (#17483)
* fix: not able to make sales order from quotation

* fix: batch naming series unicode() call is py3 incompatible
2019-05-04 23:49:24 +05:30
Himanshu
53d7e667dd fix: patch to remove scheduling tool (#17472)
* fix: patch to remove scheduling tool

* fix: drop table

* patch: delete table if exists

* fix: remove drop table

* use orm
2019-05-04 22:42:35 +05:30
Rohan
7395716476 fix(stock): Allow expired batches to be flushed out of the system (#17477) 2019-05-04 22:40:42 +05:30
Rushabh Mehta
c068b6a885 fix: unlink task rather than deleting it (#17486) 2019-05-04 22:39:00 +05:30
Himanshu Warekar
02d28c5908 fix: use list comprehension 2019-05-04 22:35:08 +05:30
Rohan
90d0d24d1a Update erpnext/selling/doctype/customer/customer.py
Co-Authored-By: hrwX <himanshuwarekar@yahoo.com>
2019-05-04 22:35:08 +05:30
Himanshu Warekar
6aab14f9cf fix: calculate percentage only if sales team exist 2019-05-04 22:35:08 +05:30
Himanshu Warekar
163dbdca34 fix: allocated percentage should be equal to 100 2019-05-04 22:35:08 +05:30
Himanshu Warekar
dcc99a2644 fix: python side validation 2019-05-04 22:35:08 +05:30
Rohit Waghchaure
f5297cf386 fix: not able to make sales order from quotation 2019-05-03 15:52:59 +05:30
Mangesh-Khairnar
9f7fd16882 feat: unlink task from project on task deletion 2019-05-03 13:06:19 +05:30
Nabin Hait
5296ab1f87 Merge pull request #16783 from sahil28297/default_warehouse_for_sales_return
fix: set warehouse from def_warehouse_for_sales_return in sales return
2019-05-03 09:27:04 +05:30
Nabin Hait
e42c14f2cb Merge pull request #17469 from rohitwaghchaure/fixed_sales_order_issue_from_quotation
fix: not able to make sales order from quotation
2019-05-03 09:19:27 +05:30
Rohit Waghchaure
4ef10fd6c3 fix: not able to make sales order from quotation 2019-05-03 01:48:45 +05:30
sahil28297
4c0d0e226b Merge branch 'hotfix' into default_warehouse_for_sales_return 2019-05-03 00:05:22 +05:30
Deepesh Garg
094caaa03e Merge pull request #17465 from deepeshgarg007/inactive-items-fix-hotfix
fix: Inactive sales item report fix
2019-05-02 23:50:57 +05:30
Nabin Hait
ce51553d29 Merge pull request #16907 from sagarvora/ewb
feat(regional 🇮🇳): auto-generate e-Way Bill JSON from Sales Invoice
2019-05-02 21:55:24 +05:30
Nabin Hait
f41fc21274 Merge branch 'hotfix' into ewb 2019-05-02 21:54:38 +05:30
Nabin Hait
1f49b77529 Merge pull request #17460 from nabinhait/project-billing-summary
Refactor: Project billing summary Report
2019-05-02 21:48:35 +05:30
deepeshgarg007
a0012f8c48 fix: Inactive sales item report fix 2019-05-02 19:32:43 +05:30
Nabin Hait
4b1c3ad7ac refactor: Project and billing summary 2019-05-02 18:11:27 +05:30
Anurag Mishra
a063803224 fix: handling case if from date and to date are equal in billing reports 2019-05-02 18:11:27 +05:30
Saurabh
63c7fd90a5 Merge branch 'hotfix' 2019-05-02 16:58:24 +05:30
Saurabh
c9c02c7c85 bumped to version 11.1.25 2019-05-02 17:28:24 +06:00
Nabin Hait
bc7ef1937e Merge pull request #17455 from saurabh6790/multiple_fixes
fix: data pulling based on quotation_to and party_name
2019-05-02 16:01:07 +05:30
Nabin Hait
75b63c5b4c Update set_missing_title_for_quotation.py 2019-05-02 16:00:49 +05:30
Saurabh
625191d20a fix: provision to setup customer name on quotation save and patch for the same 2019-05-02 15:50:01 +05:30
Saurabh
1417c7e828 fix: data pulling based on quotation_to and party_name 2019-05-02 14:12:29 +05:30
Saurabh
a4bbc68945 Merge pull request #17449 from nabinhait/auto-account-creation-company-tree
fix: Validate parent account of child company while creating new account based on parent company
2019-05-02 12:26:23 +05:30
Nabin Hait
2d7a591c61 Merge branch 'hotfix' into auto-account-creation-company-tree 2019-05-02 09:56:53 +05:30
Nabin Hait
448a5e1c9c Merge pull request #17447 from nabinhait/woocommerce-multilingual-fix
fix: Multilingual handling in woocommerce integration
2019-05-01 21:15:07 +05:30
Nabin Hait
7be75adc3f fix: Validate parent account of child company while creating new account based on parent company 2019-05-01 20:26:09 +05:30
Nabin Hait
fafee7cf61 Merge pull request #17442 from nabinhait/work-order-bom-image
feat: Added item image in work order and bom
2019-05-01 19:31:24 +05:30
Nabin Hait
16aa23e454 fix: Multilingual handling in woocommerce integration 2019-05-01 19:17:59 +05:30
Nabin Hait
a4d5c5414d Merge pull request #17440 from nabinhait/pe-ref-exchange-rate
fix: Always fetch exchange rate from ref document
2019-05-01 17:17:46 +05:30
Nabin Hait
b104e3595c feat: Added item image in work order and bom 2019-05-01 16:37:32 +05:30
Deepesh Garg
5a06dd1ed1 Merge pull request #17436 from deepeshgarg007/name_fix_hotfix
fix: Rename Inactive Items report to Inactive Sales Items
2019-05-01 16:29:02 +05:30
Nabin Hait
27fe55efe1 fix: Always fetch exchange rate from ref document 2019-05-01 15:32:30 +05:30
Sahil Khan
9089b242ba Merge branch 'hotfix' 2019-05-01 15:13:26 +05:30
Sahil Khan
98e511d236 bumped to version 11.1.24 2019-05-01 15:33:26 +05:50
Nabin Hait
93784d3804 Merge pull request #17430 from nabinhait/deferred-accounting-long-job
fix: Deferred accounting posting moved to long job
2019-05-01 15:04:34 +05:30
Deepesh Garg
d29fde0bf3 Merge branch 'hotfix' into name_fix_hotfix 2019-05-01 14:27:17 +05:30
deepeshgarg007
8326925fe8 fix: Rename Inactive Items report to Inactive Sales Item 2019-05-01 13:36:08 +05:30
rohitwaghchaure
a924b636a4 Merge pull request #17412 from rohitwaghchaure/dont_raise_user_permissions
fix: while saving employee user getting user permissions error
2019-05-01 12:54:52 +05:30
Nabin Hait
3526ed975c moved deferred accounting monthly job to long job 2019-05-01 12:49:44 +05:30
Nabin Hait
d0faec3cc0 Merge pull request #17419 from nabinhait/ss-fixes
fix: a few fixes in payroll
2019-05-01 12:16:51 +05:30
Nabin Hait
661a5ce332 Merge pull request #17393 from ashish-greycube/hotfix_allow_bank_account_import
fix: allow_import_of_bank_account_by_account_manager
2019-05-01 11:17:12 +05:30
Nabin Hait
b289aa3548 Merge pull request #17387 from ashish-greycube/hotfix_correct_party_type
fix: show only party_type doctypes in Party Type field of bank account
2019-05-01 11:15:16 +05:30
Nabin Hait
4ed521162e Merge pull request #17374 from Alchez/hotfix-projects-subject-filter
feat(projects): Add subject filter to Issue and Task
2019-05-01 11:14:08 +05:30
Nabin Hait
8865d05b4c Merge pull request #17396 from chdecultot/hotfix
fix: Multiple corrections to the bank reconciliation tool
2019-05-01 10:59:39 +05:30
Nabin Hait
4df46737ef fix: Null handling 2019-05-01 10:59:02 +05:30
Deepesh Garg
09ac547a80 Merge branch 'hotfix' into ewb 2019-04-30 10:28:57 +05:30
Faris Ansari
c1bbaf07a4 fix: Map item_code to title (#17402)
When making Project from Sales Order, description was mapped
with Task title which can exceed 140 characters easily.
Description should be mapped with description as it is html field.
2019-04-30 10:18:39 +05:30
Nabin Hait
73a081d806 fix: a few fixes in payroll 2019-04-30 00:48:11 +05:30
rohitwaghchaure
e1df414f8b Merge pull request #17413 from rohitwaghchaure/duplicate_error_if_project_created_from_so
hotfix: while making project from sales order, getting duplicate project error
2019-04-29 22:21:18 +05:30
Rohit Waghchaure
cefef3b62d fix: while making project from sales order, getting duplicate project error 2019-04-29 20:34:46 +05:30
Rohit Waghchaure
bd4ee60d1e fix: while saving employee user getting user permissions error 2019-04-29 19:11:17 +05:30
Sahil Khan
f6a9eec23a Merge branch 'hotfix' 2019-04-29 16:35:41 +05:30
Sahil Khan
5742d0836e bumped to version 11.1.23 2019-04-29 16:55:41 +05:50
deepeshgarg007
0555223797 Merge branch 'hotfix' of https://github.com/frappe/erpnext into ewb 2019-04-28 23:22:48 +05:30
deepeshgarg007
de93efb304 fix: Added test cases for ewb json creation 2019-04-28 23:14:02 +05:30
deepeshgarg007
6856033d3c fix: Code cleanup and fixes 2019-04-28 23:13:26 +05:30
Faris Ansari
0735165ef3 fix: Make Customer and lead field dynamic in quotations and opportunity (#17097)
fix: Make Customer and lead field dynamic in quotations and opportunity
2019-04-28 20:46:39 +05:30
Charles-Henri Decultot
1aa5462f36 fix: Codacy 2019-04-26 20:52:12 +02:00
Charles-Henri Decultot
f83514418e fix: Codacy 2019-04-26 20:02:38 +02:00
Charles-Henri Decultot
3c13e8e8b9 fix: Codacy 2019-04-26 18:56:47 +02:00
Charles-Henri Decultot
de0955b8ed fix: Remove leftover method 2019-04-26 14:59:10 +02:00
Charles-Henri Decultot
82af75b853 fix: Hotfix conflict resolved 2019-04-26 14:57:26 +02:00
ashish-greycube
d3ce4f815d codacy review 2019-04-26 14:10:46 +05:30
ashish-greycube
85bf9203ed fix: allow_import_of_bank_account_by_account_manager 2019-04-26 13:56:26 +05:30
rohitwaghchaure
db12f75681 Merge pull request #17385 from surajshetty3416/fix-price-list-hotfix
fix: Price list conversion for other UOM from stock UOM item price
2019-04-26 12:06:46 +05:30
ashish-greycube
3763169978 show only party_type doctypes in Party Type field of bank account 2019-04-26 11:51:34 +05:30
Suraj Shetty
36a8c431f5 fix: Typo 2019-04-26 11:18:26 +05:30
Suraj Shetty
5a67431daa fix: Price list for UOM other than stock UOM
-Fixes conversion from default UOM item price to  other UOMs
2019-04-26 11:18:13 +05:30
Nabin Hait
c4e92b3004 Merge pull request #17381 from nabinhait/tax-exemption
refactor: Employee Tax Exemption
2019-04-26 00:20:27 +05:30
Nabin Hait
2d90e8a2de fix: test cases 2019-04-25 22:29:49 +05:30
Sagar Vora
4dacb89da6 fix: test case and semicolons 2019-04-25 21:45:26 +05:30
Sagar Vora
c552d74746 fix: add missing semicolons 2019-04-25 21:45:26 +05:30
Sagar Vora
2697c2d869 feat(regional | India): auto-generate e-Way Bill JSON from Sales Invoice 2019-04-25 21:45:26 +05:30
Nabin Hait
4a950abf2e fix: test cases 2019-04-25 21:42:01 +05:30
Nabin Hait
7f10f4eea1 Merge pull request #17376 from rohitwaghchaure/trial_balance_error_finance_book
fix: Trial balance finance book issue
2019-04-25 20:35:09 +05:30
Nabin Hait
5c93260eec Merge pull request #17377 from nabinhait/pos-advance-hotfix
fix: Don't allocate advance if pos
2019-04-25 20:33:59 +05:30
Nabin Hait
485d9c133a fix: test cases 2019-04-25 19:54:20 +05:30
deepeshgarg007
891d9aeee9 fix: Remove method from validation 2019-04-25 19:19:01 +05:30
deepeshgarg007
51f0d6d409 fix: Remove fetch_from from customer name 2019-04-25 18:56:08 +05:30
Nabin Hait
80374be724 refactor: Employee Tax Exemption 2019-04-25 18:44:32 +05:30
Nabin Hait
619bf561da fix: Don't allocate advance if pos 2019-04-25 17:47:26 +05:30
Rohit Waghchaure
13c15d0222 fix: Trial balance finance book issue 2019-04-25 17:46:44 +05:30
deepeshgarg007
3d31bccaf6 fix: Change dynamic_field name and minor fixes 2019-04-25 17:29:21 +05:30
deepeshgarg007
3959bf34ee Merge branch 'hotfix' of https://github.com/frappe/erpnext into quotation-fix 2019-04-25 14:57:56 +05:30
Rohan Bansal
1897ed38df enhance(projects): Add subject filter to Issue and Task 2019-04-25 13:40:24 +05:30
rohitwaghchaure
126c4efb2c Merge pull request #17357 from rohitwaghchaure/fix_straight_line_asset_depreciation
fix: Straight line asset depreciation not working if Expected Value After Useful Life is defined
2019-04-25 10:23:15 +05:30
Rohit Waghchaure
7d0bc2bd5a fixed test cases 2019-04-25 01:26:27 +05:30
Nabin Hait
0c70ae44c3 Merge pull request #17366 from chdecultot/plaid_settings_erro
fix: Better error message for plaid setting
2019-04-24 23:15:02 +05:30
Nabin Hait
024dc4a4c9 Merge pull request #17316 from alyf-de/validate_iban
feat(accounts): validate Bank Account's IBAN
2019-04-24 22:57:04 +05:30
Deepesh Garg
e3567ff31b Merge pull request #17363 from deepeshgarg007/inactive_items_hotfix
feat: Inactive items hotfix
2019-04-24 22:30:34 +05:30
Charles-Henri Decultot
97842ca804 fix: cleanup development 2019-04-24 18:15:46 +02:00
Charles-Henri Decultot
d14799a9fa fix: verbose error message when api keys are not setup 2019-04-24 18:06:14 +02:00
Raffael Meyer
e534221245 fix: validate IBAN only if it exists 2019-04-24 16:22:34 +02:00
Raffael Meyer
4acfec901e Merge branch 'hotfix' of https://github.com/frappe/erpnext into validate_iban 2019-04-24 16:15:35 +02:00
deepeshgarg007
a981a8a153 fix: Ignore sql injections 2019-04-24 15:14:23 +05:30
deepeshgarg007
11e1c60cd3 fix: Typo fixes 2019-04-24 15:13:59 +05:30
deepeshgarg007
fe7baae9f7 fix: Ordering and datatype fixes in inactive items report 2019-04-24 15:13:46 +05:30
deepeshgarg007
da64113b9a feat: Logic for query and report creation for inactive items 2019-04-24 15:13:28 +05:30
rohitwaghchaure
fb76cb7a78 Merge pull request #17301 from rohitwaghchaure/fixed_received_qty_showing_incorrect
fix: received qty in the purchase order item showing incorrect if user has returned the rejected quantity
2019-04-24 15:08:39 +05:30
deepeshgarg007
1bd69b4490 fix: Reordered and deleted unnecessary filters 2019-04-24 15:06:48 +05:30
deepeshgarg007
62d51d82ea feat: Added filters and columns for inactive items report 2019-04-24 15:06:23 +05:30
deepeshgarg007
6ddc554965 fix: Merge branch hotfix into quotation-fix 2019-04-24 14:53:30 +05:30
Rohit Waghchaure
5a6fc77751 fix: Straight line asset depreciation not working if Expected Value After Useful Life is defined 2019-04-24 11:40:37 +05:30
Nabin Hait
733db826ec Merge branch 'hotfix' into validate_iban 2019-04-23 21:39:47 +05:30
Nabin Hait
6643156df6 Merge pull request #17347 from nabinhait/invoice-advances
fix: don't allocate advances if POS
2019-04-23 21:36:29 +05:30
Nabin Hait
6022f2bcf8 Merge pull request #17349 from rohitwaghchaure/invoiced_items_gross_margin_api
feat: Get invoiced item's gross margin using API
2019-04-23 21:36:02 +05:30
Nabin Hait
b890492dc0 Merge pull request #17348 from nabinhait/salary-slip-rounded
fix: Rounded tax amount in salary slip
2019-04-23 21:35:34 +05:30
Rohit Waghchaure
6ea108f01d feat: Get invoiced item's gross margin using API 2019-04-23 18:56:26 +05:30
Nabin Hait
628bed1f5a Merge branch 'hotfix' into validate_iban 2019-04-23 18:41:46 +05:30
Nabin Hait
4c0e3aa097 Merge pull request #17341 from Alchez/hotfix-return-deliveries
fix(selling): Fix method to also consider return documents
2019-04-23 18:41:11 +05:30
Nabin Hait
631e334a3f Merge pull request #17338 from chdecultot/bank_reco_corrections
fix: Bank reconciliation corrections
2019-04-23 18:40:03 +05:30
Nabin Hait
8f4d92eba2 Merge branch 'hotfix' into validate_iban 2019-04-23 18:39:12 +05:30
Deepesh Garg
2f4193757e fix: Stock Ledger report fix (#17342) 2019-04-23 17:30:11 +05:30
Rohan Bansal
cba64988df fix(selling): Fix method to also consider return documents 2019-04-23 16:39:09 +05:30
Nabin Hait
17e6fce486 fix: Rounded tax amount in salary slip 2019-04-23 15:51:28 +05:30
Nabin Hait
05fb3f2d75 fix: don't allocate advances if POS 2019-04-23 15:49:38 +05:30
Charles-Henri Decultot
af6360b273 Bank reconciliation corrections 2019-04-23 10:44:32 +02:00
Nabin Hait
043a47a9c0 Merge pull request #17329 from nabinhait/income-tax-period-factor
fix: Income tax period factor considering joining and relieving date
2019-04-22 21:07:35 +05:30
Nabin Hait
23e424911c Merge branch 'hotfix' into income-tax-period-factor 2019-04-22 19:28:13 +05:30
Saif
a5fbeaa3d3 fix: Remove duplicate/incorrect patch 2019-04-22 17:29:36 +05:00
Nabin Hait
8aeb4b04bd fix: Income tax period factor considering joining and relieving date 2019-04-22 17:39:02 +05:30
Raffael Meyer
f3b07495f6 fix typo 2019-04-22 12:38:22 +02:00
Raffael Meyer
70f89462a8 fix: consider empty iban 2019-04-22 12:27:12 +02:00
Nabin Hait
bbc7f474c3 Merge pull request #17327 from Anurag810/bom_prowser_patch
fix: (Patch) Removed page Bom-Browser
2019-04-22 15:54:35 +05:30
Anurag Mishra
26fe30685a Merge branch 'hotfix' into bom_prowser_patch 2019-04-22 15:53:05 +05:30
Anurag Mishra
7bdd27f7e2 chore: used frappe.delete_doc_if_exists 2019-04-22 15:50:12 +05:30
Nabin Hait
96ec4aeda0 Merge pull request #17287 from alyf-de/skr
feat(accounts): add German CoA with numbers
2019-04-22 15:44:48 +05:30
Nabin Hait
cce65d41b8 Merge pull request #17325 from PawanMeh/fix_17324
fix: Attendance list not showing employee name
2019-04-22 14:58:07 +05:30
Nabin Hait
a44d46e535 Merge pull request #17320 from nabinhait/payroll-based-on-payment-days
fix: Renamed depends_on_lwp to depends_on_payment_days
2019-04-22 14:55:37 +05:30
Anurag Mishra
4f16d17d21 fix: resolve conflicts 2019-04-22 14:48:15 +05:30
Anurag Mishra
759bb0eb62 fix: (Patch) Removed page Bom-Browser 2019-04-22 14:46:00 +05:30
Sahil Khan
d7aa71aa70 Merge branch 'hotfix' 2019-04-22 14:05:43 +05:30
Sahil Khan
32207ad722 bumped to version 11.1.22 2019-04-22 14:25:43 +05:50
hello@openetech.com
8a32ad206a [fix] #17324 2019-04-22 13:58:30 +05:30
Nabin Hait
16bd2ed967 Merge pull request #17319 from SaiFi0102/Revert-Allocate-Advance-Automatically-V11
fix: Set Allocate Advance Automatically disabled by default (v11)
2019-04-22 13:32:30 +05:30
Nabin Hait
393b12a37f fix: Renamed depends_on_lwp to depends_on_payment_days 2019-04-22 13:26:18 +05:30
Saif Ur Rehman
d1332f6c24 fix: Set Allocate Advance Automatically disabled by default 2019-04-22 12:17:25 +05:00
Saurabh
f36fa088f8 Merge pull request #17302 from sahil28297/new_site_sync
fix(site_sync): return more data in level
2019-04-22 12:46:20 +05:30
Saurabh
bb63103183 Merge branch 'hotfix' into new_site_sync 2019-04-22 12:46:13 +05:30
Anurag Mishra
7760db7563 fix: sales order status for order type 'Maintenance' (#17119)
* fix: Sales order Status for order type 'Maintenance'

* fix: test case for sales order
2019-04-22 12:15:11 +05:30
Saurabh
0ea1e67ce7 Merge branch 'hotfix' into new_site_sync 2019-04-22 11:59:52 +05:30
Nabin Hait
587b52dcd4 Merge pull request #17080 from auliabismar/patch-2
fix: Renumber Aktiva, remove excess 0
2019-04-22 11:21:15 +05:30
Nabin Hait
efcdf2fd42 Merge pull request #17255 from Anurag810/patch_for_salary_structure
fix: (Patch)make salary details submitable if  salary structure is submitted
2019-04-22 11:17:28 +05:30
Nabin Hait
010a05df48 Update set_salary_details_submitable.py 2019-04-22 11:16:47 +05:30
Nabin Hait
58d565a882 Merge pull request #17292 from prasadarr/listing-fixes-hotfix
fix: Allow system manager to access share ledger
2019-04-22 11:13:17 +05:30
Nabin Hait
b655b07f20 Merge pull request #17309 from nabinhait/ar-fix
fix: Total row alignment in AR report
2019-04-22 11:11:45 +05:30
Sahil Khan
afb59fa5c0 fix: import iteritems 2019-04-22 10:47:37 +05:30
Sahil Khan
cb4b86512d fix: syntax error 2019-04-22 10:10:50 +05:30
Raffael Meyer
49f919a4fc fix test's error message 2019-04-22 05:32:35 +02:00
Raffael Meyer
4a9127f9a6 feat(accounts): validate IBAN 2019-04-22 03:46:25 +02:00
Kenneth Sequeira
c4a670c8c8 add salutation in contact display for lead (#17312) 2019-04-20 21:44:38 +05:30
Don-Leopardo
a26e2c064a fix: Campaign Efficiency report only works in english (#17284)
* fix column translation and match

* fix float results

* fix import missing
2019-04-20 21:12:23 +05:30
Sahil Khan
3889d0f0a0 fix: refactor level 2019-04-20 14:26:49 +05:30
rohitwaghchaure
4e81fb20b9 fix: Move erpnext related methods from frappe to erpnext (#17293) 2019-04-20 11:50:45 +05:30
Nabin Hait
dfba52b834 fix: Total row alignment in AR report 2019-04-20 10:57:04 +05:30
Nabin Hait
f665e42e2a Merge pull request #17307 from surajshetty3416/fix-employee-permission-hotfix
fix: Do not create employee user permission if already exists
2019-04-20 01:44:30 +05:30
Nabin Hait
560cc66a36 Merge pull request #17279 from rohitwaghchaure/requested_items_tobe_ordered_issue_for_multi_uom
fix: Requested Items To Be Ordered report showing records even if material request is fully ordered
2019-04-20 01:27:46 +05:30
Suraj Shetty
d08953b72b fix: Do not create employee user permission if already exists 2019-04-19 21:57:39 +05:30
Nabin Hait
34e4ac2398 Merge pull request #17296 from chdecultot/reconciliation_correction
fix: Reconciliation dashboard py3 and matching corrections
2019-04-19 20:02:54 +05:30
Sahil Khan
3dbaa3c2d9 fix(site_sync): return more data in level 2019-04-19 16:31:23 +05:30
Rohit Waghchaure
613d82e12f fix: received qty in the purchase order item showing incorrect if user has returned the rejected quantity 2019-04-19 16:28:19 +05:30
Anurag Mishra
4f2fa173c9 fix: Reopen button does not appear in delivery note (#17295) 2019-04-19 16:06:17 +05:30
Rohit Waghchaure
d36e635e60 fix: Requested Items To Be Ordered report showing records even if material request is fully ordered 2019-04-19 14:54:59 +05:30
Charles-Henri Decultot
e86d21ea15 Py3 and matching corrections 2019-04-19 10:09:06 +02:00
Nabin Hait
b6fda118b9 Merge pull request #17280 from rohitwaghchaure/credit_will_not_be_converted_if_debit_amount_is_there
fix: credit amount not be consider if debit amount is present in the general ledger
2019-04-19 13:18:33 +05:30
Nabin Hait
2c607e5562 Merge pull request #17290 from alyf-de/company_test
fix(test): provide a helpful error message
2019-04-19 13:10:46 +05:30
Nabin Hait
95fce2395a Merge pull request #17289 from sunhoww/patch-1
fix: scan_barcode field adding invalid items
2019-04-19 13:07:06 +05:30
Nabin Hait
0f0dcd9035 Merge pull request #17285 from rohitwaghchaure/task_not_able_to_search_by_name_in_global_search
fix: task name was not able to search by name in global search
2019-04-19 13:06:24 +05:30
Prasad R
ce291c253b fix: show 2 missing doctypes in Accounts
Pricing Term and Exchage Rate Revaluation were missing, added in list
2019-04-19 12:17:19 +05:30
Prasad R
e85c6ad236 fix: Allow system manager to access share ledger 2019-04-19 11:07:19 +05:30
Raffael Meyer
df16cdcf31 fix(test): provide a helpful error message 2019-04-19 00:21:44 +02:00
Raffael Meyer
00303858df fix missing account types 2019-04-19 00:09:37 +02:00
Sun Howwrongbum
6f54a7b7d8 fix: scan_barcode field adding invalid items 2019-04-19 01:44:35 +05:30
Raffael Meyer
fba8bfc0d0 feat(accounts): add German CoA with numbers 2019-04-18 21:27:22 +02:00
Rohit Waghchaure
4db4f21d16 fix: task name was not able to search by name in global search 2019-04-18 22:34:19 +05:30
Rohit Waghchaure
abf9ef0244 fix: credit amount in account's currency not be consider if debit amount is present in the general ledger 2019-04-18 22:01:45 +05:30
Suraj Shetty
22ad81fb57 fix: Remove unwanted parent & parenttype field (#17274) 2019-04-18 15:45:47 +05:30
Shivam Mishra
923c5462a2 Merge pull request #17267 from fproldan/bundlestock
fix: Incorrect stock in "Available Stock for Packing Items" report
2019-04-18 11:04:55 +05:30
Francisco Roldán
753b3d1c28 Merge branch 'hotfix' into bundlestock 2019-04-17 11:44:40 -03:00
Nabin Hait
b9046edb85 Merge pull request #17259 from frappe/kennethsequeira-patch-2
fix: Improve Validation Message in BOM
2019-04-17 17:35:42 +05:30
Nabin Hait
7932eba733 Merge pull request #17266 from deepeshgarg007/gstr1-fixes
fix: GSTR-1 B2C Small report fix
2019-04-17 17:33:20 +05:30
Nabin Hait
5ba9c82922 Merge branch 'hotfix' into gstr1-fixes 2019-04-17 17:33:13 +05:30
Nabin Hait
fc48ce7073 Merge pull request #17250 from saurabh6790/patches_fix
fix: woocommerce settings patch
2019-04-17 17:30:14 +05:30
Nabin Hait
8b9a84b568 Merge pull request #17253 from hrwX/sales_percentage_validate_v11
fix(Customer): validate percentage total
2019-04-17 17:28:50 +05:30
Deepesh Garg
cc4e6a25f6 Merge branch 'hotfix' into kennethsequeira-patch-2 2019-04-17 17:24:45 +05:30
Nabin Hait
17923863de Merge pull request #17256 from Anurag810/fix_report_of_billing_summary
fix: total error arissing due to blank link field
2019-04-17 17:23:19 +05:30
Nabin Hait
32efea5e38 Merge pull request #17262 from rohitwaghchaure/finance_book_blank_issue
fix: If finance book filter is not set then show all the entries
2019-04-17 17:22:33 +05:30
Deepesh Garg
87a2c1d27d Merge branch 'hotfix' into kennethsequeira-patch-2 2019-04-17 17:16:37 +05:30
Nabin Hait
c581d67bba Merge pull request #17120 from hrwX/payment_terms_fix
fix(Purchase Order): fetch payment terms
2019-04-17 17:16:30 +05:30
Himanshu Warekar
1865e0df7c refactor: fetch payment terms in account settings 2019-04-17 15:35:43 +05:30
Himanshu Warekar
308ae1f155 fix: codacy fixes 2019-04-17 12:42:00 +05:30
Himanshu Warekar
2b54cee4aa fix: test case 2019-04-17 12:09:19 +05:30
Himanshu Warekar
ba47f89702 fix: added a single value to fetch payment terms 2019-04-17 11:24:04 +05:30
Nabin Hait
0ea32faf3d Merge pull request #17268 from nabinhait/pr-pi-onload
fix: Pull items from PR to PI
2019-04-16 23:45:48 +05:30
Nabin Hait
7be0736154 fix: removed debug 2019-04-16 23:05:39 +05:30
Nabin Hait
97bf12734a fix: Pull items from PR to PI 2019-04-16 22:11:22 +05:30
deepeshgarg007
4bccd692e5 fix: GSTR1 B2C report fix 2019-04-16 20:50:46 +05:30
NahuelOperto
07f8e6bbfc fix get_item_warehouse_quantity_map method 2019-04-16 11:36:19 -03:00
Rohit Waghchaure
15c7a05879 fix: If finance book filter is not set then show all the entries 2019-04-16 19:28:11 +05:30
Kenneth Sequeira
82e76b2c0c Improve Validation Message in BOM
Update following validation message:

"Price not found for item {0} and price list {1}" to "Price not found for item {0} in price list {1}"
2019-04-16 18:00:21 +05:30
Anurag Mishra
d6757b7af6 fix: total error arissing due to blank link field 2019-04-16 17:39:40 +05:30
Anurag Mishra
b380a02d09 fix: make salary details submitable if salary structure is submitted 2019-04-16 17:07:07 +05:30
Himanshu Warekar
64980fed59 fix: validate percentage total 2019-04-16 16:29:13 +05:30
Saurabh
7df8c0ef82 fix: woocommerce settings patch 2019-04-16 15:57:21 +05:30
deepeshgarg007
28fe73640b fix: Dynamic link fixes in quotation and opportunity 2019-04-16 15:21:51 +05:30
deepeshgarg007
7cfe247b2e Merge branch 'hotfix' of https://github.com/frappe/erpnext into quotation-fix 2019-04-16 13:38:15 +05:30
Saurabh
332b4171c0 Merge branch 'hotfix' 2019-04-16 13:34:52 +05:30
Saurabh
e5544b8c86 bumped to version 11.1.21 2019-04-16 14:04:52 +06:00
sahil28297
38e5e7f616 Merge pull request #17246 from frappe/revert-17226-site_sync
Revert "feat(site_sync): return erpnext data in level"
2019-04-16 13:22:18 +05:30
sahil28297
a595346769 Revert "feat(site_sync): return erpnext data in level" 2019-04-16 13:21:24 +05:30
Nabin Hait
8dace802dc Merge pull request #17243 from hrwX/delivery_note_fix_v11
fix(Delivery Note): show get items even if note has been amended
2019-04-16 13:19:58 +05:30
Himanshu
ac6259dfe8 fix: let user delete the elements of items 2019-04-16 13:18:47 +05:30
Nabin Hait
f801cc953b Merge pull request #17152 from Alchez/hotfix-company-address-label
fix(selling): Add missing label to company address field
2019-04-16 12:45:40 +05:30
Nabin Hait
c117048fde Merge pull request #17226 from sahil28297/site_sync
feat(site_sync): return erpnext data in level
2019-04-16 12:42:45 +05:30
Himanshu Warekar
73fd508ccf fix: show get items even if note has been amended 2019-04-16 12:15:49 +05:30
deepeshgarg007
7cc972969f Merge branch 'hotfix' of https://github.com/frappe/erpnext into quotation-fix 2019-04-16 10:32:32 +05:30
Nabin Hait
734c32b970 Merge pull request #17234 from nabinhait/pr-to-pi
fix: Invoice against partially returned DN/PR
2019-04-16 09:46:24 +05:30
Nabin Hait
fa862e6814 Merge pull request #17236 from hrwX/remove_asset_permission_v11
fix(Asset): Remove user permission for employee in asset
2019-04-16 09:45:54 +05:30
deepeshgarg007
d333d2e6eb fix: Change enquiry_from to opportunity_from in multiple files 2019-04-16 08:53:29 +05:30
deepeshgarg007
3b78a018aa Merge branch hotfix into quotation-fix 2019-04-16 08:46:07 +05:30
Himanshu Warekar
cc581d21f0 Merge branch 'hotfix' of https://github.com/frappe/erpnext into remove_asset_permission_v11 2019-04-15 22:54:42 +05:30
Himanshu Warekar
dca60888ce fix: remove user permission for emp in asset 2019-04-15 22:52:50 +05:30
Nabin Hait
b2465c7a69 fix: Invoice against partially returned DN/PR 2019-04-15 21:02:16 +05:30
rohitwaghchaure
14477c7f51 Merge pull request #17161 from karthikeyan5/hotfix-woocommerce-fix
fix(woocommerce integration): 403 error and adding defaults
2019-04-15 19:34:54 +05:30
deepeshgarg007
f0ee3d26a7 fix: Styling and indentation fixes 2019-04-15 19:20:59 +05:30
deepeshgarg007
2e87202b3b Merge branch 'hotfix' of https://github.com/frappe/erpnext into quotation-fix 2019-04-15 17:58:54 +05:30
Nabin Hait
1024b55f99 Fixed merge conflict 2019-04-15 11:41:54 +05:30
Nabin Hait
9ba7b678fe fix: Bank reconciliation cleanup 2019-04-15 11:33:06 +05:30
Nabin Hait
a8c8e6b78a Merge pull request #16752 from nabinhait/limit-cond-fix
fix: Limit conditions while fetching payment entries
2019-04-15 10:19:12 +05:30
Nabin Hait
ce107086e7 Merge pull request #17083 from frappe/revert-16926-salary-slip-fix
Revert "fix(Salary Slip): Consider Leave without Pay for calculation"
2019-04-15 10:14:12 +05:30
Nabin Hait
49d1449d2b Merge branch 'hotfix' into revert-16926-salary-slip-fix 2019-04-15 10:13:31 +05:30
Nabin Hait
2f6789e54d Merge pull request #17228 from rohitwaghchaure/pos_not_working_if_user_can_access_more_than_one_company
fix: POS not working if user has access of multiple company
2019-04-15 10:12:51 +05:30
Nabin Hait
f2893e5701 Merge pull request #17215 from nabinhait/project-task-opt-tests
perf: Project task optimization
2019-04-15 10:11:16 +05:30
Nabin Hait
870410c9d5 Merge pull request #17180 from netchampfaris/duplicate-variant-check
fix: Validate variant attributes only if is_new
2019-04-15 10:09:12 +05:30
Nabin Hait
30bca30f20 Merge pull request #17185 from netchampfaris/allow-items-not-in-stock
feat: Allow items not in stock to be added in cart
2019-04-15 10:07:49 +05:30
Nabin Hait
80f1d5f63d Merge pull request #17217 from netchampfaris/item-price-packing-unit
fix: Set default value for Packing Unit as 0
2019-04-15 10:07:00 +05:30
Nabin Hait
76d4fa9f2b Merge pull request #17221 from nabinhait/supplier-sales-analytics
fix: supplier wise sales analytics report
2019-04-15 10:05:43 +05:30
Nabin Hait
e17c9d9978 Merge pull request #17225 from hrwX/naming_series
fix(Naming Series): Naming series
2019-04-15 10:05:17 +05:30
deepeshgarg007
e8883e20cd fix: Remove comments and unused code 2019-04-14 22:39:58 +05:30
deepeshgarg007
af4d588f64 fix: Make customer and lead dynamic_link in opportunity 2019-04-14 22:35:06 +05:30
deepeshgarg007
4f0a4a1a2d fix: Customer dashboard fixes 2019-04-14 22:35:06 +05:30
deepeshgarg007
7f7a1b48ed fix: Test case fixes inquotation 2019-04-14 22:35:06 +05:30
deepeshgarg007
f094662f5e fix:Test record fixes 2019-04-14 22:35:06 +05:30
deepeshgarg007
e2fc03e561 fix: Cart test fixes 2019-04-14 22:35:06 +05:30
deepeshgarg007
3987b4b714 fix: Test case fixes in quotation for dynamic column 2019-04-14 22:35:06 +05:30
deepeshgarg007
e889a58724 fix: Patch typo fix and set label in refresh 2019-04-14 22:35:06 +05:30
deepeshgarg007
1638994436 fix: Patch to move customer and lead 2019-04-14 22:35:06 +05:30
deepeshgarg007
ff99492481 fix: Removed redundant code 2019-04-14 22:33:52 +05:30
deepeshgarg007
a65af7a2f5 fix: Quotation Query fix in lead.py 2019-04-14 22:33:52 +05:30
deepeshgarg007
6ba48c58e8 fix: Server side handling of quotation to and get_query fix 2019-04-14 22:33:52 +05:30
deepeshgarg007
3b4f481ca4 fix: Make Customer and lead field dynamic 2019-04-14 22:33:52 +05:30
Rohit Waghchaure
548e93b2d3 fix: POS not working if user has access of multiple company 2019-04-14 19:39:11 +05:30
sahil28297
76eb9b32b3 Merge branch 'hotfix' into site_sync 2019-04-14 18:58:28 +05:30
Sahil Khan
0ac4cfa9b1 fix(site_sync): remove duplicate entry 2019-04-14 18:29:49 +05:30
Himanshu
dc34393b8a fix: allow braces for custom field names 2019-04-14 00:54:24 +05:30
Himanshu
023a865e1e Merge pull request #7 from frappe/hotfix
Hotfix
2019-04-14 00:49:41 +05:30
rohitwaghchaure
d2a7ec1add Merge pull request #17222 from Anurag810/pay_fix
fix: handle for party type member in payment entry(v11)
2019-04-13 00:10:30 +05:30
rohitwaghchaure
564ee5399c Merge pull request #17223 from rohitwaghchaure/user_permissions_are_not_working_for_stock_ledger
fix: user permissions are not working on stock ledger report
2019-04-13 00:09:58 +05:30
karthikeyan5
2518a2ab16 fix(woocommerce integration): travis fix 2019-04-12 19:35:07 +05:30
karthikeyan5
df3e8853ae fix(woocommerce integration): fix strange travis error
the patch was working locally. But, in was failing on travis. The strange thing was that the patch running in travis was looking for woocommerce_settings in the path 'frappe.core.doctype.woocommerce_settings.woocommerce_settings'
2019-04-12 19:35:07 +05:30
karthikeyan5
a0b7ff60b8 fix(woocommerce integration): possible travis fix
possible fix for travis patch error "Error: No module named woocommerce_settings.woocommerce_settings)"
2019-04-12 19:35:07 +05:30
karthikeyan5
97383716e6 fix(woocommerce integration): error in new-site
resolving "Could not find UOM: Nos" error in travis
2019-04-12 19:35:06 +05:30
karthikeyan5
f788117b3e fix(woocommerce integration): defaults in settings 2019-04-12 19:35:06 +05:30
karthikeyan5
6784335e2c fix(woocommerce integration): fixing 403 error 2019-04-12 19:35:06 +05:30
Rohit Waghchaure
c5c9dc5f6d fix: user permissions are not working on stock ledger report 2019-04-12 16:58:51 +05:30
Nabin Hait
819e24ddde fix: supplier wise sales analytics report 2019-04-12 15:11:11 +05:30
Anurag Mishra
313ed4feeb fix: handle for party type member in payment entry 2019-04-12 15:11:06 +05:30
Nabin Hait
5157fa9233 Merge pull request #17150 from nabinhait/ar-credit-note
fix: Show standalone credit note in Accounts receivable report
2019-04-12 14:18:17 +05:30
Faris Ansari
774b96495f fix: Set default value for Packing Unit as 0
The default value 1 assumes Items will be always packed in integer
quantities. This is not the usual case.
2019-04-12 12:38:04 +05:30
Nabin Hait
ea4c2c9e7d fix: task optimisation and test case fixes 2019-04-12 11:33:28 +05:30
Nabin Hait
b42bbf1b6f perf: Optimisation of project and task updation 2019-04-12 11:33:28 +05:30
Nabin Hait
c768febac6 Merge pull request #17206 from rohitwaghchaure/fix_pending_so_items_for_purchase_reques_report
fix: Pending SO Items For Purchase Request report not showing the so, requested and pending quantity correctly
2019-04-12 11:11:16 +05:30
Nabin Hait
0449d30423 Merge pull request #16976 from ESS-LLP/patient_hotfix
fix: Patient relation - patient link is not showing
2019-04-12 11:08:24 +05:30
Nabin Hait
f17dfb0ebe Merge pull request #17157 from Anurag810/vedmata-print-fixes
fix: Vedmata print fixes
2019-04-12 11:07:24 +05:30
Nabin Hait
13273412d1 Merge pull request #17207 from Anurag810/timesheet_report_amount_fixes
fix: timesheet report not showing total amount correctly
2019-04-12 11:05:12 +05:30
Nabin Hait
f198b1d032 Merge branch 'hotfix' into timesheet_report_amount_fixes 2019-04-12 11:05:03 +05:30
Nabin Hait
9512a43d44 Merge pull request #17187 from nabinhait/rounding-adjustment-gle
fix: Rounding Adjustment GL Entry
2019-04-12 11:00:29 +05:30
Anurag Mishra
50db128ff1 fix: timesheet report not showing total amount correctly 2019-04-11 16:07:38 +05:30
Rohit Waghchaure
5eaf7d0517 fix: Pending SO Items For Purchase Request not showing the so quantity correctly if so has duplicate items 2019-04-11 13:56:40 +05:30
Nabin Hait
760b01912a Merge branch 'hotfix' into limit-cond-fix 2019-04-11 11:47:48 +05:30
Nabin Hait
4c331206f1 Merge branch 'hotfix' into patient_hotfix 2019-04-11 11:46:53 +05:30
Nabin Hait
02181c017a Merge branch 'hotfix' into patch-2 2019-04-11 11:46:39 +05:30
Nabin Hait
7fece8f431 Merge branch 'hotfix' into revert-16926-salary-slip-fix 2019-04-11 11:46:35 +05:30
Nabin Hait
bd7a165318 Merge branch 'hotfix' into ar-credit-note 2019-04-11 11:46:16 +05:30
Nabin Hait
4114365017 Merge branch 'hotfix' into hotfix-company-address-label 2019-04-11 11:46:08 +05:30
Nabin Hait
aace25ac2b Merge branch 'hotfix' into vedmata-print-fixes 2019-04-11 11:46:00 +05:30
Nabin Hait
697f1186c0 Merge branch 'hotfix' into duplicate-variant-check 2019-04-11 11:45:49 +05:30
Nabin Hait
55bee7a393 Merge branch 'hotfix' into allow-items-not-in-stock 2019-04-11 11:45:42 +05:30
Nabin Hait
c151b58acd Merge branch 'hotfix' into rounding-adjustment-gle 2019-04-11 11:45:36 +05:30
Sahil Khan
ef73452abe feat(sync_site): return erpnext data in levels 2019-04-10 15:29:49 +05:30
Nabin Hait
ff73090ad2 fix: Rounding Adjustment GL Entry 2019-04-09 19:24:54 +05:30
Faris Ansari
b63adcbac7 feat: Allow items not in stock to be added in cart 2019-04-09 18:41:31 +05:30
Faris Ansari
f492d5f61d fix: Validate variant attributes only if is_new 2019-04-09 15:19:10 +05:30
Anurag Mishra
4ac386d0fe fix: Removed Extra page on generating pdf in print formats 2019-04-08 11:43:24 +05:30
Anurag Mishra
4753bd4519 fix: UI on generating pdf in print format 2019-04-08 10:53:19 +05:30
Anurag Mishra
76815cf2be fix: removed before from accounts_controlle.pyr and fetch the gl from frontend 2019-04-06 12:07:40 +05:30
Rohan Bansal
936d147b4b fix(selling): Add missing label to company address field 2019-04-05 18:15:04 +05:30
Nabin Hait
20090306f6 fix: Show standalone credit note in Accounts receivable report 2019-04-05 18:06:07 +05:30
Anurag Mishra
a1a7beb12e fix: Print Auditing print format 2019-04-05 12:35:46 +05:30
Himanshu Warekar
9f2847e86c fix: test case fixes for travis 2019-04-05 11:40:37 +05:30
Himanshu Warekar
44d8224a3b fix: test case fix 2019-04-04 15:40:36 +05:30
Himanshu Warekar
5ba438af80 fix: fetch payment terms 2019-04-03 16:53:19 +05:30
Nabin Hait
3d6b51089c Revert "fix(Salary Slip): Consider Leave without Pay for calculation (#16926)"
This reverts commit 6343a697a2.
2019-04-01 11:09:55 +05:30
Aulia Bismar
f817663f11 Renumber Aktiva, remove excess 0 2019-04-01 10:37:28 +07:00
Nabin Hait
bf5ea691cf fixed merge conflict 2019-03-28 11:35:39 +05:30
Jamsheer
48e206d983 fix: Patient relation - patient link is not showing 2019-03-21 16:11:12 +05:30
Himanshu
bed6f4748e Merge pull request #6 from frappe/hotfix
Hotfix
2019-03-18 20:48:26 +05:30
Himanshu
ee2b523b31 Merge pull request #5 from frappe/hotfix
Hotfix
2019-03-09 00:05:50 +05:30
Sahil Khan
88bd0674ed fix: set warehouse from defalut_warehouse_for_sales_return in sales return 2019-02-26 14:50:20 +05:30
Nabin Hait
4ff2b0114f fix: Removed limit conditionas it does not make sense in get_outstanding function 2019-02-21 17:48:21 +05:30
Nabin Hait
e7cc6649eb fix: Limit conditions while fetching payment entries 2019-02-21 17:23:23 +05:30
Charles-Henri Decultot
4d19d344b6 Merge branch 'hotfix' into plaid_reconciliation 2019-02-19 11:05:00 +00:00
Himanshu
e7bc2beea0 Remove illegal character after break 2019-02-06 15:55:49 +05:30
Himanshu
cd416a3135 Merge pull request #4 from frappe/hotfix
Hotfix
2019-02-06 15:54:33 +05:30
Nabin Hait
2a0e8e24ec Merge branch 'staging-fixes' into plaid_reconciliation 2019-01-24 14:11:00 +05:30
Charles-Henri Decultot
c75300dc43 Purchase invoice modified date 2019-01-07 15:46:25 +00:00
Charles-Henri Decultot
8d36b362d1 Merge conflict resolution 2019-01-07 15:24:39 +00:00
Charles-Henri Decultot
4c57fae726 Codacy correction 2019-01-07 15:21:57 +00:00
Charles-Henri Decultot
2d1b5b0769 Codacy corrections 2019-01-07 15:21:57 +00:00
Charles-Henri Decultot
1d7646f31f Codacy corrections 2019-01-07 15:21:57 +00:00
Charles-Henri Decultot
1a19746904 Codacy corrections 2019-01-07 15:21:57 +00:00
Charles-Henri Decultot
e7fec6e659 Codacy corrections 2019-01-07 15:21:57 +00:00
Charles-Henri Decultot
c45e271b3e Add button to unlink bank account 2019-01-07 15:21:57 +00:00
Charles-Henri Decultot
422d483baf Move actions menu to standard menu 2019-01-07 15:21:57 +00:00
Charles-Henri Decultot
c56f771c81 UX corrections + additional tests 2019-01-07 15:21:57 +00:00
Charles-Henri Decultot
89923b84b1 UX enhancements 2019-01-07 15:21:57 +00:00
Charles-Henri Decultot
c936f07a1e Correct Travis error 2019-01-07 15:21:57 +00:00
Charles-Henri Decultot
aea2fbf82d Correct test case for Travis 2019-01-07 15:21:57 +00:00
Charles-Henri Decultot
7a1ea42271 Addition of test cases 2019-01-07 15:21:57 +00:00
Charles-Henri Decultot
58438f4e5b Duplicate query to avoid SQL injection 2019-01-07 15:21:57 +00:00
Charles-Henri Decultot
f6d18e81e9 Modify SQL queries and add a test case 2019-01-07 15:21:57 +00:00
Charles-Henri Decultot
e8f3050e27 Codacy corrections 2019-01-07 15:21:57 +00:00
Charles-Henri Decultot
cbe63ec418 Codacy corrections 2019-01-07 15:21:57 +00:00
Charles-Henri Decultot
94899981d3 Dev cleanup 2019-01-07 15:21:57 +00:00
Charles-Henri Decultot
6a4dae3a9d Codacy corrections + sql queries 2019-01-07 15:21:57 +00:00
Charles-Henri Decultot
57c6b49d1a Dev cleanup 2019-01-07 15:21:57 +00:00
Charles-Henri Decultot
eae7424984 Cleanup dev 2019-01-07 15:21:57 +00:00
Charles-Henri Decultot
e394cec194 Bank reconciliation dashboard 2019-01-07 15:21:57 +00:00
Charles-Henri Decultot
818492387a WIP 2019-01-07 15:21:57 +00:00
Charles-Henri Decultot
31cb24f48d Bank reconciliation WIP 2019-01-07 15:20:06 +00:00
Charles-Henri Decultot
590d8d3d3e Bank reconciliation wip 2019-01-07 15:20:06 +00:00
Charles-Henri Decultot
6025e498f2 Bank reconciliation wip 2019-01-07 15:20:06 +00:00
Charles-Henri Decultot
09cad814cd Reconciliation dashboard wip 2019-01-07 15:20:06 +00:00
Charles-Henri Decultot
c75a2b1eed Plaid integration 2019-01-07 15:20:06 +00:00
7108 changed files with 970965 additions and 2792707 deletions

View File

@@ -1,12 +0,0 @@
reviews:
auto_review:
ignore_title_keywords:
- "sync translations"
- "update POT file"
- "style: "
review_status: false
poem: false
collapse_walkthrough: true
sequence_diagrams: false
changed_files_summary: false
high_level_summary: false

View File

@@ -1,21 +0,0 @@
# Root editor config file
root = true
# Common settings
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
# python, js indentation settings
[{*.py,*.js,*.vue,*.css,*.scss,*.html}]
indent_style = tab
indent_size = 4
max_line_length = 110
# JSON files - mostly doctype schema files
[{*.json}]
insert_final_newline = false
indent_style = space
indent_size = 1

View File

@@ -2,34 +2,57 @@
"env": {
"browser": true,
"node": true,
"es2022": true
},
"parserOptions": {
"sourceType": "module"
"es6": true
},
"extends": "eslint:recommended",
"rules": {
"indent": "off",
"brace-style": "off",
"no-mixed-spaces-and-tabs": "off",
"no-useless-escape": "off",
"space-unary-ops": ["error", { "words": true }],
"linebreak-style": "off",
"quotes": ["off"],
"semi": "off",
"camelcase": "off",
"no-unused-vars": "off",
"no-console": ["warn"],
"no-extra-boolean-cast": ["off"],
"no-control-regex": ["off"]
"indent": [
"error",
"tab",
{ "SwitchCase": 1 }
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"off"
],
"semi": [
"warn",
"always"
],
"camelcase": [
"off"
],
"no-unused-vars": [
"warn"
],
"no-redeclare": [
"warn"
],
"no-console": [
"warn"
],
"no-extra-boolean-cast": [
"off"
],
"no-control-regex": [
"off"
],
"spaced-comment": [
"warn"
],
"no-trailing-spaces": [
"warn"
]
},
"root": true,
"globals": {
"frappe": true,
"Vue": true,
"SetVueGlobals": true,
"erpnext": true,
"hub": true,
"$": true,
"jQuery": true,
"moment": true,
@@ -59,15 +82,12 @@
"cur_page": true,
"cur_list": true,
"cur_tree": true,
"cur_pos": true,
"msg_dialog": true,
"is_null": true,
"in_list": true,
"has_common": true,
"posthog": true,
"has_words": true,
"validate_email": true,
"open_web_template_values_editor": true,
"get_number_format": true,
"format_number": true,
"format_currency": true,
@@ -113,18 +133,6 @@
"get_server_fields": true,
"set_multiple": true,
"QUnit": true,
"Chart": true,
"Cypress": true,
"cy": true,
"describe": true,
"expect": true,
"it": true,
"context": true,
"before": true,
"beforeEach": true,
"onScan": true,
"extend_cscript": true,
"localforage": true,
"Plaid": true
"Chart": true
}
}

37
.flake8
View File

@@ -1,37 +0,0 @@
[flake8]
ignore =
E121,
E126,
E127,
E128,
E203,
E225,
E226,
E231,
E241,
E251,
E261,
E265,
E302,
E303,
E305,
E402,
E501,
E741,
W291,
W292,
W293,
W391,
W503,
W504,
F403,
B007,
B950,
W191,
E124, # closing bracket, irritating while writing QB code
E131, # continuation line unaligned for hanging indent
E123, # closing bracket does not match indentation of opening bracket's line
E101, # ensured by use of black
max-line-length = 200
exclude=.github/helper/semgrep_rules

View File

@@ -1,53 +0,0 @@
# Since version 2.23 (released in August 2019), git-blame has a feature
# to ignore or bypass certain commits.
#
# This file contains a list of commits that are not likely what you
# are looking for in a blame, such as mass reformatting or renaming.
# You can set this file as a default ignore file for blame by running
# the following command.
#
# $ git config blame.ignoreRevsFile .git-blame-ignore-revs
# Replace use of Class.extend with native JS class
1fe891b287a1b3f225d29ee3d07e7b1824aba9e7
# This commit just changes spaces to tabs for indentation in some files
5f473611bd6ed57703716244a054d3fb5ba9cd23
# Whitespace fix throughout codebase
4551d7d6029b6f587f6c99d4f8df5519241c6a86
b147b85e6ac19a9220cd1e2958a6ebd99373283a
# sort and cleanup imports
915b34391c2066dfc83e60a5813c5a877cebe7ac
# removing six compatibility layer
8fe5feb6a4372bf5f2dfaf65fca41bbcc25c8ce7
# bulk format python code with black
494bd9ef78313436f0424b918f200dab8fc7c20b
# bulk format python code with black
baec607ff5905b1c67531096a9cf50ec7ff00a5d
# bulk refactor with sourcery
eb9ee3f79b94e594fc6dfa4f6514580e125eee8c
# js formatting
ec74a5e56617bbd76ac402451468fd4668af543d
# ruff formatting
a308792ee7fda18a681e9181f4fd00b36385bc23
# noisy typing refactoring of get_item_details
7b7211ac79c248a79ba8a999ff34e734d874c0ae
d827ed21adc7b36047e247cbb0dc6388d048a7f9
# `frappe.flags.in_test` => `frappe.in_test`
7a482a69985c952de0e8193c9d4e086aee65ee6d
# these commits actually changed something valuable
# but they have a lot of whitespace changes that make blame noisy
# PR: https://github.com/frappe/erpnext/pull/49816
3ffd50c772735877b330d010c1058f623da8721d
0e8f8677b8eb31e7834f72d1c6314d3c3f392ca6

View File

@@ -1,70 +1,36 @@
### Introduction (For First-Time Contributors)
### Introduction (first timers)
Thank you for your interest in raising an issue with ERPNext. An issue can be either a bug report or a feature request.
Thank you for your interest in raising an Issue with ERPNext. An Issue could mean a bug report or a request for a missing feature. By raising a bug report, you are contributing to the development of ERPNext and this is the first step of participating in the community. Bug reports are very helpful for developers as they quickly fix the issue before other users start facing it.
By reporting bugs, you contribute directly to improving ERPNext. Bug reports help developers identify and fix issues quickly before they affect more users.
Feature requests are also a great way to take the product forward. New ideas can come in any user scenario and the issue list also acts a roadmap of future features.
Feature requests are also valuable. They help shape the future of the product by introducing new ideas and improvements based on real-world use cases.
When you are raising an Issue, you should keep a few things in mind. Remember that the developer does not have access to your machine so you must give all the information you can while raising an Issue. If you are suggesting a feature, you should be very clear about what you want.
When raising an issue, keep in mind that developers do not have access to your environment. Therefore, provide as much relevant information as possible.
If you are suggesting a feature, clearly describe what you expect and how it should behave.
> ⚠️ The issue tracker is not the right place for general questions or discussions.
> Please use the forum instead: https://discuss.frappe.io/c/erpnext/6
---
The Issue list is not the right place to ask a question or start a general discussion. If you want to do that , then the right place is the forum [https://discuss.erpnext.com](https://discuss.erpnext.com).
### Reply and Closing Policy
If your issue is unclear or does not meet the guidelines, it may be closed.
If that happens, please provide the requested information and reopen the issue.
---
If your issue is not clear or does not meet the guidelines, then it will be closed. If it is closed, please supply the information asked and re-open it.
### General Issue Guidelines
1. **Search existing issues:**
Before creating a new issue, check if it already exists. You can support existing issues with a 👍 or contribute additional details or mockups.
2. **Report issues separately:**
Do not combine multiple unrelated issues into a single report.
3. **Be concise:**
Avoid long explanations. Use bullet points and screenshots where possible.
---
1. **Search existing Issues:** Before raising a Issue, search if it has been raised before. Maybe add a 👍 or give additional help by creating a mockup if it is not already created.
1. **Report each issue separately:** Don't club multiple, unreleated issues in one note.
1. **Brief:** Please don't include long explanations. Use screenshots and bullet points instead of descriptive paragraphs.
### Bug Report Guidelines
1. **Steps to reproduce:**
Clearly list the steps required to reproduce the issue. If the issue cannot be reproduced, it cannot be fixed.
2. **Version number:**
Include the ERPNext version. The issue may already be fixed in a newer release.
3. **Clear title:**
Use a descriptive title (e.g., "Unable to submit Purchase Order without Basic Rate" instead of "Cannot submit").
4. **Screenshots:**
Add screenshots or screen recordings (e.g., `.gif`) to illustrate the issue.
---
1. **Steps to Reproduce:** The bug report must have a list of steps needed to reproduce a bug. If we cannot reproduce it, then we cannot solve it.
1. **Version Number:** Please add the version number in your report. Often a bug is fixed in the latest version
1. **Clear Title:** Add a clear subject to your bug report like "Unable to submit Purchase Order without Basic Rate" instead of just "Cannot Submit"
1. **Screenshots:** Screenshots are a great way of communicating the issues. Try adding annotations or using LiceCAP to take a screencast in `gif`.
### Feature Request Guidelines
1. **Clarity:**
Clearly describe the expected behavior. Avoid vague statements.
1. **Clarity:** Clearly specify how do you want the feature to behave. Don't just say "I would like multiple PDF formats", say that "Ability to add multiple print formats for customers with different languages".
1. **Solution:** Try and identify how the feature should look like.
1. **Mockups:** Mockups are a great way to explain your requirement.
2. **Proposed solution:**
Suggest how the feature should work.
### What if my Issue is closed
3. **Mockups:**
Provide mockups or examples whenever possible.
---
### What if my issue is closed?
Don't worry. Review the feedback, provide the required information, and reopen the issue.
Don't worry, take the feedback, supply the correct information and re-open it!

View File

@@ -1,89 +0,0 @@
name: Bug Report
description: Report a bug encountered while using ERPNext
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Welcome to ERPNext issue tracker! Before creating an issue, please heed the following:
1. This tracker should only be used to report bugs and request features / enhancements to ERPNext
- For questions and general support, checkout the [user manual](https://docs.erpnext.com/) or use [forum](https://discuss.frappe.io/c/erpnext/6)
- For documentation issues, propose edit on [documentation site](https://docs.erpnext.com/) directly.
2. When making a bug report, make sure you provide all required information. The easier it is for
maintainers to reproduce, the faster it'll be fixed.
3. If you think you know what the reason for the bug is, share it with us. Maybe put in a PR 😉
- type: textarea
id: bug-info
attributes:
label: Information about bug
description: Also tell us, what did you expect to happen?
placeholder: Please provide as much information as possible.
validations:
required: true
- type: dropdown
id: module
attributes:
label: Module
description: Select affected module of ERPNext.
multiple: true
options:
- accounts
- stock
- buying
- selling
- ecommerce
- manufacturing
- HR
- projects
- support
- CRM
- assets
- integrations
- quality
- regional
- portal
- agriculture
- education
- non-profit
- other
validations:
required: true
- type: textarea
id: exact-version
attributes:
label: Version
description: Share exact version number of Frappe and ERPNext you are using.
placeholder: |
Frappe version -
ERPNext version -
validations:
required: true
- type: dropdown
id: install-method
attributes:
label: Installation method
options:
- docker
- easy-install
- manual install
- FrappeCloud
validations:
required: false
- type: textarea
id: logs
attributes:
label: Relevant log output / Stack trace / Full Error Message.
description: Please copy and paste any relevant log output. This will be automatically formatted.
render: shell
- type: markdown
attributes:
value: |
By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/frappe/erpnext/blob/develop/CODE_OF_CONDUCT.md)

View File

@@ -1,5 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Community Forum
url: https://discuss.frappe.io/c/erpnext/6
about: For general QnA, discussions and community help.

View File

@@ -1,55 +0,0 @@
---
name: Feature request
about: Suggest an idea or enhancement for ERPNext
title: ''
labels: feature-request
assignees: ''
---
<!--
Welcome to ERPNext issue tracker! Before creating an issue, please heed the following:
1. This tracker should only be used to report bugs and request features / enhancements to ERPNext
- For questions and general support, checkout the manual https://docs.erpnext.com or use https://discuss.frappe.io/c/erpnext/6
2. Use the search function before creating a new issue. Duplicates will be closed and directed to
the original discussion.
3. When making a feature request, make sure to be as verbose as possible. The better you convey your message, the greater the drive to make it happen.
Please keep in mind that we get many requests and we can't possibly work on all of them, we prioritize development based on the goals of the product and organization. Feature requests are still welcome as it helps us in research when we do decide to work on the requested feature.
If you're in urgent need of a feature, please try the following channels to get paid developments done quickly:
1. Certified ERPNext partners: https://erpnext.com/partners
2. Developer community on ERPNext forums: https://discuss.frappe.io/c/framework/5
3. Telegram group for ERPNext/Frappe development work: https://t.me/erpnext_opps
-->
## Before Submitting
- [ ] I searched existing issues and confirmed this is not a duplicate
- [ ] This is a feature request, not a bug or support question
- [ ] For support: https://discuss.frappe.io/c/erpnext/6
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. As a [role], I have to [painful task] because [missing feature].
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Impact**
<!-- Check one: -->
- [ ] Blocks critical workflow — no viable workaround
- [ ] Significant friction — workaround exists but is painful
- [ ] Nice to have — minor improvement
**Additional context**
Add any other context or screenshots about the feature request here.
**Environment**
- ERPNext Version: <!-- Find this in Help > About, e.g. v15.12.0 -->
- Frappe Version: <!-- Find this in Help > About, e.g. v15.10.0 -->
- Deployment: <!-- Frappe Cloud / Self-hosted / ERPNext Cloud -->

View File

@@ -1,33 +1,2 @@
<!--
Please read the pull request checklist to make sure your changes are merged: https://github.com/frappe/erpnext/wiki/Pull-Request-Checklist
Some key notes before you open a PR:
1. Select which branch should this PR be merged in?
2. PR name follows [convention](http://karma-runner.github.io/4.0/dev/git-commit-msg.html)
3. All tests pass locally, UI and Unit tests
4. All business logic and validations must be on the server-side
5. Update necessary Documentation
6. Put `closes #XXXX` in your comment to auto-close the issue that your PR fixes
Also, if you're new here
- Documentation Guidelines => https://github.com/frappe/erpnext/wiki/Updating-Documentation
- Contribution Guide => https://github.com/frappe/erpnext/blob/develop/.github/CONTRIBUTING.md
- Pull Request Checklist => https://github.com/frappe/erpnext/wiki/Pull-Request-Checklist
-->
> Please provide enough information so that others can review your pull request:
<!-- You can skip this if you're fixing a typo or updating existing documentation -->
> Explain the **details** for making this change. What existing problem does the pull request solve?
<!-- Example: When "Adding a function to do X", explain why it is necessary to have a way to do X. -->
> Screenshots/GIFs
<!-- Add images/recordings to better visualize the change: expected/current behviour -->

View File

@@ -1,74 +0,0 @@
[flake8]
ignore =
B007,
B009,
B010,
B950,
E101,
E111,
E114,
E116,
E117,
E121,
E122,
E123,
E124,
E125,
E126,
E127,
E128,
E131,
E201,
E202,
E203,
E211,
E221,
E222,
E223,
E224,
E225,
E226,
E228,
E231,
E241,
E242,
E251,
E261,
E262,
E265,
E266,
E271,
E272,
E273,
E274,
E301,
E302,
E303,
E305,
E306,
E402,
E501,
E502,
E701,
E702,
E703,
E741,
F403,
W191,
W291,
W292,
W293,
W391,
W503,
W504,
E711,
E129,
F841,
E713,
E712,
B023,
B028
max-line-length = 200
exclude=.github/helper/semgrep_rules,test_*.py

View File

@@ -1,65 +0,0 @@
import sys
from urllib.parse import urlparse
import requests
WEBSITE_REPOS = [
"erpnext_com",
"frappe_io",
]
DOCUMENTATION_DOMAINS = [
"docs.erpnext.com",
"docs.frappe.io",
"frappeframework.com",
]
def is_valid_url(url: str) -> bool:
parts = urlparse(url)
return all((parts.scheme, parts.netloc, parts.path))
def is_documentation_link(word: str) -> bool:
if not word.startswith("http") or not is_valid_url(word):
return False
parsed_url = urlparse(word)
if parsed_url.netloc in DOCUMENTATION_DOMAINS:
return True
if parsed_url.netloc == "github.com":
parts = parsed_url.path.split("/")
if len(parts) == 5 and parts[1] == "frappe" and parts[2] in WEBSITE_REPOS:
return True
return False
def contains_documentation_link(body: str) -> bool:
return any(is_documentation_link(word) for line in body.splitlines() for word in line.split())
def check_pull_request(number: str) -> "tuple[int, str]":
response = requests.get(f"https://api.github.com/repos/frappe/erpnext/pulls/{number}")
if not response.ok:
return 1, "Pull Request Not Found! ⚠️"
payload = response.json()
title = (payload.get("title") or "").lower().strip()
head_sha = (payload.get("head") or {}).get("sha")
body = (payload.get("body") or "").lower()
if not title.startswith("feat") or not head_sha or "no-docs" in body or "backport" in body:
return 0, "Skipping documentation checks... 🏃"
if contains_documentation_link(body):
return 0, "Documentation Link Found. You're Awesome! 🎉"
return 1, "Documentation Link Not Found! ⚠️"
if __name__ == "__main__":
exit_code, message = check_pull_request(sys.argv[1])
print(message)
sys.exit(exit_code)

View File

@@ -1,78 +0,0 @@
#!/bin/bash
set -e
cd ~ || exit
sudo apt update
sudo apt remove mysql-server mysql-client
sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev
pip install frappe-bench
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
frappeuser=${FRAPPE_USER:-"frappe"}
frappecommitish=${FRAPPE_BRANCH:-$githubbranch}
mkdir frappe
pushd frappe
git init
git remote add origin "https://github.com/${frappeuser}/frappe"
git fetch origin "${frappecommitish}" --depth 1
git checkout FETCH_HEAD
popd
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
mkdir ~/frappe-bench/sites/test_site
if [ "$DB" == "mariadb" ];then
cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config_mariadb.json" ~/frappe-bench/sites/test_site/site_config.json
else
cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config_postgres.json" ~/frappe-bench/sites/test_site/site_config.json
fi
if [ "$DB" == "mariadb" ];then
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE DATABASE test_frappe"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "FLUSH PRIVILEGES"
fi
if [ "$DB" == "postgres" ];then
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE DATABASE test_frappe" -U postgres;
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres;
fi
install_whktml() {
wget -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
sudo apt install /tmp/wkhtmltox.deb
}
install_whktml &
wkpid=$!
cd ~/frappe-bench || exit
sed -i 's/watch:/# watch:/g' Procfile
sed -i 's/schedule:/# schedule:/g' Procfile
sed -i 's/socketio:/# socketio:/g' Procfile
sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile
bench get-app payments --branch develop
bench get-app erpnext "${GITHUB_WORKSPACE}"
if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi
wait $wkpid
bench start &>> ~/frappe-bench/bench_start.log &
CI=Yes bench build --app frappe &
bench --site test_site reinstall --yes

View File

@@ -1,17 +0,0 @@
{
"db_host": "127.0.0.1",
"db_port": 3306,
"db_name": "test_frappe",
"db_password": "test_frappe",
"auto_email_id": "test@example.com",
"mail_server": "smtp.example.com",
"mail_login": "test@example.com",
"mail_password": "test",
"admin_password": "admin",
"use_mysqlclient": 1,
"root_login": "root",
"root_password": "root",
"host_name": "http://test_site:8000",
"install_apps": ["payments", "erpnext"],
"throttle_user_limit": 100
}

View File

@@ -1,18 +0,0 @@
{
"db_host": "127.0.0.1",
"db_port": 5432,
"db_name": "test_frappe",
"db_password": "test_frappe",
"db_type": "postgres",
"allow_tests": true,
"auto_email_id": "test@example.com",
"mail_server": "smtp.example.com",
"mail_login": "test@example.com",
"mail_password": "test",
"admin_password": "admin",
"root_login": "postgres",
"root_password": "travis",
"host_name": "http://test_site:8000",
"install_apps": ["erpnext"],
"throttle_user_limit": 100
}

View File

@@ -1,68 +0,0 @@
import re
import sys
errors_encounter = 0
pattern = re.compile(
r"_\(([\"']{,3})(?P<message>((?!\1).)*)\1(\s*,\s*context\s*=\s*([\"'])(?P<py_context>((?!\5).)*)\5)*(\s*,(\s*?.*?\n*?)*(,\s*([\"'])(?P<js_context>((?!\11).)*)\11)*)*\)"
)
words_pattern = re.compile(r"_{1,2}\([\"'`]{1,3}.*?[a-zA-Z]")
start_pattern = re.compile(r"_{1,2}\([f\"'`]{1,3}")
f_string_pattern = re.compile(r"_\(f[\"']")
starts_with_f_pattern = re.compile(r"_\(f")
# skip first argument
files = sys.argv[1:]
files_to_scan = [_file for _file in files if _file.endswith((".py", ".js"))]
for _file in files_to_scan:
with open(_file) as f:
print(f"Checking: {_file}")
file_lines = f.readlines()
for line_number, line in enumerate(file_lines, 1):
if "frappe-lint: disable-translate" in line:
continue
start_matches = start_pattern.search(line)
if start_matches:
starts_with_f = starts_with_f_pattern.search(line)
if starts_with_f:
has_f_string = f_string_pattern.search(line)
if has_f_string:
errors_encounter += 1
print(
f"\nF-strings are not supported for translations at line number {line_number}\n{line.strip()[:100]}"
)
continue
else:
continue
match = pattern.search(line)
error_found = False
if not match and line.endswith((",\n", "[\n")):
# concat remaining text to validate multiline pattern
line = "".join(file_lines[line_number - 1 :])
line = line[start_matches.start() + 1 :]
match = pattern.match(line)
if not match:
error_found = True
print(f"\nTranslation syntax error at line number {line_number}\n{line.strip()[:100]}")
if not error_found and not words_pattern.search(line):
error_found = True
print(
f"\nTranslation is useless because it has no words at line number {line_number}\n{line.strip()[:100]}"
)
if error_found:
errors_encounter += 1
if errors_encounter > 0:
print(
'\nVisit "https://frappeframework.com/docs/user/en/translations" to learn about valid translation strings.'
)
sys.exit(1)
else:
print("\nGood To Go!")

View File

@@ -1,40 +0,0 @@
#!/bin/bash
set -e
cd ~ || exit
echo "Setting Up Bench..."
pip install frappe-bench
bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)"
cd ./frappe-bench || exit
echo "Get ERPNext..."
bench get-app --skip-assets erpnext "${GITHUB_WORKSPACE}"
echo "Generating POT file..."
bench generate-pot-file --app erpnext
cd ./apps/erpnext || exit
echo "Configuring git user..."
git config user.email "developers@erpnext.com"
git config user.name "frappe-pr-bot"
echo "Setting the correct git remote..."
# Here, the git remote is a local file path by default. Let's change it to the upstream repo.
git remote set-url upstream https://github.com/frappe/erpnext.git
echo "Creating a new branch..."
isodate=$(date -u +"%Y-%m-%d")
branch_name="pot_${BASE_BRANCH}_${isodate}"
git checkout -b "${branch_name}"
echo "Commiting changes..."
git add erpnext/locale/main.pot
git commit -m "chore: update POT file"
gh auth setup-git
git push -u upstream "${branch_name}"
echo "Creating a PR..."
gh pr create --fill --base "${BASE_BRANCH}" --head "${branch_name}" --reviewer ${PR_REVIEWER} -R frappe/erpnext

55
.github/labeler.yml vendored
View File

@@ -1,55 +0,0 @@
accounts:
- erpnext/accounts/*
- erpnext/controllers/accounts_controller.py
- erpnext/controllers/taxes_and_totals.py
stock:
- erpnext/stock/*
- erpnext/controllers/stock_controller.py
- erpnext/controllers/item_variant.py
assets:
- erpnext/assets/*
regional:
- erpnext/regional/*
selling:
- erpnext/selling/*
- erpnext/controllers/selling_controller.py
buying:
- erpnext/buying/*
- erpnext/controllers/buying_controller.py
support:
- erpnext/support/*
POS:
- pos*
ecommerce:
- erpnext/e_commerce/*
maintenance:
- erpnext/maintenance/*
manufacturing:
- erpnext/manufacturing/*
crm:
- erpnext/crm/*
HR:
- erpnext/hr/*
payroll:
- erpnext/payroll*
projects:
- erpnext/projects/*
# Any python files modifed but no test files modified
needs-tests:
- any: ['erpnext/**/*.py']
all: ['!erpnext/**/test*.py']

4
.github/release.yml vendored
View File

@@ -1,4 +0,0 @@
changelog:
exclude:
labels:
- skip-release-notes

49
.github/stale.yml vendored
View File

@@ -1,35 +1,34 @@
# Configuration for probot-stale - https://github.com/probot/stale
# Label to use when marking as stale
staleLabel: inactive
# Number of days of inactivity before an Issue or Pull Request becomes stale
daysUntilStale: 30
# Limit the number of actions per hour, from 1-30. Default is 30
limitPerRun: 10
# Set to true to ignore issues in a project (defaults to false)
exemptProjects: true
# Set to true to ignore issues in a milestone (defaults to false)
exemptMilestones: true
# Skip the stale action for draft PRs
exemptDraftPr: true
# Number of days of inactivity before a stale Issue or Pull Request is closed.
# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
daysUntilClose: 7
# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable
exemptLabels:
- hotfix
- no-stale
pulls:
daysUntilStale: 15
daysUntilClose: 3
exemptLabels:
- hotfix
markComment: >
This pull request has been automatically marked as inactive because it has
not had recent activity. It will be closed within 3 days if no further
activity occurs, but it only takes a comment to keep a contribution alive
:) Also, even if it is closed, you can always reopen the PR when you're
ready. Thank you for contributing.
# Set to true to ignore issues in a project (defaults to false)
exemptProjects: false
# Set to true to ignore issues in a milestone (defaults to false)
exemptMilestones: true
# Label to use when marking as stale
staleLabel: inactive
# Comment to post when marking as stale. Set to `false` to disable
markComment: >
This pull request has been automatically marked as stale because it has not had
recent activity. It will be closed within a week if no further activity occurs, but it
only takes a comment to keep a contribution alive :) Also, even if it is closed,
you can always reopen the PR when you're ready. Thank you for contributing.
# Limit the number of actions per hour, from 1-30. Default is 30
limitPerRun: 30
# Limit to only `issues` or `pulls`
only: pulls

View File

@@ -1,32 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="4 2 193 52">
<g filter="url(#filter0_dd)">
<rect x="4" y="2" width="193" height="52" rx="6" fill="#2490EF"/>
<path d="M28 22.2891H32.8786V35.5H36.2088V22.2891H41.0874V19.5H28V22.2891Z" fill="white"/>
<path d="M41.6982 35.5H45.0129V28.7109C45.0129 27.2344 46.0866 26.2188 47.5494 26.2188C48.0085 26.2188 48.6388 26.2969 48.95 26.3984V23.4453C48.6543 23.375 48.2419 23.3281 47.9074 23.3281C46.5691 23.3281 45.472 24.1094 45.0362 25.5938H44.9117V23.5H41.6982V35.5Z" fill="white"/>
<path d="M52.8331 40C55.2996 40 56.6068 38.7344 57.2837 36.7969L61.9289 23.5156L58.4197 23.5L55.9221 32.3125H55.7976L53.3233 23.5H49.8374L54.1247 35.8437L53.9302 36.3516C53.4944 37.4766 52.6619 37.5312 51.4947 37.1719L50.7478 39.6562C51.2224 39.8594 51.9927 40 52.8331 40Z" fill="white"/>
<path d="M73.6142 35.7344C77.2401 35.7344 79.4966 33.2422 79.4966 29.5469C79.4966 25.8281 77.2401 23.3438 73.6142 23.3438C69.9883 23.3438 67.7319 25.8281 67.7319 29.5469C67.7319 33.2422 69.9883 35.7344 73.6142 35.7344ZM73.6298 33.1562C71.9569 33.1562 71.101 31.6171 71.101 29.5233C71.101 27.4296 71.9569 25.8827 73.6298 25.8827C75.2715 25.8827 76.1274 27.4296 76.1274 29.5233C76.1274 31.6171 75.2715 33.1562 73.6298 33.1562Z" fill="white"/>
<path d="M84.7253 28.5625C84.7331 27.0156 85.6512 26.1094 86.9895 26.1094C88.3201 26.1094 89.1215 26.9844 89.1137 28.4531V35.5H92.4284V27.8594C92.4284 25.0625 90.7945 23.3438 88.3046 23.3438C86.5306 23.3438 85.2466 24.2187 84.7097 25.6172H84.5697V23.5H81.4106V35.5H84.7253V28.5625Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M102.429 19.5H113.429V22.3141H102.429V19.5ZM102.429 35.5V26.6794H112.699V29.4982H105.94V35.5H102.429Z" fill="white"/>
<path d="M131.584 24.9625C131.09 21.5057 128.345 19.5 124.785 19.5C120.589 19.5 117.429 22.463 117.429 27.4924C117.429 32.5142 120.55 35.4848 124.785 35.4848C128.604 35.4848 131.137 33.0916 131.584 30.1211L128.651 30.1059C128.282 31.9293 126.745 32.9549 124.824 32.9549C122.22 32.9549 120.354 31.0632 120.354 27.4924C120.354 23.9824 122.204 22.0299 124.832 22.0299C126.784 22.0299 128.314 23.1011 128.651 24.9625H131.584Z" fill="white"/>
<path d="M136.409 19.7124H133.571V35.2718H136.409V19.7124Z" fill="white"/>
<path d="M144.031 35.5001C147.56 35.5001 149.803 33.0917 149.803 29.483C149.803 25.8667 147.56 23.4507 144.031 23.4507C140.502 23.4507 138.259 25.8667 138.259 29.483C138.259 33.0917 140.502 35.5001 144.031 35.5001ZM144.047 33.2969C142.094 33.2969 141.137 31.6103 141.137 29.4754C141.137 27.3406 142.094 25.6312 144.047 25.6312C145.968 25.6312 146.925 27.3406 146.925 29.4754C146.925 31.6103 145.968 33.2969 144.047 33.2969Z" fill="white"/>
<path d="M159.338 30.3641C159.338 32.1419 158.028 33.0232 156.773 33.0232C155.409 33.0232 154.499 32.0887 154.499 30.6072V23.6025H151.66V31.0327C151.66 33.8361 153.307 35.4239 155.675 35.4239C157.479 35.4239 158.749 34.5046 159.298 33.1979H159.424V35.272H162.176V23.6025H159.338V30.3641Z" fill="white"/>
<path d="M169.014 35.4769C171.084 35.4769 172.017 34.2841 172.464 33.4332H172.637V35.2718H175.429V19.7124H172.582V25.532H172.464C172.033 24.6887 171.147 23.4503 169.022 23.4503C166.238 23.4503 164.05 25.5624 164.05 29.4522C164.05 33.2965 166.175 35.4769 169.014 35.4769ZM169.806 33.2205C167.931 33.2205 166.943 31.6251 166.943 29.437C166.943 27.2642 167.916 25.7067 169.806 25.7067C171.633 25.7067 172.637 27.173 172.637 29.437C172.637 31.701 171.617 33.2205 169.806 33.2205Z" fill="white"/>
</g>
<defs>
<filter id="filter0_dd" x="0" y="0" width="201" height="60" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="0.25"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="2"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.13 0"/>
<feBlend mode="normal" in2="effect1_dropShadow" result="effect2_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -1,29 +0,0 @@
name: Backport
on:
pull_request_target:
types:
- closed
- labeled
permissions:
contents: read
jobs:
main:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout Actions
uses: actions/checkout@v6
with:
repository: "frappe/backport"
path: ./actions
ref: develop
- name: Install Actions
run: npm install --production --prefix ./actions
- name: Run backport
uses: ./actions/backport
with:
token: ${{secrets.RELEASE_TOKEN}}
labelsToAdd: "backport"
title: "{{originalTitle}}"

View File

@@ -1,18 +0,0 @@
name: Trigger Docker build on release
on:
release:
types: [released]
permissions:
contents: read
jobs:
curl:
runs-on: ubuntu-latest
container:
image: alpine:latest
steps:
- name: curl
run: |
apk add curl bash
curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: Bearer ${{ secrets.CI_PAT }}" https://api.github.com/repos/frappe/frappe_docker/actions/workflows/build_stable.yml/dispatches -d '{"ref":"main"}'

View File

@@ -1,28 +0,0 @@
name: 'Documentation Required'
on:
pull_request:
types: [ opened, synchronize, reopened, edited ]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: 'Setup Environment'
uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: 'Clone repo'
uses: actions/checkout@v6
- name: Validate Docs
env:
PR_NUMBER: ${{ github.event.number }}
run: |
pip install requests --quiet
python $GITHUB_WORKSPACE/.github/helper/documentation.py $PR_NUMBER

View File

@@ -1,44 +0,0 @@
# This workflow is agnostic to branches. Only maintain on develop branch.
# To add/remove branches just modify the matrix.
name: Regenerate POT file (translatable strings)
on:
schedule:
# 9:30 UTC => 3 PM IST Sunday
- cron: "30 9 * * 0"
workflow_dispatch:
jobs:
regenerate-pot-file:
name: Regenerate POT file
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
branch: ["develop", "version-16-hotfix"]
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ matrix.branch }}
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.14"
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
- name: Run script to update POT file
run: |
bash ${GITHUB_WORKSPACE}/.github/helper/update_pot_file.sh
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
BASE_BRANCH: ${{ matrix.branch }}
PR_REVIEWER: barredterra # change to your GitHub username if you copied this file

View File

@@ -1,36 +0,0 @@
# This workflow is agnostic to branches. Only maintain on develop branch.
# To add/remove versions just modify the matrix.
name: Create weekly release pull requests
permissions:
contents: read
on:
schedule:
# 9:30 UTC => 3 PM IST Tuesday
- cron: "30 9 * * 2"
workflow_dispatch:
jobs:
stable-release:
name: Release
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
version: ["15", "16"]
steps:
- uses: octokit/request-action@v2.x
with:
route: POST /repos/{owner}/{repo}/pulls
owner: frappe
repo: erpnext
title: |-
"chore: release v${{ matrix.version }}"
body: "Automated weekly release."
base: version-${{ matrix.version }}
head: version-${{ matrix.version }}-hotfix
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}

View File

@@ -1,30 +0,0 @@
name: "Auto-label PRs based on title"
on:
pull_request_target:
types: [opened, reopened]
jobs:
add-label-if-prefix-matches:
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest
steps:
- name: Check PR title and add label if it matches prefixes
uses: actions/github-script@v7
continue-on-error: true
with:
script: |
const title = context.payload.pull_request.title.toLowerCase();
const prefixes = ['chore', 'ci', 'style', 'test', 'refactor'];
// Check if the PR title starts with any of the prefixes
if (prefixes.some(prefix => title.startsWith(prefix))) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
labels: ['skip-release-notes']
});
}

View File

@@ -1,16 +0,0 @@
name: "Pull Request Labeler"
on:
pull_request_target:
types: [opened, reopened]
permissions:
issues: write
pull-requests: write
jobs:
triage:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v3
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"

View File

@@ -1,48 +0,0 @@
name: Linters
on:
pull_request: { }
permissions:
contents: read
jobs:
linters:
name: linters
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python 3.14
uses: actions/setup-python@v6
with:
python-version: '3.14'
cache: pip
- name: Install and Run Pre-commit
uses: pre-commit/action@v3.0.0
semgrep:
name: semgrep
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python 3.14
uses: actions/setup-python@v6
with:
python-version: '3.14'
cache: pip
- name: Download Semgrep rules
run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules
- name: Download semgrep
run: pip install semgrep
- name: Run Semgrep rules
run: semgrep ci --config ./frappe-semgrep-rules/rules --config r/python.lang.correctness
- name: Semgrep for Test Correctness
run: semgrep ci --include=**/test_*.py --config ./semgrep/test-correctness.yml

View File

@@ -1,21 +0,0 @@
name: 'Lock threads'
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
jobs:
lock:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v5
with:
github-token: ${{ github.token }}
issue-inactive-days: 14
pr-inactive-days: 14

View File

@@ -1,170 +0,0 @@
name: Patch
on:
pull_request:
paths-ignore:
- '**.js'
- '**.css'
- '**.md'
- '**.html'
- '**.csv'
- 'crowdin.yml'
- '.coderabbit.yml'
- '.mergify.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: patch-develop-${{ github.event_name }}-${{ github.event.number || github.event_name == 'workflow_dispatch' && github.run_id || '' }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 60
name: Patch Test
services:
mysql:
image: mariadb:11.8
env:
MARIADB_ROOT_PASSWORD: 'root'
ports:
- 3306:3306
options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
steps:
- name: Clone
uses: actions/checkout@v6
- name: Check for valid Python & Merge Conflicts
run: |
python -m compileall -fq "${GITHUB_WORKSPACE}"
if grep -lr --exclude-dir=node_modules "^<<<<<<< " "${GITHUB_WORKSPACE}"
then echo "Found merge conflicts"
exit 1
fi
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: |
3.11
3.13
3.14
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
check-latest: true
- name: Add to Hosts
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-
${{ runner.os }}-
- name: Cache node modules
uses: actions/cache@v4
env:
cache-name: cache-node-modules
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install
run: |
pip install frappe-bench
bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
env:
DB: mariadb
TYPE: server
- name: Run Patch Tests
run: |
cd ~/frappe-bench/
bench remove-app payments --force
jq 'del(.install_apps)' ~/frappe-bench/sites/test_site/site_config.json > tmp.json
mv tmp.json ~/frappe-bench/sites/test_site/site_config.json
wget https://frappe.io/files/erpnext-v14.sql.gz
bench --site test_site --force restore ~/frappe-bench/erpnext-v14.sql.gz
git -C "apps/frappe" remote set-url upstream https://github.com/frappe/frappe.git
git -C "apps/erpnext" remote set-url upstream https://github.com/frappe/erpnext.git
function update_to_version() {
version=$1
branch_name="version-$version-hotfix"
echo "Updating to v$version"
# Fetch and checkout branches
git -C "apps/frappe" fetch --depth 1 upstream $branch_name:$branch_name
git -C "apps/erpnext" fetch --depth 1 upstream $branch_name:$branch_name
git -C "apps/frappe" checkout -q -f $branch_name
git -C "apps/erpnext" checkout -q -f $branch_name
# Resetup env and install apps
pgrep honcho | xargs kill
rm -rf ~/frappe-bench/env
bench -v setup env --python python$2
bench pip install -e ./apps/erpnext
bench start &>> ~/frappe-bench/bench_start.log &
bench --site test_site migrate
}
update_to_version 15 3.13
update_to_version 16 3.14
echo "Updating to latest version"
git -C "apps/frappe" fetch --depth 1 upstream "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}"
git -C "apps/frappe" checkout -q -f FETCH_HEAD
git -C "apps/erpnext" checkout -q -f "$GITHUB_SHA"
pgrep honcho | xargs kill
rm -rf ~/frappe-bench/env
bench -v setup env
bench pip install -e ./apps/erpnext
bench start &>> ~/frappe-bench/bench_start.log &
bench --site test_site migrate
- name: Show bench output
if: ${{ always() }}
run: |
cd ~/frappe-bench
cat bench_start.log || true
cd logs
for f in ./*.log*; do
echo "Printing log: $f";
cat $f
done

View File

@@ -1,28 +0,0 @@
# Tests are skipped for these files but github doesn't allow "passing" hence this is required.
name: Skipped Patch Test
on:
pull_request:
paths:
- "**.js"
- "**.css"
- "**.md"
- "**.html"
- "**.csv"
- 'crowdin.yml'
- '.coderabbit.yml'
- '.mergify.yml'
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
name: Patch Test
steps:
- name: Pass skipped tests unconditionally
run: "echo Skipped"

View File

@@ -1,35 +0,0 @@
name: Generate Semantic Release
on:
push:
branches:
- version-13
permissions:
contents: read
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout Entire Repository
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 20
- name: Setup dependencies
run: |
npm install @semantic-release/git @semantic-release/exec --no-save
- name: Create Release
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GIT_AUTHOR_NAME: "Frappe PR Bot"
GIT_AUTHOR_EMAIL: "developers@frappe.io"
GIT_COMMITTER_NAME: "Frappe PR Bot"
GIT_COMMITTER_EMAIL: "developers@frappe.io"
run: npx semantic-release

View File

@@ -1,42 +0,0 @@
# This action:
#
# 1. Generates release notes using github API.
# 2. Strips unnecessary info like chore/style etc from notes.
# 3. Updates release info.
# This action needs to be maintained on all branches that do releases.
name: 'Release Notes'
on:
workflow_dispatch:
inputs:
tag_name:
description: 'Tag of release like v13.0.0'
required: true
type: string
release:
types: [released]
permissions:
contents: read
jobs:
regen-notes:
name: 'Regenerate release notes'
runs-on: ubuntu-latest
steps:
- name: Update notes
run: |
NEW_NOTES=$(gh api --method POST -H "Accept: application/vnd.github+json" /repos/frappe/erpnext/releases/generate-notes -f tag_name=$RELEASE_TAG \
| jq -r '.body' \
| sed -E '/^\* (chore|ci|test|docs|style)/d' \
| sed -E 's/by @mergify //'
)
RELEASE_ID=$(gh api -H "Accept: application/vnd.github+json" /repos/frappe/erpnext/releases/tags/$RELEASE_TAG | jq -r '.id')
gh api --method PATCH -H "Accept: application/vnd.github+json" /repos/frappe/erpnext/releases/$RELEASE_ID -f body="$NEW_NOTES"
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
RELEASE_TAG: ${{ github.event.inputs.tag_name || github.event.release.tag_name }}

View File

@@ -1,143 +0,0 @@
name: Individual
on:
workflow_dispatch:
concurrency:
group: server-individual-tests-lightmode-develop
cancel-in-progress: true
permissions:
contents: read
jobs:
discover:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Clone
uses: actions/checkout@v6
- id: set-matrix
run: |
# Use grep and find to get the list of test files
matrix=$(find . -path '*/test_*.py' | xargs grep -l 'def test_' | sort | awk '{
# Remove ./ prefix, file extension, and replace / with .
gsub(/^\.\//, "", $0)
gsub(/\.py$/, "", $0)
gsub(/\//, ".", $0)
# Add to array
tests[NR] = $0
}
END {
# Start JSON array
printf "{\n \"include\": [\n"
# Loop through array and create JSON objects
for (i=1; i<=NR; i++) {
printf " {\"test\": \"%s\"}", tests[i]
if (i < NR) printf ","
printf "\n"
}
# Close JSON array
printf " ]\n}"
}')
# Output the matrix
echo "matrix=$(echo "$matrix" | jq -c)" >> $GITHUB_OUTPUT
# For debugging (optional)
echo "Generated matrix:"
echo "$matrix"
test:
needs: discover
runs-on: ubuntu-latest
timeout-minutes: 60
env:
NODE_ENV: "production"
strategy:
fail-fast: false
matrix: ${{fromJson(needs.discover.outputs.matrix)}}
max-parallel: 14
name: Test
services:
mysql:
image: mariadb:10.6
env:
MARIADB_ROOT_PASSWORD: 'root'
ports:
- 3306:3306
options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
steps:
- name: Clone
uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: '3.14'
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
check-latest: true
- name: Add to Hosts
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-
${{ runner.os }}-
- name: Cache node modules
uses: actions/cache@v4
env:
cache-name: cache-node-modules
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
env:
DB: mariadb
TYPE: server
FRAPPE_USER: ${{ github.event.inputs.user }}
FRAPPE_BRANCH: ${{ github.event.inputs.branch }}
- name: Run Tests
run: |
site_name=$(echo "${{matrix.test}}" | sed -e 's/.*\.\(test_.*$\)/\1/')
echo "$site_name"
mkdir ~/frappe-bench/sites/$site_name
cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config_mariadb.json" ~/frappe-bench/sites/$site_name/site_config.json
cd ~/frappe-bench/
bench --site $site_name reinstall --yes
bench --site $site_name set-config allow_tests true
bench --site $site_name run-tests --module ${{ matrix.test }} --lightmode

View File

@@ -1,30 +0,0 @@
name: Semantic Commits
on:
pull_request: {}
permissions:
contents: read
concurrency:
group: commitcheck-erpnext-${{ github.event.number }}
cancel-in-progress: true
jobs:
commitlint:
name: Check Commit Titles
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 200
- uses: actions/setup-node@v6
with:
node-version: 18
check-latest: true
- name: Check commit titles
run: |
npm install @commitlint/cli @commitlint/config-conventional
npx commitlint --verbose --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }}

View File

@@ -1,31 +0,0 @@
# Tests are skipped for these files but github doesn't allow "passing" hence this is required.
name: Skipped Tests
on:
pull_request:
paths:
- "**.js"
- "**.css"
- "**.svg"
- "**.md"
- "**.html"
- 'crowdin.yml'
- '.coderabbit.yml'
- '.mergify.yml'
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
container: [1, 2, 3, 4]
name: Python Unit Tests
steps:
- name: Pass skipped tests unconditionally
run: "echo Skipped"

View File

@@ -1,166 +0,0 @@
name: Server (Mariadb)
on:
repository_dispatch:
types: [frappe-framework-change]
pull_request:
paths-ignore:
- '**.js'
- '**.css'
- '**.svg'
- '**.md'
- '**.html'
- 'crowdin.yml'
- '.coderabbit.yml'
- '.mergify.yml'
schedule:
# Run everday at midnight UTC / 5:30 IST
- cron: "0 0 * * *"
workflow_dispatch:
inputs:
user:
description: 'Frappe Framework repository user (add your username for forks)'
required: true
default: 'frappe'
type: string
branch:
description: 'Frappe Framework branch'
default: 'develop'
required: false
type: string
permissions:
contents: read
concurrency:
group: server-mariadb-develop-${{ github.event_name }}-${{ github.event.number || github.event_name == 'workflow_dispatch' && github.run_id || '' }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 60
env:
TZ: 'Asia/Kolkata'
NODE_ENV: "production"
WITH_COVERAGE: ${{ github.event_name != 'pull_request' }}
strategy:
fail-fast: false
matrix:
container: [1, 2, 3, 4]
name: Python Unit Tests
services:
mysql:
image: mariadb:10.6
env:
TZ: 'Asia/Kolkata'
MARIADB_ROOT_PASSWORD: 'root'
ports:
- 3306:3306
options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
steps:
- name: Clone
uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: '3.14'
- name: Check for valid Python & Merge Conflicts
run: |
python -m compileall -fq "${GITHUB_WORKSPACE}"
if grep -lr --exclude-dir=node_modules "^<<<<<<< " "${GITHUB_WORKSPACE}"
then echo "Found merge conflicts"
exit 1
fi
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
check-latest: true
- name: Add to Hosts
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-
${{ runner.os }}-
- name: Cache node modules
uses: actions/cache@v4
env:
cache-name: cache-node-modules
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
env:
DB: mariadb
TYPE: server
FRAPPE_USER: ${{ github.event.inputs.user }}
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
- name: Run Tests
run: 'cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --lightmode --app erpnext --total-builds ${{ strategy.job-total }} --build-number ${{ matrix.container }} --with-coverage'
env:
TYPE: server
- name: Show bench output
if: ${{ always() }}
run: cat ~/frappe-bench/bench_start.log || true
- name: Upload coverage data
uses: actions/upload-artifact@v4
with:
name: coverage-${{ matrix.container }}
path: /home/runner/frappe-bench/sites/coverage.xml
coverage:
name: Coverage Wrap Up
needs: test
runs-on: ubuntu-latest
steps:
- name: Clone
uses: actions/checkout@v6
- name: Download artifacts
uses: actions/download-artifact@v4
- name: Upload coverage data
uses: codecov/codecov-action@v4
with:
name: MariaDB
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
verbose: true

View File

@@ -1,118 +0,0 @@
name: Server (Postgres)
on:
pull_request:
paths-ignore:
- '**.js'
- '**.md'
- '**.html'
- 'crowdin.yml'
- '.coderabbit.yml'
- '.mergify.yml'
types: [opened, labelled, synchronize, reopened]
concurrency:
group: server-postgres-develop-${{ github.event_name }}-${{ github.event.number || github.event_name == 'workflow_dispatch' && github.run_id || '' }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
if: ${{ contains(github.event.pull_request.labels.*.name, 'postgres') }}
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
container: [1]
name: Python Unit Tests
services:
postgres:
image: postgres:13.3
env:
POSTGRES_PASSWORD: travis
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Clone
uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: '3.14'
- name: Check for valid Python & Merge Conflicts
run: |
python -m compileall -fq "${GITHUB_WORKSPACE}"
if grep -lr --exclude-dir=node_modules "^<<<<<<< " "${GITHUB_WORKSPACE}"
then echo "Found merge conflicts"
exit 1
fi
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
check-latest: true
- name: Add to Hosts
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-
${{ runner.os }}-
- name: Cache node modules
uses: actions/cache@v4
env:
cache-name: cache-node-modules
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
env:
DB: postgres
TYPE: server
- name: Run Tests
run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app erpnext --use-orchestrator
env:
TYPE: server
CI_BUILD_ID: ${{ github.run_id }}
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io

13
.gitignore vendored
View File

@@ -2,24 +2,15 @@
*.py~
.DS_Store
conf.py
locale
latest_updates.json
.wnf-lang-status
*.egg-info
dist/
erpnext/public/dist
erpnext/docs/current
*.swp
*.swo
__pycache__
*~
.idea/
.vscode/
.helix/
node_modules/
.backportrc.json
# Aider AI Chat
.aider*
# Banking SPA
erpnext/public/banking
erpnext/www/banking.html
node_modules/

View File

@@ -1,94 +0,0 @@
pull_request_rules:
- name: Auto-close PRs on stable branch
conditions:
- and:
- and:
- author!=surajshetty3416
- author!=gavindsouza
- author!=rohitwaghchaure
- author!=nabinhait
- author!=ankush
- author!=deepeshgarg007
- author!=frappe-pr-bot
- author!=mergify[bot]
- or:
- base=version-13
- base=version-12
- base=version-14
- base=version-15
- base=version-16
actions:
close:
comment:
message: |
@{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch.
https://github.com/frappe/erpnext/wiki/Pull-Request-Checklist#which-branch
- name: backport to develop
conditions:
- label="backport develop"
actions:
backport:
branches:
- develop
assignees:
- "{{ author }}"
- name: backport to version-14-hotfix
conditions:
- label="backport version-14-hotfix"
actions:
backport:
branches:
- version-14-hotfix
assignees:
- "{{ author }}"
- name: backport to version-15-hotfix
conditions:
- label="backport version-15-hotfix"
actions:
backport:
branches:
- version-15-hotfix
assignees:
- "{{ author }}"
- name: backport to version-16-hotfix
conditions:
- label="backport version-16-hotfix"
actions:
backport:
branches:
- version-16-hotfix
assignees:
- "{{ author }}"
- name: Automatic merge on CI success and review
conditions:
- status-success=linters
- status-success=Sider
- status-success=Semantic Pull Request
- status-success=Patch Test
- status-success=Python Unit Tests (1)
- status-success=Python Unit Tests (2)
- status-success=Python Unit Tests (3)
- label!=dont-merge
- label!=squash
- "#approved-reviews-by>=1"
actions:
merge:
method: merge
- name: Automatic squash on CI success and review
conditions:
- status-success=linters
- status-success=Sider
- status-success=Patch Test
- status-success=Python Unit Tests (1)
- status-success=Python Unit Tests (2)
- status-success=Python Unit Tests (3)
- label!=dont-merge
- label=squash
- "#approved-reviews-by>=1"
actions:
merge:
method: squash
commit_message_template: |
{{ title }} (#{{ number }})
{{ body }}

View File

@@ -1,72 +0,0 @@
exclude: 'node_modules|.git'
default_stages: [pre-commit]
fail_fast: false
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
hooks:
- id: trailing-whitespace
files: "erpnext.*"
exclude: ".*json$|.*txt$|.*csv|.*md"
- id: check-yaml
- id: no-commit-to-branch
args: ['--branch', 'develop']
- id: check-merge-conflict
- id: check-ast
- id: check-json
- id: check-toml
- id: check-yaml
- id: debug-statements
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.7.1
hooks:
- id: prettier
types_or: [javascript, vue, scss]
# Ignore any files that might contain jinja / bundles
exclude: |
(?x)^(
erpnext/public/dist/.*|
cypress/.*|
.*node_modules.*|
.*boilerplate.*|
erpnext/templates/includes/.*
)$
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v8.44.0
hooks:
- id: eslint
types_or: [javascript]
args: ['--quiet']
# Ignore any files that might contain jinja / bundles
exclude: |
(?x)^(
erpnext/public/dist/.*|
cypress/.*|
.*node_modules.*|
.*boilerplate.*|
erpnext/public/js/controllers/.*|
erpnext/templates/pages/order.js|
erpnext/templates/includes/.*
)$
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.2.0
hooks:
- id: ruff
name: "Run ruff import sorter"
args: ["--select=I", "--fix"]
- id: ruff
name: "Run ruff linter"
- id: ruff-format
name: "Run ruff formatter"
ci:
autoupdate_schedule: weekly
skip: []
submodules: false

View File

@@ -1,24 +0,0 @@
{
"branches": ["version-13"],
"plugins": [
"@semantic-release/commit-analyzer", {
"preset": "angular",
"releaseRules": [
{"breaking": true, "release": false}
]
},
"@semantic-release/release-notes-generator",
[
"@semantic-release/exec", {
"prepareCmd": 'sed -ir "s/[0-9]*\.[0-9]*\.[0-9]*/${nextRelease.version}/" erpnext/__init__.py'
}
],
[
"@semantic-release/git", {
"assets": ["erpnext/__init__.py"],
"message": "chore(release): Bumped to Version ${nextRelease.version}\n\n${nextRelease.notes}"
}
],
"@semantic-release/github"
]
}

56
.travis.yml Normal file
View File

@@ -0,0 +1,56 @@
language: python
dist: trusty
python:
- "2.7"
services:
- mysql
install:
# fix mongodb travis error
- sudo rm /etc/apt/sources.list.d/mongodb*.list
- pip install flake8==3.3.0
- flake8 . --count --select=E901,E999,F821,F822,F823 --show-source --statistics
- sudo rm /etc/apt/sources.list.d/docker.list
- sudo apt-get install hhvm && rm -rf /home/travis/.kiex/
- sudo apt-get purge -y mysql-common mysql-server mysql-client
- nvm install 10
- pip install python-coveralls
- wget https://raw.githubusercontent.com/frappe/bench/master/playbooks/install.py
- sudo python install.py --develop --user travis --without-bench-setup
- sudo pip install -e ~/bench
- rm $TRAVIS_BUILD_DIR/.git/shallow
- bash $TRAVIS_BUILD_DIR/travis/bench_init.sh
- cp -r $TRAVIS_BUILD_DIR/test_sites/test_site ~/frappe-bench/sites/
before_script:
- mysql -u root -ptravis -e 'create database test_frappe'
- echo "USE mysql;\nCREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe';\nFLUSH PRIVILEGES;\n" | mysql -u root -ptravis
- echo "USE mysql;\nGRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost';\n" | mysql -u root -ptravis
- cd ~/frappe-bench
- bench get-app erpnext $TRAVIS_BUILD_DIR
- bench use test_site
- bench reinstall --mariadb-root-username root --mariadb-root-password travis --yes
- bench scheduler disable
- sed -i 's/9000/9001/g' sites/common_site_config.json
- bench start &
- sleep 10
jobs:
include:
- stage: test
script:
- set -e
- bench run-tests --app erpnext --coverage
after_script:
- coveralls -b apps/erpnext -d ../../sites/.coverage
env: Server Side Test
- # stage
script:
- wget http://build.erpnext.com/20171108_190013_955977f8_database.sql.gz
- bench --force restore ~/frappe-bench/20171108_190013_955977f8_database.sql.gz --mariadb-root-password travis
- bench migrate
env: Patch Testing

View File

@@ -1,23 +0,0 @@
# Each line is a file pattern followed by one or more owners.
# These owners will be the default owners for everything in
# the repo. Unless a later match takes precedence,
erpnext/accounts/ @ruthra-kumar
erpnext/assets/ @khushi8112
erpnext/regional @ruthra-kumar
erpnext/selling @ruthra-kumar
erpnext/buying/ @rohitwaghchaure @mihir-kandoi
erpnext/maintenance/ @rohitwaghchaure @mihir-kandoi
erpnext/manufacturing/ @rohitwaghchaure @mihir-kandoi
erpnext/quality_management/ @rohitwaghchaure @mihir-kandoi
erpnext/stock/ @rohitwaghchaure @mihir-kandoi
erpnext/subcontracting/ @mihir-kandoi
erpnext/projects/ @nishkagosalia
erpnext/controllers/ @ruthra-kumar @rohitwaghchaure @mihir-kandoi
erpnext/patches/ @ruthra-kumar @rohitwaghchaure @mihir-kandoi
.github/ @ruthra-kumar @mihir-kandoi
pyproject.toml @ruthra-kumar

20
MANIFEST.in Normal file
View File

@@ -0,0 +1,20 @@
include MANIFEST.in
include requirements.txt
include *.json
include *.md
include *.py
include *.txt
include .travis.yml
recursive-include erpnext *.txt
recursive-include erpnext *.css
recursive-include erpnext *.csv
recursive-include erpnext *.html
recursive-include erpnext *.ico
recursive-include erpnext *.js
recursive-include erpnext *.json
recursive-include erpnext *.md
recursive-include erpnext *.png
recursive-include erpnext *.py
recursive-include erpnext *.svg
recursive-include erpnext/public *
recursive-exclude * *.pyc

211
README.md
View File

@@ -1,177 +1,94 @@
<div align="center">
<a href="https://frappe.io/erpnext">
<img src="./erpnext/public/images/v16/erpnext.svg" alt="ERPNext Logo" height="80px" width="80px"/>
</a>
<img src="https://github.com/frappe/design/blob/master/logos/erpnext-logo.svg" height="128">
<h2>ERPNext</h2>
<div align="center">
<p>Powerful, Intuitive and Open-Source ERP</p>
</div>
<p align="center">
<p>ERP made simple</p>
</p>
[![Learn on Frappe School](https://img.shields.io/badge/Frappe%20School-Learn%20ERPNext-blue?style=flat-square)](https://frappe.school)<br><br>
[![CI](https://github.com/frappe/erpnext/actions/workflows/server-tests-mariadb.yml/badge.svg?event=schedule)](https://github.com/frappe/erpnext/actions/workflows/server-tests-mariadb.yml)
[![docker pulls](https://img.shields.io/docker/pulls/frappe/erpnext.svg)](https://hub.docker.com/r/frappe/erpnext)
[![Build Status](https://travis-ci.com/frappe/erpnext.png)](https://travis-ci.com/frappe/erpnext)
[![Open Source Helpers](https://www.codetriage.com/frappe/erpnext/badges/users.svg)](https://www.codetriage.com/frappe/erpnext)
[![Coverage Status](https://coveralls.io/repos/github/frappe/erpnext/badge.svg?branch=develop)](https://coveralls.io/github/frappe/erpnext?branch=develop)
[https://erpnext.com](https://erpnext.com)
</div>
<div align="center">
<img src="./erpnext/public/images/v16/hero_image.png" alt="ERPNext Hero Image"/>
</div>
Includes: Accounting, Inventory, Manufacturing, CRM, Sales, Purchase, Project Management, HRMS. Requires MariaDB.
<div align="center">
<a href="https://erpnext-demo.frappe.cloud/api/method/erpnext_demo.erpnext_demo.auth.login_demo">Live Demo</a>
-
<a href="https://frappe.io/erpnext">Website</a>
-
<a href="https://docs.frappe.io/erpnext/">Documentation</a>
</div>
ERPNext is built on the [Frappe](https://github.com/frappe/frappe) Framework, a full-stack web app framework in Python & JavaScript.
## ERPNext
- [User Guide](https://erpnext.com/docs/user)
- [Discussion Forum](https://discuss.erpnext.com/)
100% Open-Source ERP System to help you run your business.
---
### Motivation
Running a business is a complex task - handling invoices, tracking stock, managing personnel, and other daily operations. In a market where software is sold separately to manage each of these tasks, ERPNext does all of the above and more, for free.
### Key Features
- **Accounting**: All the tools you need to manage cash flow in one place, right from recording transactions to summarizing and analyzing financial reports.
- **Order Management**: Track inventory levels, replenish stock, and manage sales orders, customers, suppliers, shipments, deliverables, and order fulfillment.
- **Manufacturing**: Simplifies the production cycle, helps track material consumption, exhibits capacity planning, handles subcontracting, and more!
- **Asset Management**: From purchase to disposal, IT infrastructure to equipment. Covers every branch of your organization, all in one centralized system.
- **Projects**: Deliver both internal and external projects on time, budget and profitability. Track tasks, timesheets, and issues by project.
<details open>
<summary>More</summary>
<img src="https://erpnext.com/files/v16_bom.png"/>
<img src="https://erpnext.com/files/v16_stock_summary.png"/>
<img src="https://erpnext.com/files/v16_job_card.png"/>
<img src="https://erpnext.com/files/v16_tasks.png"/>
</details>
### Under the Hood
- [**Frappe Framework**](https://github.com/frappe/frappe): A full-stack web application framework written in Python and JavaScript. The framework provides a robust foundation for building web applications, including a database abstraction layer, user authentication, and a REST API.
- [**Frappe UI**](https://github.com/frappe/frappe-ui): A Vue-based UI library, to provide a modern user interface. The Frappe UI library provides a variety of components that can be used to build single-page applications on top of the Frappe Framework.
## Production Setup
### Managed Hosting
You can try [Frappe Cloud](https://frappecloud.com), a simple, user-friendly, and sophisticated [open-source](https://github.com/frappe/press) platform to host Frappe applications reliably and securely.
It handles installation, setup, upgrades, monitoring, maintenance, and support of your Frappe deployments. It is a fully featured developer platform with an ability to manage and control multiple Frappe deployments.
<div>
<a href="https://erpnext-demo.frappe.cloud/app/home" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://frappe.io/files/try-on-fc-white.png">
<img src="https://frappe.io/files/try-on-fc-black.png" alt="Try on Frappe Cloud" height="28" />
</picture>
</a>
</div>
### Self-Hosted
#### Docker
See [Frappe Docker Documentation](https://github.com/frappe/frappe_docker) for full documentation & FAQ on Docker setup
#### Prerequisites
- [Docker](https://docs.docker.com/get-docker/)
- [Docker Compose v2](https://docs.docker.com/compose/)
- [git](https://docs.github.com/en/get-started/getting-started-with-git/set-up-git)
> For Docker basics and best practices refer to Docker's [documentation](https://docs.docker.com)
### Try on your environment
> **⚠️ Disposable demo only**
>
> **This setup is intended for quick evaluation. Expect to throw the environment away.** You will not be able to install custom apps to this setup. For production deployments, custom configurations, and detailed explanations, see the full documentation.
First clone the repo:
```sh
git clone https://github.com/frappe/frappe_docker
cd frappe_docker
```
Then run:
```sh
docker compose -f pwd.yml up -d
```
Wait for a couple of minutes for ERPNext site to be created or check the `create-site` container logs before opening browser on port `8080`. (username: `Administrator`, password: `admin`)
See [Frappe Docker](https://github.com/frappe/frappe_docker/blob/main/docs/01-getting-started/03-arm64.md) for ARM based docker setup
## Development Setup
### Manual Install
### Full Install
The Easy Way: our install script for bench will install all dependencies (e.g. MariaDB). See https://github.com/frappe/bench for more details.
New passwords will be created for the ERPNext "Administrator" user, the MariaDB root user, and the Frappe user (the script displays the passwords and saves them to ~/frappe_passwords.txt).
New passwords will be created for the ERPNext "Administrator" user, the MariaDB root user, and the frappe user (the script displays the passwords and saves them to ~/frappe_passwords.txt).
### Virtual Image
### Local
You can download a virtual image to run ERPNext in a virtual machine on your local system.
To setup the repository locally follow the steps mentioned below:
- [ERPNext Download](http://erpnext.com/download)
1. Setup bench by following the [Installation Steps](https://frappeframework.com/docs/user/en/installation) and start the server
```
bench start
```
System and user credentials are listed on the download page.
2. In a separate terminal window, run the following commands:
```
# Create a new site
bench new-site erpnext.localhost
```
---
3. Get the ERPNext app and install it
```
# Get the ERPNext app
bench get-app https://github.com/frappe/erpnext
## License
# Install the app
bench --site erpnext.localhost install-app erpnext
```
GNU/General Public License (see [license.txt](license.txt))
4. Open the URL `http://erpnext.localhost:8000/app` in your browser, you should see the app running
## Learning and Community
1. [Frappe School](https://school.frappe.io) - Learn Frappe Framework and ERPNext from the various courses by the maintainers or from the community.
2. [Official documentation](https://docs.erpnext.com/) - Extensive documentation for ERPNext.
3. [Discussion Forum](https://discuss.frappe.io/c/erpnext/6) - Engage with the community of ERPNext users and service providers.
4. [Telegram Group](https://erpnext_public.t.me) - Get instant help from huge community of users.
The ERPNext code is licensed as GNU General Public License (v3) and the Documentation is licensed as Creative Commons (CC-BY-SA-3.0) and the copyright is owned by Frappe Technologies Pvt Ltd (Frappe) and Contributors.
---
## Contributing
1. [Issue Guidelines](https://github.com/frappe/erpnext/wiki/Issue-Guidelines)
2. [Report Security Vulnerabilities](https://erpnext.com/security)
3. [Pull Request Requirements](https://github.com/frappe/erpnext/wiki/Contribution-Guidelines)
4. [Translations](https://crowdin.com/project/frappe)
1. [Report Security Vulnerabilities](https://erpnext.com/report)
1. [Pull Request Requirements](https://github.com/frappe/erpnext/wiki/Contribution-Guidelines)
1. [Translations](https://translate.erpnext.com)
1. [Chart of Accounts](https://charts.erpnext.com)
---
## Logo and Trademark Policy
## Logo and Trademark
Please read our [Logo and Trademark Policy](TRADEMARK_POLICY.md).
The brand name ERPNext and the logo are trademarks of Frappe Technologies Pvt. Ltd.
<br />
<br />
<div align="center" style="padding-top: 0.75rem;">
<a href="https://frappe.io" target="_blank">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://frappe.io/files/Frappe-white.png">
<img src="https://frappe.io/files/Frappe-black.png" alt="Frappe Technologies" height="28"/>
</picture>
</a>
</div>
### Introduction
Frappe Technologies Pvt. Ltd. (Frappe) owns and oversees the trademarks for the ERPNext name and logos. We have developed this trademark usage policy with the following goals in mind:
- Wed like to make it easy for anyone to use the ERPNext name or logo for community-oriented efforts that help spread and improve ERPNext.
- Wed like to make it clear how ERPNext-related businesses and projects can (and cannot) use the ERPNext name and logo.
- Wed like to make it hard for anyone to use the ERPNext name and logo to unfairly profit from, trick or confuse people who are looking for official ERPNext resources.
### Frappe Trademark Usage Policy
Permission from Frappe is required to use the ERPNext name or logo as part of any project, product, service, domain or company name.
We will grant permission to use the ERPNext name and logo for projects that meet the following criteria:
- The primary purpose of your project is to promote the spread and improvement of the ERPNext software.
- Your project is non-commercial in nature (it can make money to cover its costs or contribute to non-profit entities, but it cannot be run as a for-profit project or business).
Your project neither promotes nor is associated with entities that currently fail to comply with the GPL license under which ERPNext is distributed.
- If your project meets these criteria, you will be permitted to use the ERPNext name and logo to promote your project in any way you see fit with one exception: Please do not use ERPNext as part of a domain name.
Use of the ERPNext name and logo is additionally allowed in the following situations:
All other ERPNext-related businesses or projects can use the ERPNext name and logo to refer to and explain their services, but they cannot use them as part of a product, project, service, domain, or company name and they cannot use them in any way that suggests an affiliation with or endorsement by ERPNext or Frappe Technologies or the ERPNext open source project. For example, a consulting company can describe its business as “123 Web Services, offering ERPNext consulting for small businesses,” but cannot call its business “The ERPNext Consulting Company.”
Similarly, its OK to use the ERPNext logo as part of a page that describes your products or services, but it is not OK to use it as part of your company or product logo or branding itself. Under no circumstances is it permitted to use ERPNext as part of a top-level domain name.
We do not allow the use of the trademark in advertising, including AdSense/AdWords.
Please note that it is not the goal of this policy to limit commercial activity around ERPNext. We encourage ERPNext-based businesses, and we would love to see hundreds of them.
When in doubt about your use of the ERPNext name or logo, please contact Frappe Technologies for clarification.
(inspired by WordPress)

View File

@@ -1,7 +0,0 @@
# Security Policy
The ERPNext team and community take security issues seriously. To report a security issue, please go through the information mentioned [here](https://frappe.io/security).
You can help us make ERPNext and all its users more secure by following the [Reporting guidelines](https://frappe.io/security).
We appreciate your efforts to responsibly disclose your findings. We'll endeavor to respond quickly, and will keep you updated throughout the process.

View File

@@ -1,37 +0,0 @@
## Logo and Trademark Policy
The brand name ERPNext and the logo are trademarks of Frappe Technologies Pvt. Ltd.
### Introduction
Frappe Technologies Pvt. Ltd. (Frappe) owns and oversees the trademarks for the ERPNext name and logos. We have developed this trademark usage policy with the following goals in mind:
- Wed like to make it easy for anyone to use the ERPNext name or logo for community-oriented efforts that help spread and improve ERPNext.
- Wed like to make it clear how ERPNext-related businesses and projects can (and cannot) use the ERPNext name and logo.
- Wed like to make it hard for anyone to use the ERPNext name and logo to unfairly profit from, trick or confuse people who are looking for official ERPNext resources.
### Frappe Trademark Usage Policy
Permission from Frappe is required to use the ERPNext name or logo as part of any project, product, service, domain or company name.
We will grant permission to use the ERPNext name and logo for projects that meet the following criteria:
- The primary purpose of your project is to promote the spread and improvement of the ERPNext software.
- Your project is non-commercial in nature (it can make money to cover its costs or contribute to non-profit entities, but it cannot be run as a for-profit project or business).
- Your project neither promotes nor is associated with entities that currently fail to comply with the GPL license under which ERPNext is distributed.
If your project meets these criteria, you will be permitted to use the ERPNext name and logo to promote your project in any way you see fit with one exception: Please do not use ERPNext as part of a domain name.
Use of the ERPNext name and logo is additionally allowed in the following situations:
All other ERPNext-related businesses or projects can use the ERPNext name and logo to refer to and explain their services, but they cannot use them as part of a product, project, service, domain, or company name and they cannot use them in any way that suggests an affiliation with or endorsement by ERPNext or Frappe Technologies or the ERPNext open source project. For example, a consulting company can describe its business as “123 Web Services, offering ERPNext consulting for small businesses,” but cannot call its business “The ERPNext Consulting Company.”
Similarly, its OK to use the ERPNext logo as part of a page that describes your products or services, but it is not OK to use it as part of your company or product logo or branding itself. Under no circumstances is it permitted to use ERPNext as part of a top-level domain name.
We do not allow the use of the trademark in advertising, including AdSense/AdWords.
Please note that it is not the goal of this policy to limit commercial activity around ERPNext. We encourage ERPNext-based businesses, and we would love to see hundreds of them.
When in doubt about your use of the ERPNext name or logo, please contact Frappe Technologies for clarification.
(inspired by WordPress)

View File

@@ -1,5 +0,0 @@
**/setup/setup_wizard/data/uom_data.json,erpnext.gettext.extractors.uom_data.extract
**/setup/doctype/incoterm/incoterms.csv,erpnext.gettext.extractors.incoterms.extract
**/setup/setup_wizard/data/*.txt,erpnext.gettext.extractors.lines_from_txt_file.extract
**.tsx,frappe.gettext.extractors.html_template.extract
**.ts,frappe.gettext.extractors.html_template.extract
1 **/setup/setup_wizard/data/uom_data.json erpnext.gettext.extractors.uom_data.extract
2 **/setup/doctype/incoterm/incoterms.csv erpnext.gettext.extractors.incoterms.extract
3 **/setup/setup_wizard/data/*.txt erpnext.gettext.extractors.lines_from_txt_file.extract
4 **.tsx frappe.gettext.extractors.html_template.extract
5 **.ts frappe.gettext.extractors.html_template.extract

View File

@@ -1 +0,0 @@
VITE_BASE_NAME="banking"

24
banking/.gitignore vendored
View File

@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,73 +0,0 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -1,24 +0,0 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
onlyExportComponents: false,
},
]);

View File

@@ -1,50 +0,0 @@
<!doctype html>
<html lang="{{ lang }}" dir="{{layout_direction}}">
<head>
<meta charset="UTF-8" />
<!-- Chrome, Firefox OS and Opera -->
<meta name="theme-color" content="#0089FF">
<!-- Windows Phone -->
<meta name="msapplication-navbutton-color" content="#0089FF">
<!-- iOS Safari -->
<meta name="apple-mobile-web-app-status-bar-style" content="#0089FF">
<meta content="text/html;charset=utf-8" http-equiv="Content-Type">
<meta content="utf-8" http-equiv="encoding">
<meta name="author" content="">
<meta name="viewport" content="width=device-width, initial-scale=1.0,
maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, minimal-ui">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="white">
<meta name="mobile-web-app-capable" content="yes">
<link rel="shortcut icon" href="{{ favicon or ' /assets/erpnext/images/erpnext-favicon.svg' }}" type="image/x-icon">
<link rel="icon" href="{{ favicon or ' /assets/erpnext/images/erpnext-favicon.svg' }}" type="image/x-icon">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Banking | {{ app_name }}</title>
</head>
<body>
<div id="root"></div>
<script>window.csrf_token = '{{ frappe.session.csrf_token }}';
if (!window.frappe) window.frappe = {};
frappe.boot = JSON.parse({{ boot }});
frappe.boot.layout_direction = "{{ layout_direction }}";
frappe._translations_loaded = fetch(
`/api/method/frappe.translate.get_boot_translations?v=${frappe.boot.translations_version}&lang=${frappe.boot.lang}`,
{
credentials: "same-origin",
headers: {
"X-Frappe-CSRF-Token": frappe.csrf_token,
"Accept": "application/json"
}
}
).then(r => r.json()).then(data => {
frappe._messages = data.message || {};
}).catch(() => { });
</script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,66 +0,0 @@
{
"name": "banking",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build --base=/assets/erpnext/banking/ && yarn copy-html-entry",
"lint": "eslint .",
"preview": "vite preview",
"copy-html-entry": "cp ../erpnext/public/banking/index.html ../erpnext/www/banking.html"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.24",
"@vitejs/plugin-react": "^6.0.1",
"chrono-node": "^2.9.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.20",
"frappe-react-sdk": "^1.15.0",
"fuse.js": "^7.3.0",
"jotai": "^2.20.0",
"jotai-family": "^1.0.1",
"lodash.isplainobject": "^4.0.6",
"lucide-react": "^1.14.0",
"radix-ui": "^1.4.3",
"react": "^19.2.6",
"react-currency-input-field": "^4.0.5",
"react-day-picker": "9.14.0",
"react-dom": "^19.2.6",
"react-dropzone": "^15.0.0",
"react-hook-form": "^7.75.0",
"react-hotkeys-hook": "^5.3.2",
"react-markdown": "^10.1.0",
"react-router": "^7.15.0",
"react-router-dom": "^7.15.0",
"react-virtuoso": "^4.18.6",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0",
"usehooks-ts": "^3.1.1",
"vite": "^8.0.11"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^25.3.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0"
}
}

View File

@@ -1,13 +0,0 @@
const common_site_config = require('../../../sites/common_site_config.json');
const { webserver_port } = common_site_config;
export default {
'^/(app|api|assets|files|private)': {
target: `http://127.0.0.1:${webserver_port}`,
ws: true,
router: function(req) {
const site_name = req.headers.host.split(':')[0];
return `http://${site_name}:${webserver_port}`;
}
}
};

View File

@@ -1,65 +0,0 @@
import { useEffect } from 'react'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { FrappeProvider } from 'frappe-react-sdk'
import { Toaster } from '@/components/ui/sonner'
import BankReconciliation from '@/pages/BankReconciliation'
import { TooltipProvider } from './components/ui/tooltip'
import BankStatementImporter from '@/pages/BankStatementImporter'
import { LucideProvider } from 'lucide-react'
import { ThemeProvider } from './components/ui/theme-provider'
import ViewBankStatementImportLog from './pages/ViewBankStatementImportLog'
import BankStatementImporterContainer from './pages/BankStatementImporterContainer'
function App() {
useEffect(() => {
// Check if user is logged in by checking the Cookie "user_id"
// In Frappe, unauthenticated users are "Guest"
const userId = document.cookie?.split('; ').find(row => row.startsWith('user_id='))?.split('=')[1]?.trim()
const isLoggedIn = userId !== 'Guest'
if (!isLoggedIn) {
if (import.meta.env.DEV) {
return
}
// Redirect to Frappe login page
window.location.href = '/login?redirect-to=/banking'
return
}
}, [])
return (
<LucideProvider
strokeWidth={1.5}
>
<TooltipProvider>
<FrappeProvider
swrConfig={{
errorRetryCount: 2
}}
socketPort={import.meta.env.VITE_SOCKET_PORT}
siteName={window.frappe?.boot?.sitename ?? import.meta.env.VITE_SITE_NAME}>
<ThemeProvider
defaultTheme={window.frappe?.boot?.desk_theme ?? "Automatic"}
>
{window.frappe?.boot?.user?.name && window.frappe?.boot?.user?.name !== 'Guest' &&
<BrowserRouter basename={import.meta.env.VITE_BASE_NAME ? `/${import.meta.env.VITE_BASE_NAME}` : ''}>
<Routes>
<Route index element={<BankReconciliation />} />
<Route path="/statement-importer" element={<BankStatementImporterContainer />}>
<Route index element={<BankStatementImporter />} />
<Route path=":id" element={<ViewBankStatementImportLog />} />
</Route>
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</BrowserRouter>
}
<Toaster richColors />
</ThemeProvider>
</FrappeProvider>
</TooltipProvider>
</LucideProvider>
)
}
export default App

View File

@@ -1,228 +0,0 @@
import { Button } from "@/components/ui/button"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
import _ from "@/lib/translate"
import { cn } from "@/lib/utils"
import { useFrappeGetDocList } from "frappe-react-sdk"
import Fuse from "fuse.js"
import { ChevronDownIcon } from "lucide-react"
import { useLayoutEffect, useMemo, useRef, useState } from "react"
import { FormControl } from "../ui/form"
export interface AccountsDropdownProps {
root_type?: ('Asset' | 'Liability' | 'Equity' | 'Income' | 'Expense')[],
report_type?: 'Balance Sheet' | 'Profit and Loss',
account_type?: string[],
value?: string,
onChange?: (value: string) => void,
readOnly?: boolean,
disabled?: boolean,
company?: string,
filterFunction?: (account: Account) => boolean,
// If true, the component will be wrapped in a FormControl component
useInForm?: boolean,
buttonClassName?: string,
size?: 'sm' | 'md' | 'lg',
}
/**
* Component to select an account - supports fuzzy search
* @param root_type - The root type of the account
* @param report_type - The report type of the account
* @param account_type - The type of the account
* @param value - The value of the account field
* @param onChange - The function to call when the value changes
* @returns
*/
const AccountsDropdown = ({ root_type, report_type, account_type, value, onChange, readOnly, disabled, company, filterFunction, useInForm, buttonClassName, size = 'md' }: AccountsDropdownProps) => {
const { data } = useGetAccounts(root_type, report_type, account_type, company, filterFunction)
const groupedAccounts = useMemo(() => {
if (!data) return []
const grouped: Record<string, Account[]> = data.reduce((acc, account) => {
const parentAccount = account.parent_account
if (!parentAccount) return acc
if (!acc[parentAccount]) {
acc[parentAccount] = []
}
acc[parentAccount].push(account)
return acc
}, {} as Record<string, Account[]>)
return Object.entries(grouped).map(([parentAccount, accounts]) => ({
// Remove the last abbreviation from the parent account name like "Assets - TCC" should be "Assets", and "Assets - USD - TCC" should be "Assets - USD"
parentAccount: parentAccount.split(" - ").slice(0, -1).join(" - "),
accounts
}))
}, [data])
const searchIndex = useMemo(() => {
if (!data) {
return null
}
return new Fuse(data, {
keys: ['name'],
threshold: 0.5,
includeScore: true
})
}, [data])
const [search, setSearch] = useState("")
const recommendedAccounts = useMemo(() => {
if (!searchIndex || !search) {
return []
}
return searchIndex.search(search).map((result) => result.item)
}, [searchIndex, search])
const [open, setOpen] = useState(false)
const onOpenChange = (open: boolean) => {
if (readOnly) return
setOpen(open)
// setSearch("")
}
const onSelect = (value: string) => {
onChange?.(value)
setOpen(false)
setSearch(value)
}
const buttonRef = useRef<HTMLButtonElement>(null)
const [width, setWidth] = useState(320)
useLayoutEffect(() => {
if (buttonRef.current) {
setWidth(buttonRef.current.getBoundingClientRect().width)
}
}, [])
return (
<Popover open={open} onOpenChange={onOpenChange} modal={true}>
<PopoverTrigger asChild>
{useInForm ? <FormControl>
<Button
variant="subtle"
type='button'
size={size}
role="combobox"
ref={buttonRef}
tabIndex={0}
disabled={disabled || readOnly}
aria-readonly={readOnly}
aria-expanded={open}
className={cn("w-full justify-between font-normal",
readOnly ? "bg-surface-gray-1 pointer-events-none" : ""
, buttonClassName)}>
{value || _('Select Account')}
<ChevronDownIcon className="ms-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
: <Button
variant="subtle"
size={size}
type='button'
role="combobox"
ref={buttonRef}
disabled={disabled}
aria-expanded={open}
className={cn("w-full justify-between font-normal",
readOnly ? "bg-surface-gray-1" : ""
)}>
{value || _('Select Account')}
<ChevronDownIcon className="ms-2 h-4 w-4 shrink-0 opacity-50" />
</Button>}
</PopoverTrigger>
<PopoverContent className="p-0" style={{ minWidth: width }} align="start">
<Command shouldFilter={false} className="w-full">
<CommandInput placeholder={_("Search account...")} onValueChange={setSearch} value={search} />
<CommandList>
<CommandEmpty>{_("No accounts found.")}</CommandEmpty>
{recommendedAccounts.length > 0 && (
<CommandGroup heading={_("Search Results")}>
{recommendedAccounts.map((account) => (
<CommandItem key={account.name} onSelect={() => onSelect(account.name)}>{account.name}</CommandItem>
))}
</CommandGroup>
)}
{!search && groupedAccounts.map((group) => (
<CommandGroup key={group.parentAccount} heading={group.parentAccount}>
{group.accounts.map((account) => (
<CommandItem key={account.name} onSelect={() => onSelect(account.name)}>{account.name}</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
interface Account {
name: string
root_type: 'Asset' | 'Liability' | 'Equity' | 'Income' | 'Expense'
report_type: 'Balance Sheet' | 'Profit and Loss'
account_type: string
account_currency: string
parent_account: string
}
export const useGetAccounts = (root_type?: ('Asset' | 'Liability' | 'Equity' | 'Income' | 'Expense')[], report_type?: 'Balance Sheet' | 'Profit and Loss', account_type?: string[], company?: string,
filterFunction?: (account: Account) => boolean) => {
const currentCompany = useCurrentCompany()
const { data, isLoading, error, mutate } = useFrappeGetDocList<Account>("Account", {
fields: ["name", "root_type", "report_type", "account_type", "account_currency", "parent_account"],
filters: [["is_group", "=", 0], ["disabled", "=", 0], ["company", "=", company ?? currentCompany]],
limit: 1000,
orderBy: {
"field": "root_type",
// @ts-expect-error - we can pass in additional fields to orderBy
"order": "asc, account_number asc"
}
}, `accounts-${company ?? currentCompany}`, {
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
})
const filteredData = useMemo(() => {
return data?.filter((account) => {
if (root_type && !root_type.includes(account.root_type)) return false
if (report_type && account.report_type !== report_type) return false
if (account_type && !account_type.includes(account.account_type)) return false
if (filterFunction) return filterFunction(account)
return true
}) ?? []
}, [data, root_type, report_type, account_type, filterFunction])
return { data: filteredData, isLoading, error, mutate }
}
export default AccountsDropdown

View File

@@ -1,26 +0,0 @@
import { cn } from '@/lib/utils'
import { SelectedBank } from '../features/BankReconciliation/bankRecAtoms'
import { useTheme } from '../ui/theme-provider'
import { Landmark } from 'lucide-react'
import { H4 } from '../ui/typography'
const BankLogo = ({ bank, className, imageClassName, iconSize = '18px', iconClassName }: { bank?: SelectedBank | null, className?: string, imageClassName?: string, iconSize?: string, iconClassName?: string }) => {
const { themeValue } = useTheme()
return (
<div className={cn('h-6 flex items-center gap-1', className)}> {bank?.logo ? <img
src={`/assets/erpnext/images/bank-logos/${themeValue === 'Dark' ? (bank.logoDark ?? bank.logo) : bank.logo}`}
alt={bank.bank || bank.name || ''}
className={cn("h-6 max-w-22 me-auto object-contain", imageClassName, {
'dark:invert dark:brightness-0': bank.darkModeInvert
}, bank.logoClassName)}
/> : <>
<Landmark size={iconSize} className={iconClassName} />
<H4 className={cn("text-xs -mb-0.5", {
})}>{bank?.bank ?? ''}</H4>
</>
}</div>
)
}
export default BankLogo

View File

@@ -1,17 +0,0 @@
import { CheckCircle } from 'lucide-react'
import { Progress } from '../ui/progress'
import _ from '@/lib/translate'
const FileUploadBanner = ({
uploadProgress,
}: { uploadProgress: number }) => {
return <div className="flex items-center justify-center flex-col gap-4">
<div className="flex flex-col items-center gap-4">
<CheckCircle size={48} className="text-ink-green-3" />
<span className="text-ink-gray-8 text-p-base">{_("The document has been created and reconciled. Uploading attachments...")}</span>
<Progress value={Math.round(uploadProgress * 100)} size="lg" />
</div>
</div>
}
export default FileUploadBanner

View File

@@ -1,301 +0,0 @@
import { useDocType } from "@/hooks/useDocType";
import { getSystemDefault, slug } from "@/lib/frappe";
import { Filter, useFrappeGetCall } from "frappe-react-sdk"
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { canCreateDocument } from "@/lib/permissions";
import { useDebounceValue } from "usehooks-ts";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { FormControl } from "../ui/form";
import { ChevronDownIcon, ExternalLink } from "lucide-react";
import { Button } from "../ui/button";
import { cn } from "@/lib/utils";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "../ui/command";
import _ from "@/lib/translate";
import ErrorBanner from "../ui/error-banner";
import MarkdownRenderer from "../ui/markdown";
export interface ResultItem {
value: string,
description: string,
label?: string
}
export interface LinkFieldComboboxProps {
/** DocType to be fetched */
doctype: string;
/** Filters to be applied. Default: none */
filters?: Filter[]
/** Number of records to paginate with. Default: Comes from System Settings or 10 */
limit?: number;
/**
* API to call to fetch records.
*
* Default: `frappe.desk.search.search_link`
*
* If you want to use a custom API, you can pass the path to the API here.
*
* The API should return a list of documents in the following format:
* [{value: string, description: string, label?: string}] - where the value is the ID of the document.
*
* If the API sends a label, it will be used as the label in the dropdown.
*/
searchAPIPath?: string;
/**
* Field you want to search against in the doctype.
*
* Default: `name`
*
* If you want to search against a different field, you can pass the fieldname here.
*
* If you want to search against multiple fields, you can try using the `searchAPIPath` prop to call a custom API,
* or use a custom query in the `customQuery` prop.
*/
searchfield?: string;
/**
* Custom query to be used to fetch records.
*
* If you want to use a custom query, you can pass the query here.
*
* The query should be in the following format:
* {
* query: string,
* filters: {
* fieldname: string,
* operator: string,
* value: string
* }
* }
*/
customQuery?: {
/** Path to function for the query.
*
* Refer: Item/Supplier query
*/
query: string,
/** Filters are usually an object instead of an array in a custom query */
filters?: Record<string, string | number | boolean>,
},
/**
* Used for certain queries where a reference doctype is needed.
*
* For example when searching a supplier in a "Purchase Invoice", the reference_doctype is "Purchase Invoice"
*/
reference_doctype?: string,
/** Placeholder for the dropdown. Default: `doctype` */
placeholder?: string;
/**
* Should the field be read-only.
*/
readOnly?: boolean;
/** Should the field be disabled. Default: false */
disabled?: boolean;
/**
* Function to filter the options based on the input value/other criteria.
*
* For example, you might want to limit the companies shown in the dropdown since they have been already added (like in Cost Codes)
*/
filterFn?: (option: ResultItem, inputValue: string) => boolean,
value?: string,
onChange: (value: string) => void,
/** If true, the component will be wrapped in a FormControl component */
useInForm?: boolean,
/** Button Class name */
buttonClassName?: string,
size?: 'sm' | 'md' | 'lg',
}
const LinkFieldCombobox = ({
doctype,
reference_doctype,
filters = [],
value,
onChange,
readOnly,
disabled,
filterFn,
placeholder = `Select ${doctype}`,
customQuery,
searchfield,
searchAPIPath = "frappe.desk.search.search_link",
limit,
useInForm,
buttonClassName,
size = 'md'
}: LinkFieldComboboxProps) => {
const pageLimit = useMemo(() => limit || getSystemDefault('link_field_results_limit') || 20, [limit])
/** Load the Doctype meta so that we can determine the search fields + the name of the title field */
const { data: meta } = useDocType(doctype)
const userCanCreate = useMemo(() => canCreateDocument(doctype), [doctype])
const [open, setOpen] = useState(false)
const [searchInput, setSearchInput] = useDebounceValue('', 400)
const { data: linkTitleData } = useFrappeGetCall('frappe.client.get_value', {
doctype,
filters: JSON.stringify({
name: value
}),
fieldname: meta?.title_field
}, (meta?.show_title_field_in_link ?? false) && (meta?.title_field) && value ? `link_title::${doctype}::${value}` : null, {
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
})
const linkTitle = meta?.title_field && meta?.show_title_field_in_link ? (linkTitleData?.message?.[meta?.title_field] ?? value) : value
const buttonRef = useRef<HTMLButtonElement>(null)
const [width, setWidth] = useState(320)
useLayoutEffect(() => {
if (buttonRef.current) {
setWidth(buttonRef.current.getBoundingClientRect().width)
}
}, [])
const { data, error, isLoading } = useFrappeGetCall<{ message: ResultItem[] }>(searchAPIPath, {
doctype,
txt: searchInput,
page_length: pageLimit,
query: customQuery?.query,
searchfield,
filters: JSON.stringify(customQuery?.filters || filters || []),
reference_doctype,
}, () => {
if (!open) {
return null
} else {
let key = `${searchAPIPath}_${doctype}_${searchInput}`
if (pageLimit) {
key += `_${pageLimit}`
}
if (customQuery?.filters) {
key += `_${JSON.stringify(customQuery.filters)}`
} else if (filters) {
key += `_${JSON.stringify(filters)}`
}
if (customQuery && customQuery.query) {
key += `_${customQuery.query}`
}
if (reference_doctype) {
key += `_${reference_doctype}`
}
if (searchfield && searchfield !== 'name') {
key += `_${searchfield}`
}
return key
}
}, {
revalidateOnFocus: false,
revalidateIfStale: false,
shouldRetryOnError: false,
revalidateOnReconnect: false,
})
const onOpenChange = (open: boolean) => {
if (readOnly) return
setOpen(open)
setSearchInput("")
}
const onSelect = (value: string) => {
onChange?.(value)
setOpen(false)
}
const items = filterFn ? data?.message?.slice(0, 50).filter((item) => filterFn(item, searchInput)) : data?.message
const buttonProps = {
variant: "subtle",
type: 'button',
size: size,
role: "combobox",
"data-state": open ? "open" : "closed",
ref: buttonRef,
tabIndex: 0,
disabled: disabled || readOnly,
"aria-expanded": open,
"aria-readonly": readOnly,
className: cn("w-full justify-between font-normal group border border-transparent outline-none",
"data-[state=open]:bg-surface-white data-[state=open]:border-outline-gray-4 data-[state=open]:shadow-sm",
readOnly ? "bg-surface-gray-1" : "",
// Placeholder and value styling
linkTitle ? "text-ink-gray-7" : "text-ink-gray-4",
buttonClassName)
} as const
return (
<Popover open={open} onOpenChange={onOpenChange} modal={true}>
<PopoverTrigger asChild>
{useInForm ? <FormControl>
<Button {...buttonProps}>
{linkTitle || placeholder}
<div className="flex items-center gap-1">
{value && <a href={`/desk/${slug(doctype)}/${value}`} target="_blank" className="group-hover:block hidden">
<ExternalLink className="size-4 shrink-0 opacity-50" />
</a>}
<ChevronDownIcon className="ms-2 size-4 shrink-0" />
</div>
</Button>
</FormControl>
: <Button {...buttonProps}>
{linkTitle || placeholder}
<div className="flex items-center gap-1">
{value && <a href={`/desk/${slug(doctype)}/${value}`} target="_blank" className="group-hover:block hidden">
<ExternalLink className="size-4 shrink-0 opacity-50" />
</a>}
<ChevronDownIcon className="ms-2 size-4 shrink-0" />
</div>
</Button>}
</PopoverTrigger>
<PopoverContent className="p-0" style={{ minWidth: width }} align="start">
{error && <ErrorBanner error={error} />}
<Command shouldFilter={false} className="w-full">
<CommandInput placeholder={placeholder} onValueChange={setSearchInput} />
<CommandList>
<CommandEmpty>{isLoading ? _("Loading...") : _("No results found.")}</CommandEmpty>
<CommandGroup>
{items?.map((result) => (
<CommandItem key={result.value} onSelect={() => onSelect(result.value)} className="flex flex-col items-start gap-0.5">
<span className="font-medium">
{result.label || result.value}
</span>
{result.description && <span className="text-xs text-ink-gray-5">
<MarkdownRenderer content={result.description} />
</span>}
</CommandItem>
))}
{userCanCreate && <CommandItem asChild>
<a href={`/desk/${slug(doctype)}/new-${slug(doctype)}-1`}
target="_blank"
className="hover:underline underline-offset-4 cursor-pointer flex justify-between items-center">
{_("Create New {0}", [doctype])}
<ExternalLink />
</a>
</CommandItem>}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
export default LinkFieldCombobox

View File

@@ -1,82 +0,0 @@
import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/select'
import _ from '@/lib/translate'
import { useFrappeGetDocList } from 'frappe-react-sdk'
import { ComponentProps, useMemo } from 'react'
import { FormControl } from '../ui/form'
export type PartyTypeDropdownProps = {
value?: string,
onChange?: (value: string) => void,
readOnly?: boolean,
disabled?: boolean,
/** Set this to order the parties so that suggested types are shown first */
type?: 'Receivable' | 'Payable'
/** Set this to true if you want to hide other options by type. e.g. - if type is Receivable, Payable options like "Supplier" will be hidden */
hideOptionsByType?: boolean,
valueProps?: ComponentProps<typeof SelectValue>,
triggerProps?: ComponentProps<typeof SelectTrigger>,
// If true, the component will be wrapped in a FormControl component
useInForm?: boolean
}
const PartyTypeDropdown = ({ value, onChange, readOnly, disabled, type, hideOptionsByType, valueProps, triggerProps, useInForm }: PartyTypeDropdownProps) => {
const { data } = useFrappeGetDocList("Party Type", {
fields: ['name', 'account_type'],
orderBy: {
field: 'creation',
order: 'asc'
}
}, `party_types`, {
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
})
const filteredData = useMemo(() => {
let options = data ?? [
{ name: "Customer", account_type: "Receivable" },
{ name: "Supplier", account_type: "Payable" },
{ name: "Employee", account_type: "Payable" },
{ name: "Shareholder", account_type: "Payable" },
]
if (hideOptionsByType && type) {
options = options.filter((option) => option.account_type === type)
}
// Order by type if type is set
if (type) {
options = options.sort((a) => a.account_type === type ? -1 : 1)
}
return options
}, [data, type, hideOptionsByType])
const onSelect = (value: string) => {
if (!readOnly) {
onChange?.(value)
}
}
return (
<Select onValueChange={onSelect} value={value} disabled={disabled}>
{useInForm ? <FormControl>
<SelectTrigger tabIndex={0} aria-readonly={readOnly} disabled={disabled || readOnly} {...triggerProps}>
<SelectValue placeholder={_("Type")} aria-readonly={readOnly} {...valueProps} />
</SelectTrigger>
</FormControl> : <SelectTrigger tabIndex={0} {...triggerProps}>
<SelectValue placeholder={_("Type")} aria-readonly={readOnly} {...valueProps} />
</SelectTrigger>
}
<SelectContent>
{filteredData.map((option) => (
<SelectItem key={option.name} value={option.name}>{option.name}</SelectItem>
))}
</SelectContent>
</Select>
)
}
export default PartyTypeDropdown

View File

@@ -1,475 +0,0 @@
import { Button } from '@/components/ui/button'
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import _ from '@/lib/translate'
import { useAtomValue, useSetAtom } from 'jotai'
import { ArrowDownRight, ArrowRightLeftIcon, ArrowUpRight, CalendarIcon, CircleXIcon, GitCompareIcon, HistoryIcon, LandmarkIcon, Loader2Icon, ReceiptIcon, ReceiptTextIcon, UserIcon, WalletIcon } from 'lucide-react'
import { useMemo, useState } from 'react'
import { ActionLogItem, ActionLog as ActionLogType, bankRecActionLog, bankRecDateAtom, bankRecMatchFilters, SelectedBank, selectedBankAccountAtom } from '../BankReconciliation/bankRecAtoms'
import { useHotkeys } from 'react-hotkeys-hook'
import { useGetBankAccounts } from '../BankReconciliation/utils'
import { getCompanyCurrency } from '@/lib/company'
import { formatCurrency } from '@/lib/numbers'
import dayjs from 'dayjs'
import { cn } from '@/lib/utils'
import { formatDate } from '@/lib/date'
import { Separator } from '@/components/ui/separator'
import { slug } from '@/lib/frappe'
import { PaymentEntry } from '@/types/Accounts/PaymentEntry'
import { JournalEntry } from '@/types/Accounts/JournalEntry'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { Table, TableCell, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
import { useFrappePostCall, useSWRConfig } from 'frappe-react-sdk'
import { toast } from 'sonner'
import { getErrorMessage } from '@/lib/frappe'
import ErrorBanner from '@/components/ui/error-banner'
import SelectedTransactionDetails from '../BankReconciliation/SelectedTransactionDetails'
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
import BankLogo from '@/components/common/BankLogo'
const ActionLog = () => {
const [isOpen, setIsOpen] = useState(false)
useHotkeys('meta+z', () => {
setIsOpen(true)
}, {
enabled: true,
enableOnFormTags: false,
preventDefault: true
})
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button variant={'outline'} isIconButton size='md'>
<HistoryIcon />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>
{_("Reconciliation History")}
</TooltipContent>
</Tooltip>
<DialogContent className='min-w-[90vw]'>
<DialogHeader>
<DialogTitle>{_("Reconciliation History")}</DialogTitle>
<DialogDescription>{_("View all reconciliation actions taken in this session.")}</DialogDescription>
</DialogHeader>
<ActionLogDialogContent />
<DialogFooter>
<DialogClose asChild>
<Button variant={'outline'} size='md' onClick={() => setIsOpen(false)}>{_("Close")}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
const ActionLogDialogContent = () => {
const actionLog = useAtomValue(bankRecActionLog)
return <div className='flex flex-col gap-2'>
{actionLog.map((action) => (
<div key={action.timestamp} className='flex flex-col gap-1'>
<ActionGroupHeader action={action} />
<div>
<div className='ms-2 border-s border-s-outline-gray-2 py-1'>
<div className='ms-5'>
{action.items.map((item, index) => (
<Row
item={item}
key={item.bankTransaction.name}
index={index}
action={action}
isLast={index === action.items.length - 1} />
))}
</div>
</div>
</div>
</div>
))}
{actionLog.length === 0 && <Empty>
<EmptyMedia>
<HistoryIcon />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>{_("No reconciliation actions found")}</EmptyTitle>
<EmptyDescription>{_("You have not performed any reconciliations in this session yet.")}</EmptyDescription>
</EmptyHeader>
</Empty>}
</div>
}
const ActionGroupHeader = ({ action }: { action: ActionLogType }) => {
const label = useMemo(() => {
switch (action.type) {
case 'match':
return _("Matched")
case 'payment':
if (action.isBulk) {
return _("Bulk Payment")
}
return _("Payment")
case 'transfer':
if (action.isBulk) {
return _("Bulk Transfer")
}
return _("Transfer")
case 'bank_entry':
if (action.isBulk) {
return _("Bulk Bank Entry")
}
return _("Bank Entry")
default:
return _("Action")
}
}, [action])
return <div className='flex items-center gap-2 text-ink-gray-5'>
{action.type === 'match' && <GitCompareIcon className='w-4 h-4' />}
{action.type === 'payment' && <ReceiptIcon className='w-4 h-4' />}
{action.type === 'transfer' && <ArrowRightLeftIcon className='w-4 h-4' />}
{action.type === 'bank_entry' && <LandmarkIcon className='w-4 h-4' />}
<span className='flex items-center gap-2 text-sm'>
{label} - {dayjs(action.timestamp).fromNow()}
</span>
</div>
}
const Row = ({ item, index, isLast, action }: { item: ActionLogItem, index: number, isLast: boolean, action: ActionLogType }) => {
const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0
const { banks } = useGetBankAccounts()
const bank = useMemo(() => {
if (item.bankTransaction.bank_account) {
return banks?.find((bank) => bank.name === item.bankTransaction.bank_account)
}
return null
}, [item.bankTransaction.bank_account, banks])
const amount = item.bankTransaction.withdrawal ? item.bankTransaction.withdrawal : item.bankTransaction.deposit
const currency = item.bankTransaction.currency || getCompanyCurrency(item.bankTransaction.company ?? '')
return <div className='flex items-center gap-2 group'>
<div className={cn('p-3.5 group-hover:bg-surface-gray-1 border-s border-e border-t w-full', isLast ? 'rounded-b border-b' : '', index === 0 ? 'rounded-t' : '')}>
<div className='flex justify-between items-center'>
<div className='flex flex-col gap-2'>
<p className='text-p-base'>{item.bankTransaction.description}</p>
<div className='flex items-center gap-3'>
<div className='flex gap-2 items-center'>
<BankLogo bank={bank} className='h-4 mb-0' iconSize='16px' />
<span className='text-sm text-ink-gray-5'>{item.bankTransaction.bank_account}</span>
</div>
<Separator orientation='vertical' />
<div className='flex items-center gap-2 text-ink-gray-5 text-sm' title={_("Transaction Date")}>
<CalendarIcon className='w-4 h-4' />
<span className='text-sm'>{formatDate(item.bankTransaction.date, 'Do MMM YYYY')}</span>
</div>
<Separator orientation='vertical' />
<div>
<div className='flex items-center gap-1' title={isWithdrawal ? _("Spent") : _("Received")}>
{isWithdrawal ? <ArrowUpRight className="w-5 h-5 text-ink-red-3" /> : <ArrowDownRight className="w-5 h-5 text-ink-green-3" />}
<span className='text-sm text-ink-gray-5'>{formatCurrency(amount, currency)}</span>
</div>
</div>
</div>
</div>
<div className='flex justify-end items-center gap-2'>
<div className='text-end flex flex-col gap-2'>
<a
href={`/desk/${slug(item.voucher.reference_doctype)}/${item.voucher.reference_name}`}
target='_blank'
className='underline underline-offset-4 text-base'>
{["Payment Entry", "Journal Entry"].includes(item.voucher.reference_doctype) ? "" : _("{} :", [item.voucher.reference_doctype])} {item.voucher.reference_name}
</a>
{item.voucher.reference_doctype === "Payment Entry" && item.voucher.doc && <PaymentEntryDetails item={item} />}
{item.voucher.reference_doctype === "Journal Entry" && <JournalEntryDetails item={item} bank={bank} />}
</div>
</div>
</div>
</div>
<div className='w-10 h-10 flex items-center justify-center'>
<CancelActionLogItem item={item} type={action.type} timestamp={action.timestamp} bank={bank} />
</div>
</div>
}
const JournalEntryDetails = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => {
return <div className='flex items-center gap-2 text-ink-gray-5 justify-end'>
<WalletIcon className='w-4 h-4' />
<JournalEntryAccountsTable item={item} bank={bank} />
</div>
}
const JournalEntryAccountsTable = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => {
const accounts = useMemo(() => {
const allAccounts = (item.voucher.doc as JournalEntry).accounts
return allAccounts.filter((acc) => bank ? acc.account !== bank.account : true)
}, [item, bank])
return <>
{accounts.length === 1 ? <span className='text-sm'>{accounts[0].account}</span> :
<HoverCard>
<HoverCardTrigger>
<span className='text-sm cursor-pointer hover:underline underline-offset-4'>{_("Split across {} accounts", [accounts.length.toString()])}</span>
</HoverCardTrigger>
<HoverCardContent className='w-full p-2' align='end'>
<Table>
<TableHeader>
<TableRow>
<TableHead>{_("Account")}</TableHead>
<TableHead className='text-end'>{_("Debit")}</TableHead>
<TableHead className='text-end'>{_("Credit")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{accounts.map((account) => (
<TableRow key={account.account}>
<TableCell>{account.account}</TableCell>
<TableCell className='text-end font-numeric'>{formatCurrency(account.debit ?? 0, account.account_currency ?? '')}</TableCell>
<TableCell className='text-end font-numeric'>{formatCurrency(account.credit ?? 0, account.account_currency ?? '')}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</HoverCardContent>
</HoverCard>
}</>
}
const PaymentEntryDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => {
if ((item.voucher.doc as PaymentEntry).payment_type === "Internal Transfer") {
return <TransferDetails item={item} className={className} />
}
const invoices = (item.voucher.doc as PaymentEntry).references ?? []
const currency = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0 ? (item.voucher.doc as PaymentEntry)?.paid_to_account_currency : (item.voucher.doc as PaymentEntry)?.paid_from_account_currency
return <div className='flex items-center gap-3'>
<div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
<UserIcon className='w-4 h-4' />
<span className='text-sm'>{(item.voucher.doc as PaymentEntry).party_name}</span>
</div>
<Separator orientation='vertical' />
<HoverCard>
<HoverCardTrigger>
<div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
<ReceiptTextIcon className='w-4 h-4' />
<span className='text-sm cursor-pointer hover:underline underline-offset-4'>{invoices.length === 0 ? _("No invoice linked") : invoices.length === 1 ? _("1 invoice") : _("{} invoices", [invoices.length.toString()])}</span>
</div>
</HoverCardTrigger>
<HoverCardContent className='w-full p-2' align='end'>
<div className='flex flex-col gap-2'>
{invoices.map((invoice) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>{_("Document")}</TableHead>
<TableHead>{_("Invoice No")}</TableHead>
<TableHead>{_("Due Date")}</TableHead>
<TableHead className='text-end'>{_("Grand Total")}</TableHead>
<TableHead className='text-end'>{_("Allocated")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell><a href={`/desk/${slug(invoice.reference_doctype)}/${invoice.reference_name}`} target='_blank' className='underline underline-offset-4'>{invoice.reference_doctype}: {invoice.reference_name}</a></TableCell>
<TableCell>{invoice.bill_no ?? "-"}</TableCell>
<TableCell>{formatDate(invoice.due_date)}</TableCell>
<TableCell className='text-end font-numeric'>{formatCurrency(invoice.total_amount, currency ?? '')}</TableCell>
<TableCell className='text-end font-numeric'>{formatCurrency(invoice.allocated_amount, currency ?? '')}</TableCell>
</TableRow>
</TableBody>
</Table>
))}
</div>
</HoverCardContent>
</HoverCard>
</div>
}
const TransferDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => {
const { banks } = useGetBankAccounts()
const bank = useMemo(() => {
const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0
let transferAccount = ""
if (isWithdrawal) {
transferAccount = (item.voucher.doc as PaymentEntry).paid_to
} else {
transferAccount = (item.voucher.doc as PaymentEntry).paid_from
}
const transferBankAccount = banks?.find((bank) => bank.account === transferAccount)
return transferBankAccount
}, [banks, item])
return <div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
<BankLogo bank={bank} className='h-5 mb-0' iconSize='16px' imageClassName='max-h-5' />
<span className='text-sm'>{bank?.account}</span>
</div>
}
const ACTION_TYPE_MAP = {
'bank_entry': _("Bank Entry"),
'payment': _("Payment"),
'transfer': _("Transfer"),
'match': _("Match"),
}
const CancelActionLogItem = ({ item, type, timestamp, bank }: { item: ActionLogItem, type: ActionLogType['type'], timestamp: number, bank?: SelectedBank | null }) => {
const [isOpen, setIsOpen] = useState(false)
const { call, loading, error } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction_entry')
const { mutate } = useSWRConfig()
const actionLog = useSetAtom(bankRecActionLog)
const dates = useAtomValue(bankRecDateAtom)
const matchFilters = useAtomValue(bankRecMatchFilters)
const selectedBank = useAtomValue(selectedBankAccountAtom)
const onUndo = () => {
call({
bank_transaction_id: item.bankTransaction.name,
voucher_type: item.voucher.reference_doctype,
voucher_id: item.voucher.reference_name,
}).then(() => {
toast.success(type === 'match' ? _("Unmatched") : _("Cancelled"))
if (selectedBank?.name === item.bankTransaction.bank_account) {
mutate(`bank-reconciliation-unreconciled-transactions-${selectedBank?.name}-${dates.fromDate}-${dates.toDate}`)
mutate(`bank-reconciliation-account-closing-balance-${selectedBank?.name}-${dates.toDate}`)
// Update the matching vouchers for the selected transaction
mutate(`bank-reconciliation-vouchers-${item.bankTransaction.name}-${dates.fromDate}-${dates.toDate}-${matchFilters.join(',')}`)
}
setTimeout(() => {
actionLog((prev) => {
// Find the action and then remove the item from the action. If the action is empty, remove the action from the array
const action = prev.find((action) => action.timestamp === timestamp)
if (action) {
action.items = action.items.filter((i) => i.bankTransaction.name !== item.bankTransaction.name)
}
// If the action is empty, remove the action from the array
if (action && action.items.length === 0) {
return prev.filter((a) => a.timestamp !== timestamp)
} else {
return prev.map((a) => a.timestamp === timestamp ? { ...a, items: action?.items ?? [] } : a)
}
})
}, 100)
setIsOpen(false)
}).catch((error) => {
toast.error(_("There was an error while performing the action."), {
duration: 5000,
description: getErrorMessage(error),
})
})
}
return <AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
<TooltipTrigger asChild>
<AlertDialogTrigger asChild>
<Button
variant={'ghost'}
isIconButton
theme='red'
title={_("Cancel")}
className='hover:text-ink-red-3 hover:bg-destructive/5 text-ink-gray-5 hidden group-hover:inline-flex'>
<CircleXIcon className='w-8 h-8' />
</Button>
</AlertDialogTrigger>
</TooltipTrigger>
<TooltipContent>
{_("Cancel")}
</TooltipContent>
</Tooltip>
<AlertDialogContent className='min-w-3xl'>
<AlertDialogHeader>
<AlertDialogTitle>{type === 'match' ? _("Unmatch Transaction?") : _("Undo {}?", [item.voucher.reference_doctype])}</AlertDialogTitle>
<AlertDialogDescription>{type === 'match' ? _("Are you sure you want to unmatch the voucher from this transaction?") : _("Are you sure you want to cancel this {} {}?", [_(item.voucher.reference_doctype), item.voucher.reference_name])}</AlertDialogDescription>
</AlertDialogHeader>
{error && <ErrorBanner error={error} />}
<div className='flex flex-col gap-2'>
<SelectedTransactionDetails transaction={item.bankTransaction} />
<Table>
<TableRow>
<TableHead>{_("Action Type")}</TableHead>
<TableCell>{ACTION_TYPE_MAP[type]}</TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Voucher Type")}</TableHead>
<TableCell>{_(item.voucher.reference_doctype)}</TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Voucher Name")}</TableHead>
<TableCell><a href={`/desk/${slug(item.voucher.reference_doctype)}/${item.voucher.reference_name}`} target='_blank' className='underline underline-offset-4'>{item.voucher.reference_name}</a></TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Posting Date")}</TableHead>
<TableCell>{formatDate(item.voucher.posting_date, 'Do MMM YYYY')}</TableCell>
</TableRow>
{type === 'transfer' && item.voucher.doc && <TableRow>
<TableHead>{_("Transfer Account")}</TableHead>
<TableCell>
<TransferDetails item={item} className='text-ink-gray-8' />
</TableCell>
</TableRow>}
{type === 'payment' && item.voucher.doc && <TableRow>
<TableHead>{_("Payment Details")}</TableHead>
<TableCell>
<PaymentEntryDetails item={item} className='text-ink-gray-8' />
</TableCell>
</TableRow>}
{type === 'bank_entry' && item.voucher.doc && <TableRow>
<TableHead>{_("Account")}</TableHead>
<TableCell><JournalEntryAccountsTable item={item} bank={bank} /></TableCell>
</TableRow>}
</Table>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>
{_("Close")}
</AlertDialogCancel>
<Button theme="red" size='md' disabled={loading} onClick={onUndo}>
{loading ? <Loader2Icon className='w-4 h-4 animate-spin' /> : _(("Undo"))}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
}
export default ActionLog

View File

@@ -1,334 +0,0 @@
import { useAtomValue, useSetAtom } from "jotai"
import { bankRecClosingBalanceAtom, bankRecDateAtom, SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms"
import { FrappeConfig, FrappeContext, useFrappeGetDocCount, useFrappeGetDocList, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
import { BankTransaction } from "@/types/Accounts/BankTransaction"
import { Progress } from "@/components/ui/progress"
import { useGetAccountClosingBalance, useGetAccountClosingBalanceAsPerStatement, useGetAccountOpeningBalance, useGetUnreconciledTransactions } from "./utils"
import { flt, formatCurrency } from "@/lib/numbers"
import { Skeleton } from "@/components/ui/skeleton"
import { StatContainer, StatLabel, StatValue } from "@/components/ui/stats"
import { Edit, Info, Trash2 } from "lucide-react"
import { H4, Paragraph } from "@/components/ui/typography"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
import { getCompanyCurrency } from "@/lib/company"
import _ from "@/lib/translate"
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { formatDate } from "@/lib/date"
import { Form } from "@/components/ui/form"
import { CurrencyFormField } from "@/components/ui/form-elements"
import { useForm } from "react-hook-form"
import { Button } from "@/components/ui/button"
import { useContext, useState } from "react"
import { Separator } from "@/components/ui/separator"
import { BankAccountBalance } from "@/types/Accounts/BankAccountBalance"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "sonner"
import ErrorBanner from "@/components/ui/error-banner"
const BankBalance = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
if (!bankAccount) {
return null
}
return (
<div className="flex justify-between">
<div className="w-[80%] flex flex-wrap justify-between gap-2 pe-8 border-e-border border-e">
<OpeningBalance />
<ClosingBalance />
<ClosingBalanceAsPerStatement />
<Difference />
</div>
<ReconcileProgress />
</div>
)
}
const OpeningBalance = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
const { data, isLoading } = useGetAccountOpeningBalance()
return <StatContainer className="min-w-48">
<StatLabel>{_("Opening Balance")}</StatLabel>
{isLoading ? <Skeleton className="w-[150px] h-5 rounded-sm" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>}
</StatContainer>
}
const ClosingBalance = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
const { data, isLoading } = useGetAccountClosingBalance()
return (
<StatContainer className="min-w-48">
<div className="flex items-start gap-1">
<StatLabel>
{_("Closing Balance as per system")}
</StatLabel>
<HoverCard openDelay={100}>
<HoverCardTrigger>
<Info className="size-3.5 text-ink-gray-6 -mt-px" />
</HoverCardTrigger>
<HoverCardContent className="w-96" align="start" side="right">
<H4 className="text-base">{_("Closing balance as per system")}</H4>
<Paragraph className="mt-2 text-p-sm">
{_("This is what the system expects the closing balance to be in your bank statement.")}
<br />
{_("It takes into account all the transactions that have been posted and subtracts the transactions that have not cleared yet.")}
<br />
{_("If your bank statement shows a different closing balance, it is because all transactions have not reconciled yet.")}
<br /><br />
For more information, click on the <strong>Bank Reconciliation Statement</strong> tab below.
</Paragraph>
</HoverCardContent>
</HoverCard>
</div>
{isLoading ? <Skeleton className="w-[150px] h-5 rounded-sm" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>}
</StatContainer>
)
}
const Difference = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
const { data, isLoading } = useGetAccountClosingBalance()
const value = useAtomValue(bankRecClosingBalanceAtom(bankAccount?.name ?? ''))
const difference = flt(value.value - (data?.message ?? 0))
const isError = difference !== 0
return <StatContainer className="w-fit text-end sm:min-w-56">
<StatLabel className="text-end">{_("Difference")}</StatLabel>
{isLoading ? <Skeleton className="w-[150px] h-5 self-end rounded-sm" /> : <StatValue className={isError ? 'text-ink-red-3 font-numeric' : 'font-numeric'}>
{formatCurrency(difference,
bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))
}</StatValue>}
</StatContainer>
}
const ReconcileProgress = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
const dates = useAtomValue(bankRecDateAtom)
const { data: totalCount } = useFrappeGetDocCount<BankTransaction>('Bank Transaction', [
["bank_account", "=", bankAccount?.name ?? ''],
['docstatus', '=', 1],
['date', '<=', dates?.toDate],
['date', '>=', dates?.fromDate]
], false, undefined, {
revalidateOnFocus: false
})
const { data: unreconciledTransactions, } = useGetUnreconciledTransactions()
const reconciledCount = (totalCount ?? 0) - (unreconciledTransactions?.message?.length ?? 0)
const progress = (totalCount ? reconciledCount / totalCount : 0) * 100
return <div className="w-[18%] flex flex-col gap-1 items-end">
<div className="w-full">
<Progress
value={progress}
max={100}
size="md"
label="Progress"
hint
hintText={`${reconciledCount} / ${totalCount} ${_("reconciled")}`} />
</div>
</div>
}
const ClosingBalanceAsPerStatement = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
const dates = useAtomValue(bankRecDateAtom)
const setValue = useSetAtom(bankRecClosingBalanceAtom(bankAccount?.name ?? ''))
const { data, isLoading } = useGetAccountClosingBalanceAsPerStatement({
onSuccess: (data) => {
if (data?.message && data?.message?.balance) {
setValue({
value: data?.message?.balance,
stringValue: data?.message?.balance.toString()
})
}
}
})
const isDateSame = data?.message?.date === dates.toDate
const [isOpen, setIsOpen] = useState(false)
return <StatContainer className="min-w-48">
<StatLabel>{_("Closing Balance as per statement")}</StatLabel>
<div className="flex flex-col gap-2 items-start">
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-4 underline cursor-pointer underline-offset-6" role="button">
{isLoading ? <Skeleton className="w-[150px] h-5 rounded-sm" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message?.balance, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>}
<Edit className="w-4 h-4" />
</div>
</TooltipTrigger>
<TooltipContent>
{_("Click to set the closing balance as per statement")}
</TooltipContent>
</Tooltip>
</DialogTrigger>
<DialogContent className="min-w-xl">
<ClosingBalanceForm
defaultBalance={data?.message?.balance ?? 0}
date={dates.toDate}
bankAccount={bankAccount}
onClose={() => setIsOpen(false)}
/>
</DialogContent>
</Dialog>
{!isDateSame && data?.message.date && <span className="text-xs font-medium text-ink-red-3">{_("As of {0}", [formatDate(data?.message?.date ?? '', 'Do MMM YYYY')])}</span>}
</div>
</StatContainer>
}
const ClosingBalanceForm = ({ defaultBalance, date, bankAccount, onClose }: { defaultBalance: number, date: string, bankAccount: SelectedBank | null, onClose: VoidFunction }) => {
const { mutate } = useSWRConfig()
const form = useForm<{ balance: number }>({
defaultValues: {
balance: defaultBalance
}
})
const setValue = useSetAtom(bankRecClosingBalanceAtom(bankAccount?.name ?? ''))
const { call, loading, error } = useFrappePostCall("erpnext.accounts.doctype.bank_account.bank_account.set_closing_balance_as_per_statement")
const onSubmit = (data: { balance: number }) => {
if (data.balance) {
call({
bank_account: bankAccount?.name ?? '',
date: date,
balance: data.balance
})
.then(() => {
// Mutate the closing balance as per statement
mutate(`bank-reconciliation-account-closing-balance-as-per-statement-${bankAccount?.name}-${date}`)
setValue({
value: data.balance,
stringValue: data.balance.toString()
})
toast.success(_("Closing balance set."))
onClose()
})
} else {
toast.error(_("Closing balance is required."))
}
}
const currency = bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '')
return <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>{_("Set closing balance as per bank statement")}</DialogTitle>
<DialogDescription>
{_("Enter the closing balance you see in your bank statement for {0} as of the {1}", [bankAccount?.account_name ?? bankAccount?.name ?? '', formatDate(date, 'Do MMM YYYY')])}
</DialogDescription>
</DialogHeader>
{error && <ErrorBanner error={error} />}
<div className="py-4">
<CurrencyFormField
name="balance"
label={_("Closing balance on bank statement as of {0}", [formatDate(date, 'Do MMM YYYY')])}
isRequired
currency={currency}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant={'outline'} size='md' disabled={loading}>{_("Cancel")}</Button>
</DialogClose>
<Button type='submit' size='md' disabled={loading}>{_("Save")}</Button>
</DialogFooter>
<ClosingBalancesList bankAccount={bankAccount} date={date} />
</form>
</Form>
}
const ClosingBalancesList = ({ bankAccount, date }: { bankAccount: SelectedBank | null, date: string }) => {
const { data, mutate } = useFrappeGetDocList<BankAccountBalance>("Bank Account Balance", {
filters: [["bank_account", "=", bankAccount?.name ?? ''], ["date", "<=", date]],
orderBy: {
field: "date",
order: "desc"
},
fields: ["date", "balance", "name"],
limit: 10
})
const { db } = useContext(FrappeContext) as FrappeConfig
const onDelete = (name: string) => {
toast.promise(db.deleteDoc("Bank Account Balance", name).then(() => {
mutate()
}), {
loading: _("Deleting closing balance..."),
success: _("Closing balance deleted."),
error: _("Failed to delete closing balance.")
})
}
if (data?.length === 0) {
return null
}
return <div>
<Separator className="my-8" />
<p className="text-sm text-center">{_("Balances as per bank statement before {0}", [formatDate(date, 'Do MMM YYYY')])}</p>
<Table>
<TableHeader>
<TableRow>
<TableHead>{_("Date")}</TableHead>
<TableHead className="text-end">{_("Balance")}</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.map((item) => (
<TableRow key={item.name}>
<TableCell>{formatDate(item.date, 'Do MMM YYYY')}</TableCell>
<TableCell className="text-end">{formatCurrency(flt(item.balance, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</TableCell>
<TableCell className="text-end">
<Button
title={_("Delete")}
type='button' isIconButton variant='ghost' onClick={() => onDelete(item.name)}>
<Trash2 />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
}
export default BankBalance

View File

@@ -1,355 +0,0 @@
import { useAtomValue } from "jotai"
import { MissingFiltersBanner } from "./MissingFiltersBanner"
import { bankRecDateAtom, SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms"
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
import { Paragraph } from "@/components/ui/typography"
import type { ColumnDef } from "@tanstack/react-table"
import { useCallback, useMemo, useState } from "react"
import { useFrappeGetCall, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
import { QueryReportReturnType } from "@/types/custom/Reports"
import { formatDate } from "@/lib/date"
import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view"
import { Table, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { formatCurrency } from "@/lib/numbers"
import { getCompanyCurrency } from "@/lib/company"
import { slug } from "@/lib/frappe"
import { CheckCircle2, ReceiptTextIcon, XCircle } from "lucide-react"
import ErrorBanner from "@/components/ui/error-banner"
import { Badge } from "@/components/ui/badge"
import _ from "@/lib/translate"
import { useCopyToClipboard } from "usehooks-ts"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Form } from "@/components/ui/form"
import { useForm } from "react-hook-form"
import { DateField } from "@/components/ui/form-elements"
import { Empty, EmptyMedia, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty"
const BankClearanceSummary = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
const dates = useAtomValue(bankRecDateAtom)
if (!bankAccount) {
return <MissingFiltersBanner text={_("Please select a bank account to view the bank clearance summary.")} />
}
if (!dates) {
return <MissingFiltersBanner text={_("Please select dates to view the bank clearance summary.")} />
}
return <BankClearanceSummaryView />
}
interface BankClearanceSummaryEntry {
payment_document_type: string
payment_entry: string
posting_date: string,
cheque_no?: string,
amount: number,
against: string,
clearance_date: string,
}
const BankClearanceSummaryView = () => {
const companyID = useCurrentCompany()
const bankAccount = useAtomValue(selectedBankAccountAtom)
const dates = useAtomValue(bankRecDateAtom)
const filters = useMemo(() => {
return JSON.stringify({
account: bankAccount?.account,
from_date: dates.fromDate,
to_date: dates.toDate
})
}, [bankAccount, dates])
const { data, error, mutate } = useFrappeGetCall<{ message: QueryReportReturnType<BankClearanceSummaryEntry> }>('frappe.desk.query_report.run', {
report_name: 'Bank Clearance Summary',
filters,
ignore_prepared_report: 1,
are_default_filters: false,
}, `Report-Bank Clearance Summary-${filters}`, { keepPreviousData: true, revalidateOnFocus: false }, 'POST')
const formattedFromDate = formatDate(dates.fromDate)
const formattedToDate = formatDate(dates.toDate)
const [, copyToClipboard] = useCopyToClipboard()
const onCopy = useCallback(
(text: string) => {
copyToClipboard(text).then(() => {
toast.success(_("Copied to clipboard"))
})
},
[copyToClipboard, _],
)
const accountCurrency = useMemo(
() => bankAccount?.account_currency ?? getCompanyCurrency(companyID),
[bankAccount?.account_currency, companyID],
)
const clearanceColumns = useMemo<ColumnDef<BankClearanceSummaryEntry, unknown>[]>(
() => [
{
accessorKey: "payment_document_type",
header: _("Document Type"),
size: 140,
cell: ({ row }) => _(row.original.payment_document_type),
},
{
id: "payment_entry",
header: _("Payment Document"),
size: 160,
meta: {
getTooltipText: (r) => {
const x = r as BankClearanceSummaryEntry
return [x.payment_document_type, x.payment_entry].filter(Boolean).join(" · ") || undefined
},
} satisfies ListViewColumnMeta,
cell: ({ row }) => (
<a
target="_blank"
rel="noreferrer"
className="text-ink-gray-8 block min-w-0 w-full underline underline-offset-4"
href={`/desk/${slug(row.original.payment_document_type)}/${row.original.payment_entry}`}
>
{row.original.payment_entry}
</a>
),
},
{
accessorKey: "posting_date",
header: _("Posting Date"),
size: 118,
meta: { tabularNums: true } satisfies ListViewColumnMeta,
cell: ({ row }) => formatDate(row.original.posting_date),
},
{
accessorKey: "cheque_no",
header: _("Cheque/Reference Number"),
size: 160,
cell: ({ row }) => {
const ref = row.original.cheque_no ?? ""
return (
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
className="text-ink-gray-8 hover:underline min-w-0 w-full cursor-pointer truncate text-start underline-offset-4"
onClick={() => onCopy(ref)}
>
{ref}
</button>
</TooltipTrigger>
<TooltipContent>
{ref}
</TooltipContent>
</Tooltip>
)
},
},
{
accessorKey: "clearance_date",
header: _("Clearance Date"),
size: 118,
meta: { tabularNums: true } satisfies ListViewColumnMeta,
cell: ({ row }) => formatDate(row.original.clearance_date),
},
{
accessorKey: "against",
header: _("Against Account"),
size: 250,
},
{
accessorKey: "amount",
header: _("Amount"),
size: 150,
meta: { align: "right" } satisfies ListViewColumnMeta,
cell: ({ row }) => <span className="font-numeric">{formatCurrency(row.original.amount, accountCurrency)}</span>,
},
{
id: "status",
header: _("Status"),
size: 200,
meta: { truncate: false, truncateTooltip: false } satisfies ListViewColumnMeta,
cell: ({ row }) => {
const r = row.original
return r.clearance_date ? (
<Badge theme="green">
<CheckCircle2 />
{_("Cleared")}
</Badge>
) : (
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Badge theme="red">
<XCircle />
{_("Not Cleared")}
</Badge>
<SetClearanceDateButton
voucher={r}
bankAccount={bankAccount}
companyID={companyID}
mutate={mutate}
/>
</div>
)
},
},
],
[_, accountCurrency, bankAccount, companyID, mutate, onCopy],
)
return <div className="space-y-4 py-2">
<div>
<Paragraph className="text-sm">
<span dangerouslySetInnerHTML={{
__html: _("Below is a list of all accounting entries posted against the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
}} />
</Paragraph>
</div>
{error && <ErrorBanner error={error} />}
{data && data.message.result.length > 0 ? (
<ListView
data={data.message.result}
columns={clearanceColumns}
getRowId={(row) => `${row.payment_entry}-${row.posting_date}`}
maxHeight="calc(100vh - 200px)"
scrollAreaClassName="min-h-[calc(100vh-200px)]"
emptyState={_("No rows to display.")}
/>
) : null}
{data && data.message.result.length == 0 &&
<Empty>
<EmptyMedia>
<ReceiptTextIcon />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>{_("No entries found")}</EmptyTitle>
<EmptyDescription>{_("There are no accounting entries in the system for the selected account and dates.")}</EmptyDescription>
</EmptyHeader>
</Empty>
}
</div>
}
const SetClearanceDateButton = ({ voucher, bankAccount, companyID, mutate }: { voucher: BankClearanceSummaryEntry, bankAccount: SelectedBank | null, companyID: string, mutate: VoidFunction }) => {
const [open, setOpen] = useState(false)
const onClose = () => {
setOpen(false)
mutate()
}
return <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger disabled={!bankAccount}>
<Tooltip delayDuration={500}>
<TooltipTrigger>
<Button variant='link' size="sm" className="px-0" theme="red">{_("Force Clear")}</Button>
</TooltipTrigger>
<TooltipContent align='start'>
{_("Set the clearance date for this voucher without reconciling with a bank transaction.")}
</TooltipContent>
</Tooltip>
</DialogTrigger>
<DialogContent className="min-w-2xl">
{bankAccount && <ForceClearVoucherForm voucher={voucher} bankAccount={bankAccount} companyID={companyID} onClose={onClose} />}
</DialogContent>
</Dialog>
}
const ForceClearVoucherForm = ({ voucher, bankAccount, companyID, onClose }: { voucher: BankClearanceSummaryEntry, bankAccount: SelectedBank, companyID: string, onClose: () => void }) => {
const { mutate } = useSWRConfig()
const dates = useAtomValue(bankRecDateAtom)
const form = useForm<{ clearance_date: string }>({
defaultValues: {
clearance_date: voucher.posting_date,
}
})
const { call, loading, error } = useFrappePostCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.update_clearance_date')
const onSubmit = (data: { clearance_date: string }) => {
call({
payment_document: voucher.payment_document_type,
payment_entry: voucher.payment_entry,
account: bankAccount.account,
clearance_date: data.clearance_date,
})
.then(() => {
toast.success(_("Clearance date updated"))
onClose()
mutate(`bank-reconciliation-account-closing-balance-${bankAccount?.name}-${dates.toDate}`)
})
}
return <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className='flex flex-col gap-4'>
<DialogHeader>
<DialogTitle>{_("Force Clear Voucher")}</DialogTitle>
<DialogDescription>
{_("Set the clearance date for this voucher without reconciling with a bank transaction.")}
</DialogDescription>
</DialogHeader>
{error && <ErrorBanner error={error} />}
<div>
<Table>
<TableHeader>
<TableRow>
<TableHead>{_("Payment Document")}</TableHead>
<TableCell><a target="_blank" className="underline underline-offset-4"
href={`/desk/${slug(voucher.payment_document_type)}/${voucher.payment_entry}`}>{_(voucher.payment_document_type)} : {voucher.payment_entry}</a></TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Posting Date")}</TableHead>
<TableCell>{formatDate(voucher.posting_date)}</TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Cheque/Reference Number")}</TableHead>
<TableCell title={voucher.cheque_no}>{voucher.cheque_no?.slice(0, 40)}{voucher.cheque_no?.length && voucher.cheque_no?.length > 40 ? "..." : ""}</TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Amount")}</TableHead>
<TableCell className="text-end">{formatCurrency(voucher.amount, bankAccount?.account_currency ?? getCompanyCurrency(companyID))}</TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Against Account")}</TableHead>
<TableCell><a target="_blank" className="underline underline-offset-4" href={`/desk/account/${voucher.against}`}>{voucher.against}</a></TableCell>
</TableRow>
</TableHeader>
</Table>
</div>
<DateField
name='clearance_date'
label={_("Clearance Date")}
isRequired
inputProps={{ autoFocus: true }}
/>
<DialogFooter>
<DialogClose asChild>
<Button variant={'outline'} disabled={loading} size='md'>{_("Cancel")}</Button>
</DialogClose>
<Button type='submit' disabled={loading} size='md'>{_("Submit")}</Button>
</DialogFooter>
</div>
</form>
</Form>
}
export default BankClearanceSummary

View File

@@ -1,831 +0,0 @@
import { useAtom, useAtomValue, useSetAtom } from "jotai"
import { bankRecRecordJournalEntryModalAtom, bankRecSelectedTransactionAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader, DialogFooter, DialogClose } from "@/components/ui/dialog"
import _ from "@/lib/translate"
import { UnreconciledTransaction, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from "./utils"
import { useFieldArray, useForm, useFormContext, useWatch } from "react-hook-form"
import { JournalEntry } from "@/types/Accounts/JournalEntry"
import { getCompanyCostCenter, getCompanyCurrency } from "@/lib/company"
import { FrappeConfig, FrappeContext, useFrappePostCall } from "frappe-react-sdk"
import { toast } from "sonner"
import ErrorBanner from "@/components/ui/error-banner"
import { Button } from "@/components/ui/button"
import SelectedTransactionDetails from "./SelectedTransactionDetails"
import { AccountFormField, CurrencyFormField, DataField, DateField, LinkFormField, PartyTypeFormField, SmallTextField } from "@/components/ui/form-elements"
import { Form } from "@/components/ui/form"
import { useCallback, useContext, useMemo, useRef, useState } from "react"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Checkbox } from "@/components/ui/checkbox"
import { ArrowDownRight, ArrowUpRight, Plus, Trash2 } from "lucide-react"
import { flt, formatCurrency } from "@/lib/numbers"
import { cn } from "@/lib/utils"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import SelectedTransactionsTable from "./SelectedTransactionsTable"
import { JournalEntryAccount } from "@/types/Accounts/JournalEntryAccount"
import { BankTransaction } from "@/types/Accounts/BankTransaction"
import FileUploadBanner from "@/components/common/FileUploadBanner"
import { Label } from "@/components/ui/label"
import { FileDropzone } from "@/components/ui/file-dropzone"
import { useGetAccounts } from "@/components/common/AccountsDropdown"
import { useHotkeys } from "react-hotkeys-hook"
const BankEntryModal = () => {
const [isOpen, setIsOpen] = useAtom(bankRecRecordJournalEntryModalAtom)
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className='min-w-[95vw]'>
<DialogHeader>
<DialogTitle>{_("Bank Entry")}</DialogTitle>
<DialogDescription>
{_("Record a journal entry for expenses, income or split transactions.")}
</DialogDescription>
</DialogHeader>
<RecordBankEntryModalContent />
</DialogContent>
</Dialog>
)
}
const RecordBankEntryModalContent = () => {
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? ''))
if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) {
return <div className='p-4'>
<span className='text-center'>{_("No transaction selected")}</span>
</div>
}
if (selectedTransaction.length === 1) {
return <BankEntryForm
selectedTransaction={selectedTransaction[0]} />
}
return <BulkBankEntryForm
selectedTransactions={selectedTransaction}
/>
}
const BulkBankEntryForm = ({ selectedTransactions }: { selectedTransactions: UnreconciledTransaction[] }) => {
const form = useForm<{
account: string
}>({
defaultValues: {
account: ''
}
})
const { call, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_bank_entry_and_reconcile')
const onReconcile = useRefreshUnreconciledTransactions()
const addToActionLog = useUpdateActionLog()
const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
const onSubmit = (data: { account: string }) => {
call({
bank_transactions: selectedTransactions.map(transaction => transaction.name),
account: data.account
}).then(({ message }) => {
addToActionLog({
type: 'bank_entry',
timestamp: (new Date()).getTime(),
isBulk: true,
items: message.map((item) => ({
bankTransaction: item.transaction,
voucher: {
reference_doctype: "Journal Entry",
reference_name: item.journal_entry.name,
doc: item.journal_entry,
posting_date: item.journal_entry.posting_date,
}
})),
bulkCommonData: {
account: data.account,
}
})
toast.success(_("Bank Entries Created"), {
duration: 4000,
})
// Set this to the last selected transaction
onReconcile(selectedTransactions[selectedTransactions.length - 1])
setIsOpen(false)
})
}
return <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-4">
{error && <ErrorBanner error={error} />}
<SelectedTransactionsTable />
<div className="grid grid-cols-3 gap-4">
<AccountFormField
name='account'
filterFunction={(acc) => {
// Do not allow payable and receivable accounts
return acc.account_type !== 'Payable' && acc.account_type !== 'Receivable'
}}
label={_('Account')}
isRequired
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
</DialogClose>
<Button size='md' type='submit' disabled={loading}>{_("Submit")}</Button>
</DialogFooter>
</div>
</form>
</Form>
}
interface BankEntryFormData extends Pick<JournalEntry, 'voucher_type' | 'cheque_date' | 'posting_date' | 'cheque_no' | 'user_remark'> {
entries: JournalEntry['accounts']
}
const BankEntryForm = ({ selectedTransaction }: { selectedTransaction: UnreconciledTransaction }) => {
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
const { data: rule } = useGetRuleForTransaction(selectedTransaction)
const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
const onClose = () => {
setIsOpen(false)
}
const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false
const defaultAccounts = useMemo(() => {
const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false
const accounts: Partial<JournalEntryAccount>[] = [
{
account: selectedBankAccount?.account ?? '',
bank_account: selectedTransaction.bank_account,
// Bank is debited if it's a deposit
debit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount,
credit: isWithdrawal ? selectedTransaction.unallocated_amount : 0,
party_type: '',
party: '',
cost_center: ''
}]
// If there is no rule, we can just add the entries for the bank account transaction and the other side will be the reverse
if (!rule) {
accounts.push(
{
account: '',
// Amounts will be the reverse of the bank account transaction
debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0,
credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount,
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
}
)
} else {
// Rule exists, so we need to check the type of rule
if (!rule.bank_entry_type || rule.bank_entry_type === "Single Account") {
// Only a single account needs to be added
accounts.push({
account: rule.account ?? '',
// Amounts will be the reverse of the bank account transaction
debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0,
credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount,
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
})
} else {
// For multiple accounts, we need to loop over and add entries for each
// The last row will just be the remaining amount
let hasTotallyEmptyRowEarlier = false;
let totalDebits = isWithdrawal ? 0 : selectedTransaction.unallocated_amount ?? 0
let totalCredits = isWithdrawal ? selectedTransaction.unallocated_amount ?? 0 : 0
for (let i = 0; i < (rule.accounts?.length ?? 0); i++) {
const acc = rule.accounts?.[i]
// If it's the last row, add the difference amount
if (i === (rule.accounts?.length ?? 0) - 1 && !hasTotallyEmptyRowEarlier) {
const differenceAmount = flt(totalDebits - totalCredits, 2)
accounts.push({
account: acc?.account ?? '',
debit: differenceAmount > 0 ? 0 : Math.abs(differenceAmount),
credit: differenceAmount > 0 ? Math.abs(differenceAmount) : 0,
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
user_remark: acc?.user_remark ?? '',
})
} else {
/**
* The debit and credit amounts can also be expressions - like "transaction_amount * 0.5"
* So we need to compute the value of the expression
* We can use the eval function to do this. But we need to expose certain variables to the expression.
* One of them is transaction_amount which is the unallocated amount of the selected transaction
* @param expression - The expression to compute
* @returns The computed value
*/
const computeExpression = (expression: string) => {
const script = `
const transaction_amount = ${selectedTransaction.unallocated_amount ?? 0}
${expression};
`
let value = 0;
try {
value = window.eval(script);
} catch (error: unknown) {
console.error(error);
value = 0;
}
return value;
}
if (!acc?.debit && !acc?.credit) {
hasTotallyEmptyRowEarlier = true;
}
const computedDebit = acc?.debit ? flt(computeExpression(acc.debit), 2) : 0
const computedCredit = acc?.credit ? flt(computeExpression(acc.credit), 2) : 0
totalDebits = flt(totalDebits + computedDebit, 2)
totalCredits = flt(totalCredits + computedCredit, 2)
accounts.push({
account: acc?.account ?? '',
debit: computedDebit,
credit: computedCredit,
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
user_remark: acc?.user_remark ?? '',
})
}
}
}
}
return accounts
}, [rule, selectedTransaction, selectedBankAccount])
const form = useForm<BankEntryFormData>({
defaultValues: {
voucher_type: selectedBankAccount?.is_credit_card ? 'Credit Card Entry' : 'Bank Entry',
cheque_date: selectedTransaction.date,
posting_date: selectedTransaction.date,
cheque_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140),
user_remark: selectedTransaction.description,
entries: defaultAccounts,
}
})
const onReconcile = useRefreshUnreconciledTransactions()
const { call: createBankEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bank_entry_and_reconcile')
const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom)
const addToActionLog = useUpdateActionLog()
const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig
const [isUploading, setIsUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [files, setFiles] = useState<File[]>([])
const onSubmit = (data: BankEntryFormData) => {
createBankEntry({
bank_transaction_name: selectedTransaction.name,
...data
}).then(async ({ message }) => {
addToActionLog({
type: 'bank_entry',
isBulk: false,
timestamp: (new Date()).getTime(),
items: [
{
bankTransaction: message.transaction,
voucher: {
reference_doctype: "Journal Entry",
reference_name: message.journal_entry.name,
reference_no: message.journal_entry.cheque_no,
reference_date: message.journal_entry.cheque_date,
posting_date: message.journal_entry.posting_date,
doc: message.journal_entry,
}
}
]
})
toast.success(_("Bank Entry Created"), {
duration: 4000,
closeButton: true,
action: {
label: _("Undo"),
onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name)
},
actionButtonStyle: {
backgroundColor: "rgb(0, 138, 46)"
}
})
if (files.length > 0) {
setIsUploading(true)
const uploadPromises = files.map(f => {
return frappeFile.uploadFile(f, {
isPrivate: true,
doctype: "Journal Entry",
docname: message.journal_entry.name,
}, (_bytesUploaded, _totalBytes, progress) => {
setUploadProgress((currentProgress) => {
//If there are multiple files, we need to add the progress to the current progress
return currentProgress + ((progress?.progress ?? 0) / files.length)
})
})
})
return Promise.all(uploadPromises).then(() => {
setUploadProgress(0)
setIsUploading(false)
}).catch((error) => {
console.error(error)
toast.error(_("Error uploading attachments"), {
duration: 4000,
})
setIsUploading(false)
})
} else {
return Promise.resolve()
}
}).then(() => {
onReconcile(selectedTransaction)
onClose()
})
}
useHotkeys('meta+s', () => {
form.handleSubmit(onSubmit)()
}, {
enabled: true,
preventDefault: true,
enableOnFormTags: true
})
if (isUploading && isCompleted) {
return <FileUploadBanner uploadProgress={uploadProgress} />
}
return <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className='flex flex-col gap-4'>
{error && <ErrorBanner error={error} />}
<div className='grid grid-cols-2 gap-4'>
<SelectedTransactionDetails transaction={selectedTransaction} />
<div className='flex flex-col gap-4'>
<div className='grid grid-cols-2 gap-4'>
<DateField
name='posting_date'
label={_("Posting Date")}
isRequired
inputProps={{ autoFocus: false }}
/>
<DateField
name='cheque_date'
label={_("Reference Date")}
isRequired
inputProps={{ autoFocus: false }}
rules={{
required: _("Reference Date is required"),
}}
/>
</div>
<DataField name='cheque_no' label={_("Reference")} isRequired inputProps={{ autoFocus: false }}
rules={{
required: _("Reference is required"),
}} />
</div>
</div>
<div>
<Entries company={selectedTransaction.company ?? ''} isWithdrawal={isWithdrawal} currency={selectedTransaction.currency ?? getCompanyCurrency(selectedTransaction.company ?? '')} />
</div>
<div className='flex flex-col gap-2'>
<div className='grid grid-cols-2 gap-4'>
<SmallTextField
name='user_remark'
label={_("Remarks")}
/>
<div
data-slot="form-item"
className="flex flex-col gap-2"
>
<Label>{_("Attachments")}</Label>
<FileDropzone files={files} setFiles={setFiles} />
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
</DialogClose>
<Button size='md' type='submit' disabled={loading}>{_("Submit")}</Button>
</DialogFooter>
</div>
</form>
</Form>
}
const Entries = ({ company, isWithdrawal, currency }: { company: string, isWithdrawal: boolean, currency: string }) => {
const { getValues, setValue, control } = useFormContext<BankEntryFormData>()
const { call } = useContext(FrappeContext) as FrappeConfig
const partyMapRef = useRef<Record<string, string>>({})
const onPartyChange = (value: string, index: number) => {
// Get the account for the party type
if (value) {
if (partyMapRef.current[value]) {
setValue(`entries.${index}.account`, partyMapRef.current[value])
} else {
call.get('erpnext.accounts.party.get_party_account', {
party: value,
party_type: getValues(`entries.${index}.party_type`),
company: company
}).then((result: { message: string }) => {
setValue(`entries.${index}.account`, result.message)
partyMapRef.current[value] = result.message
})
}
} else {
setValue(`entries.${index}.account`, '')
}
}
const { data: accounts } = useGetAccounts()
const onAccountChange = (value: string, index: number) => {
// If it's an income or expense account, get the default cost center
if (value) {
const account = accounts?.find((acc) => acc.name === value)
if (account && account.report_type === "Profit and Loss") {
// Set the default company cost center
setValue(`entries.${index}.cost_center`, getCompanyCostCenter(company) ?? '')
return
}
}
setValue(`entries.${index}.cost_center`, '')
}
const { fields, append, remove } = useFieldArray({
control: control,
name: 'entries'
})
const onAdd = useCallback(() => {
const existingEntries = getValues('entries')
const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0)
const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0)
const remainingAmount = flt(totalDebits - totalCredits, 2)
// Remaining amount is credit if it's positive - since some debit is pending to be cleared.
const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount)
const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0
append({
party_type: '',
party: '',
account: '',
debit: debitAmount,
credit: creditAmount,
cost_center: getCompanyCostCenter(company) ?? ''
} as JournalEntryAccount, {
focusName: `entries.${existingEntries.length}.account`
})
}, [company, append, getValues])
const [selectedRows, setSelectedRows] = useState<number[]>([])
const onSelectRow = useCallback((index: number) => {
setSelectedRows(prev => {
if (prev.includes(index)) {
return prev.filter(i => i !== index)
}
return [...prev, index]
})
}, [])
const onSelectAll = useCallback(() => {
setSelectedRows(prev => {
if (prev.length === fields.length) {
return []
}
return [...fields.map((_, index) => index)]
})
}, [fields])
const onRemove = useCallback(() => {
remove(selectedRows)
setSelectedRows([])
}, [remove, selectedRows])
/**
* When add difference is clicked, check if the last row has nothing filled in.
* If last row is empty (no debit or credit), then set that row's amount. Else, add a new row with the difference amount.
*/
const onAddDifferenceClicked = () => {
const existingEntries = getValues('entries')
const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0)
const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0)
const lastIndex = existingEntries.length - 1
const isLastRowEmpty = (existingEntries[lastIndex]?.debit === 0 || existingEntries[lastIndex]?.debit === undefined) && (existingEntries[lastIndex]?.credit === 0 || existingEntries[lastIndex]?.credit === undefined)
const remainingAmount = flt(totalDebits - totalCredits, 2)
// Remaining amount is credit if it's positive - since some debit is pending to be cleared.
const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount)
const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0
if (isLastRowEmpty) {
setValue(`entries.${lastIndex}.debit`, debitAmount)
setValue(`entries.${lastIndex}.credit`, creditAmount)
} else {
append({
party_type: '',
party: '',
account: '',
debit: debitAmount,
credit: creditAmount,
cost_center: getCompanyCostCenter(company) ?? ''
} as JournalEntryAccount, {
focusName: `entries.${existingEntries.length}.account`
})
}
}
return <div className="flex flex-col gap-2">
<Table>
<TableHeader>
<TableRow>
<TableHead><Checkbox
disabled={fields.length === 0}
// Make this accessible to screen readers
aria-label={_("Select all")}
checked={selectedRows.length > 0 && selectedRows.length === fields.length}
onCheckedChange={onSelectAll} /></TableHead>
<TableHead>{_("Party")}</TableHead>
<TableHead>{_("Account")}</TableHead>
<TableHead>{_("Cost Center")}</TableHead>
<TableHead>{_("Remarks")}</TableHead>
<TableHead className="text-end">{_("Debit")}</TableHead>
<TableHead className="text-end">{_("Credit")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fields.map((field, index) => (
<TableRow key={field.id} className={index === 0 ? 'bg-surface-gray-1 cursor-not-allowed' : ''} title={index === 0 ? _("This is the bank account entry. You cannot edit it.") : ''}>
<TableCell>
<Checkbox
checked={selectedRows.includes(index)}
onCheckedChange={() => onSelectRow(index)}
// Make this accessible to screen readers
aria-label={_("Select row {0}", [String(index + 1)])}
disabled={index === 0}
/>
</TableCell>
<TableCell className="align-top">
<div className="flex">
<PartyTypeFormField
name={`entries.${index}.party_type`}
label={_("Party Type")}
isRequired
readOnly={index === 0}
hideLabel
inputProps={{
type: isWithdrawal ? 'Payable' : 'Receivable',
triggerProps: {
className: 'rounded-e-none',
tabIndex: -1
},
readOnly: index === 0,
}} />
<PartyField index={index} onChange={onPartyChange} readOnly={index === 0} />
</div>
</TableCell>
<TableCell className="align-top">
<AccountFormField
name={`entries.${index}.account`}
label={_("Account")}
rules={{
required: _("Account is required"),
onChange: (event) => {
onAccountChange(event.target.value, index)
}
}}
buttonClassName="min-w-64"
readOnly={index === 0}
isRequired
hideLabel
/>
</TableCell>
<TableCell className="align-top">
<LinkFormField
doctype="Cost Center"
name={`entries.${index}.cost_center`}
label={_("Cost Center")}
filters={[["company", "=", company], ["is_group", "=", 0], ["disabled", "=", 0]]}
buttonClassName="min-w-48"
readOnly={index === 0}
hideLabel
/>
</TableCell>
<TableCell className="align-top">
<DataField
name={`entries.${index}.user_remark`}
label={_("Remarks")}
readOnly={index === 0}
inputProps={{
placeholder: _("e.g. Bank Charges"),
className: 'min-w-64',
readOnly: index === 0
}}
hideLabel
/>
</TableCell>
<TableCell className={cn("text-end align-top")}>
<CurrencyFormField
name={`entries.${index}.debit`}
label={_("Debit")}
isRequired
hideLabel
readOnly={index === 0}
style={index === 0 ? !isWithdrawal ? {
color: "var(--color-ink-gray-8)",
} : {} : {}}
currency={currency}
leftSlot={index === 0 && !isWithdrawal ? <Tooltip>
<TooltipTrigger asChild><ArrowDownRight className="text-ink-green-3" /></TooltipTrigger>
<TooltipContent>{_("Bank account debit for deposit")}</TooltipContent>
</Tooltip> : undefined}
/>
</TableCell>
<TableCell className={cn("text-end align-top")}>
<CurrencyFormField
name={`entries.${index}.credit`}
style={index === 0 && isWithdrawal ? {
color: "var(--color-ink-gray-8)",
} : {}}
label={_("Credit")}
isRequired
hideLabel
readOnly={index === 0}
currency={currency}
leftSlot={index === 0 && isWithdrawal ? <Tooltip>
<TooltipTrigger asChild><ArrowUpRight className="text-ink-red-3" /></TooltipTrigger>
<TooltipContent>{_("Bank account credit for withdrawal")}</TooltipContent>
</Tooltip> : undefined}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex justify-between gap-2">
<div className="flex gap-2 justify-end">
<div>
<Button size='sm' type='button' variant={'outline'} onClick={onAdd}><Plus /> {_("Add Row")}</Button>
</div>
{selectedRows.length > 0 && <div>
<Button size='sm' type='button' theme="red" onClick={onRemove}><Trash2 /> {_("Remove")}</Button>
</div>}
</div>
<Summary currency={currency} addRow={onAddDifferenceClicked} />
</div>
</div>
}
const PartyField = ({ index, onChange, readOnly }: { index: number, onChange: (value: string, index: number) => void, readOnly: boolean }) => {
const { control } = useFormContext<BankEntryFormData>()
const party_type = useWatch({
control,
name: `entries.${index}.party_type`
})
if (!party_type) {
return <DataField
name={`entries.${index}.party`}
label={_("Party")}
isRequired
inputProps={{
disabled: true,
className: 'rounded-s-none border-s-0 min-w-64'
}}
hideLabel
/>
}
return <LinkFormField
name={`entries.${index}.party`}
label={_("Party")}
rules={{
onChange: (event) => {
onChange(event.target.value, index)
},
}}
hideLabel
readOnly={readOnly}
buttonClassName="rounded-s-none border-s-0 min-w-64"
doctype={party_type}
/>
}
const Summary = ({ currency, addRow }: { currency: string, addRow: () => void }) => {
const { control } = useFormContext<BankEntryFormData>()
const entries = useWatch({ control, name: 'entries' })
const { total, totalCredits, totalDebits } = useMemo(() => {
// Do a total debits - total credits
const totalDebits = entries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0)
const totalCredits = entries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0)
return { total: flt(totalDebits - totalCredits, 2), totalDebits, totalCredits }
}, [entries])
const onAddRow = useCallback(() => {
addRow()
}, [addRow])
const TextComponent = ({ className, children }: { className?: string, children: React.ReactNode }) => {
return <span className={cn("w-32 text-end font-medium text-sm font-numeric", className)}>{children}</span>
}
return <div className="flex flex-col gap-2 items-end">
<div className="flex gap-2 justify-between">
<TextComponent>{_("Total Debit")}</TextComponent>
<TextComponent>{formatCurrency(totalDebits, currency)}</TextComponent>
</div>
<div className="flex gap-2 justify-between">
<TextComponent>{_("Total Credit")}</TextComponent>
<TextComponent>{formatCurrency(totalCredits, currency)}</TextComponent>
</div>
{total !== 0 && <div className="flex gap-2 justify-between">
<TextComponent>{_("Difference")}</TextComponent>
<Tooltip>
<TooltipTrigger asChild>
<Button type='button' variant='link' className="p-0 text-ink-red-3 underline h-fit" role='button' onClick={onAddRow}>
<TextComponent className='text-ink-red-3'>{formatCurrency(total, currency)}</TextComponent>
</Button>
</TooltipTrigger>
<TooltipContent>
{_("Add a row with the difference amount")}
</TooltipContent>
</Tooltip>
</div>}
</div>
}
export default BankEntryModal

View File

@@ -1,134 +0,0 @@
import { useAtom } from "jotai"
import { SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms"
import { useCallback } from "react"
import { useGetBankAccounts, useGetUnreconciledTransactions } from "./utils"
import { cn } from "@/lib/utils"
import { getTimeago } from "@/lib/date"
import ErrorBanner from "@/components/ui/error-banner"
import _ from "@/lib/translate"
import { Badge } from "@/components/ui/badge"
import { useTheme } from "@/components/ui/theme-provider"
import BankLogo from "@/components/common/BankLogo"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { LandmarkIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
const BankPicker = ({ className }: { className?: string }) => {
const [selectedBank, setSelectedBank] = useAtom(selectedBankAccountAtom)
const onLoadingSuccess = useCallback((data?: SelectedBank[]) => {
// If the bank is already selected, then don't set it again
if (selectedBank) {
// Check if selected bank is in the data
if (data?.some((bank: SelectedBank) => bank.name === selectedBank.name)) {
return
}
}
if (!data) return
if (data.length === 1) {
setSelectedBank(data[0])
} else if (data.length > 1) {
const defaultBank = data.find((bank: SelectedBank) => bank.is_default)
if (defaultBank) {
setSelectedBank(defaultBank)
} else {
// Select the first available bank account
setSelectedBank(data[0])
}
}
}, [setSelectedBank, selectedBank])
const selectedCompany = useCurrentCompany()
const { banks, isLoading, error } = useGetBankAccounts(onLoadingSuccess)
const { themeValue } = useTheme()
if (isLoading) {
return null
}
if (error) {
return <ErrorBanner error={error} />
}
if (banks?.length === 0) {
return <Empty>
<EmptyMedia>
<LandmarkIcon />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>{_("No bank accounts found")}</EmptyTitle>
<EmptyDescription>{_("You have not added any bank accounts to your company.")}</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild>
<a href={`/desk/bank-account?company=${encodeURIComponent(selectedCompany)}&is_company_account=1`}>
{_("Configure Bank Accounts")}
</a>
</Button>
</EmptyContent>
</Empty>
}
return (
<div
className={cn("flex gap-3 items-stretch w-full overflow-x-auto pe-4",
banks?.length > 4 ? 'pb-2' : '', className,
)}
style={{
scrollbarWidth: 'thin',
scrollbarColor: themeValue === 'Dark' ? 'var(--surface-gray-2) var(--surface-gray-1)' : 'rgb(209 213 219) rgb(243 244 246)',
}}
>
{
banks?.map((bank) => (
<BankPickerItem key={bank.name} bank={bank} />
))
}
</div>
)
}
const BankPickerItem = ({ bank }: { bank: SelectedBank }) => {
const [selectedBank, setSelectedBank] = useAtom(selectedBankAccountAtom)
const isSelected = selectedBank?.name === bank.name
const { mutate } = useGetUnreconciledTransactions()
const onSelect = () => {
setSelectedBank(bank)
mutate()
}
return <div
role="button"
title={`Select ${bank.account_name}`}
onClick={onSelect}
className={cn('rounded-md border border-outline-gray-1 max-w-60 min-w-60 p-2 overflow-hidden cursor-pointer',
isSelected ? 'border-outline-gray-5 bg-surface-gray-1' : 'hover:bg-surface-gray-1'
)}
>
<BankLogo bank={bank} className="mb-2" />
<div className="flex flex-col gap-1">
<div className="flex gap-2 items-center">
<span className={cn("text-sm font-medium line-clamp-1 text-ink-gray-8")}>{bank.account_name}</span>
{bank.account_type && <Badge variant='subtle' size='sm' theme='gray'>
{bank.account_type?.slice(0, 24)}
</Badge>}
</div>
<span title={_("GL Account")} className={cn("text-ellipsis line-clamp-1 text-sm text-ink-gray-6")}>{bank.account}</span>
{bank.last_integration_date && <span className="text-xs text-ink-gray-5">{_("Last Synced Transaction")}: {getTimeago(bank.last_integration_date)}</span>}
</div>
</div >
}
export default BankPicker

View File

@@ -1,275 +0,0 @@
import { useAtom } from 'jotai'
import { bankRecDateAtom } from './bankRecAtoms'
import { useMemo, useState } from 'react'
import { AVAILABLE_TIME_PERIODS, formatDate, getDatesForTimePeriod, TimePeriod } from '@/lib/date'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ChevronDownIcon, ChevronLeftIcon, ChevronRight } from 'lucide-react'
import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { parse } from "chrono-node"
import { Calendar } from '@/components/ui/calendar'
import useFiscalYear from '@/hooks/useFiscalYear'
import dayjs from 'dayjs'
import _ from '@/lib/translate'
import { useDirection } from '@/components/ui/direction'
const BankRecDateFilter = () => {
const [bankRecDate, setBankRecDate] = useAtom(bankRecDateAtom)
const { data: fiscalYear } = useFiscalYear()
const timePeriodOptions = useMemo(() => {
const standardOptions = AVAILABLE_TIME_PERIODS.map((period) => {
const dates = getDatesForTimePeriod(period)
return {
label: period,
fromDate: dates.fromDate,
toDate: dates.toDate,
format: dates.format,
translatedLabel: dates.translatedLabel
}
})
if (fiscalYear?.message) {
// For a fiscal year, we need to replace "Last Year", "This Year", and add options for quarters
const fiscalYearStart = fiscalYear.message.year_start_date
const fiscalYearEnd = fiscalYear.message.year_end_date
const q1 = {
label: `Q1: ${fiscalYear.message.name}`,
translatedLabel: `${_("Q1")}: ${fiscalYear.message.name}`,
fromDate: fiscalYearStart,
toDate: dayjs(fiscalYearStart).add(3, 'month').format('YYYY-MM-DD'),
format: 'MMM YYYY'
}
const q2 = {
label: `Q2: ${fiscalYear.message.name}`,
translatedLabel: `${_("Q2")}: ${fiscalYear.message.name}`,
fromDate: dayjs(fiscalYearStart).add(3, 'month').format('YYYY-MM-DD'),
toDate: dayjs(fiscalYearStart).add(6, 'month').format('YYYY-MM-DD'),
format: 'MMM YYYY'
}
const q3 = {
label: `Q3: ${fiscalYear.message.name}`,
translatedLabel: `${_("Q3")}: ${fiscalYear.message.name}`,
fromDate: dayjs(fiscalYearStart).add(6, 'month').format('YYYY-MM-DD'),
toDate: dayjs(fiscalYearStart).add(9, 'month').format('YYYY-MM-DD'),
format: 'MMM YYYY'
}
const q4 = {
label: `Q4: ${fiscalYear.message.name}`,
translatedLabel: `${_("Q4")}: ${fiscalYear.message.name}`,
fromDate: dayjs(fiscalYearStart).add(9, 'month').format('YYYY-MM-DD'),
toDate: fiscalYearEnd,
format: 'MMM YYYY'
}
const thisYear = {
label: `This Fiscal Year`,
translatedLabel: `${_("This Fiscal Year")}`,
fromDate: fiscalYearStart,
toDate: fiscalYearEnd,
format: 'MMM YYYY'
}
const lastYear = {
label: `Last Fiscal Year`,
translatedLabel: `${_("Last Fiscal Year")}`,
fromDate: dayjs(fiscalYearStart).subtract(1, 'year').format('YYYY-MM-DD'),
toDate: dayjs(fiscalYearEnd).subtract(1, 'year').format('YYYY-MM-DD'),
format: 'MMM YYYY'
}
// Sort the options so that we get "This Month", "Last Month", quarters, fiscal year, then the rest of the standard options
const topRankedItems = standardOptions.filter((option) => {
return option.label === "This Month" || option.label === "Last Month"
})
const bottomRankedItems = standardOptions.filter((option) => {
return option.label !== "This Month" && option.label !== "Last Month"
})
return [...topRankedItems, q1, q2, q3, q4, thisYear, lastYear, ...bottomRankedItems]
}
return standardOptions
}, [fiscalYear])
const [open, setOpen] = useState(false)
const [value, setValue] = useState("")
const timePeriod: TimePeriod | string = useMemo(() => {
if (bankRecDate.fromDate && bankRecDate.toDate) {
// Check if the from and to dates match any predefined time period
for (const period of timePeriodOptions) {
if (period.fromDate === bankRecDate.fromDate && period.toDate === bankRecDate.toDate) {
return period.label;
}
}
return "Date Range";
} else {
return "Date Range";
}
}, [bankRecDate.fromDate, bankRecDate.toDate, timePeriodOptions]);
const handleTimePeriodChange = (fromDate: string, toDate: string) => {
setBankRecDate({ fromDate, toDate })
setOpen(false)
}
const dateObj = useMemo(() => {
return {
from: new Date(bankRecDate.fromDate),
to: new Date(bankRecDate.toDate)
}
}, [bankRecDate.fromDate, bankRecDate.toDate])
const direction = useDirection()
return <div className='flex items-center'>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant={'outline'}
aria-expanded={open}
size='md'
className='rounded-e-none border-e-0'
role="combobox">
{timePeriodOptions.find((period) => period.label === timePeriod)?.translatedLabel ?? _(timePeriod)}
<ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent className="w-84 p-1" align='start'>
<Command>
<CommandInput placeholder="e.g. Last 3 weeks" onValueChange={setValue} value={value} />
<CommandList className='max-h-fit'>
<CommandEmpty className='text-start p-2 hover:bg-surface-gray-1'>
<EmptyState onSelect={handleTimePeriodChange} value={value} />
</CommandEmpty>
{timePeriodOptions.map((period) => (
<CommandItem key={period.label} className='flex justify-between' onSelect={() => handleTimePeriodChange(period.fromDate, period.toDate)}>
<span>
{period.translatedLabel ?? _(period.label)}
</span>
<span className='text-xs text-ink-gray-5 flex items-center gap-1 text-end whitespace-nowrap'>
{formatDate(period.fromDate, period.format)} {direction === 'ltr' ? <ChevronRight className='text-[12px] text-ink-gray-5/70' /> : <ChevronLeftIcon className='text-[12px] text-ink-gray-5/70' />} {formatDate(period.toDate, period.format)}
</span>
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<Button variant={'outline'} className='rounded-s-none' size='md'>
{formatDate(bankRecDate.fromDate)} - {formatDate(bankRecDate.toDate)}
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto overflow-hidden p-0' align='end'>
<Calendar
mode='range'
captionLayout='dropdown'
selected={{
from: dateObj.from,
to: dateObj.to
}}
numberOfMonths={2}
defaultMonth={dateObj.from}
onSelect={(date) => {
if (date) {
setBankRecDate({ fromDate: formatDate(date.from, 'YYYY-MM-DD'), toDate: formatDate(date.to, 'YYYY-MM-DD') })
}
}}
/>
</PopoverContent>
</Popover>
</div>
}
const referentialKeywords = ["last", "this", "next", "previous"]
const EmptyState = ({ onSelect, value }: { onSelect: (fromDate: string, toDate: string) => void, value: string }) => {
const dates = useMemo(() => {
if (value) {
// Try parsing the value
const parsedDate = parse(value, undefined, { forwardDate: false })
if (parsedDate && parsedDate.length > 0) {
const startDate = parsedDate[0].start.date()
const endDate = parsedDate[0].end?.date()
if (!endDate) {
const today = new Date()
// If today is greater than the start date, use today as the end date
if (startDate.getTime() > today.getTime()) {
return { fromDate: today, toDate: startDate }
} else {
// Check if the user only wants a specific month like "May 2025"
// If the "known values" just has month and year, then we need to get the first day of the month and the last day of the month
// @ts-expect-error - "Known Values" is available in the start "ParsingComponents"
if (parsedDate[0].start.knownValues?.month && !parsedDate[0].start.knownValues?.day) {
return {
fromDate: startDate,
toDate: dayjs(startDate).endOf('month').toDate()
}
// @ts-expect-error - "Known Values" is available in the start "ParsingComponents"
} else if (parsedDate[0].start.knownValues?.month && parsedDate[0].start.knownValues?.day && !referentialKeywords.some(keyword => value.toLowerCase().includes(keyword))) {
// If month and day is known, then we should not assume that the user wants to get everything until today
return {
fromDate: startDate,
toDate: startDate,
}
}
return {
fromDate: startDate,
toDate: today
}
}
} else {
return { fromDate: startDate, toDate: endDate }
}
}
}
}, [value])
const onClick = (fromDate: Date, toDate: Date) => {
onSelect(formatDate(fromDate, 'YYYY-MM-DD'), formatDate(toDate, 'YYYY-MM-DD'))
}
const isEqual = dates?.fromDate && dates?.toDate && dayjs(dates.fromDate).isSame(dates.toDate, 'date')
return <div>
{dates ?
<div className='flex gap-2 items-center justify-between cursor-pointer' onClick={() => onClick(dates.fromDate, dates.toDate)}>
<span className='text-sm text-ink-gray-5 max-w-[30%]'>
{value}
</span>
{isEqual ? <span className='text-xs text-ink-gray-5 text-balance flex items-center gap-1'>
{formatDate(dates.fromDate, 'Do MMM YYYY')}
</span> :
<span className='text-xs text-ink-gray-5 flex items-center gap-1'>
{formatDate(dates.fromDate, 'Do MMM YY')} <ChevronRight size='16' className='text-ink-gray-5' /> {formatDate(dates.toDate, 'Do MMM YY')}
</span>}
</div> :
<span className='text-sm text-ink-gray-5'>
No results found
</span>
}
</div>
}
export default BankRecDateFilter

View File

@@ -1,315 +0,0 @@
import { useAtomValue } from "jotai"
import { MissingFiltersBanner } from "./MissingFiltersBanner"
import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms"
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
import { Paragraph } from "@/components/ui/typography"
import { useCallback, useMemo } from "react"
import type { ColumnDef } from "@tanstack/react-table"
import { useFrappeGetCall } from "frappe-react-sdk"
import { QueryReportReturnType } from "@/types/custom/Reports"
import { formatDate } from "@/lib/date"
import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view"
import { formatCurrency } from "@/lib/numbers"
import { getCompanyCurrency } from "@/lib/company"
import { slug } from "@/lib/frappe"
import { ScrollTextIcon } from "lucide-react"
import ErrorBanner from "@/components/ui/error-banner"
import { StatContainer, StatLabel, StatValue } from "@/components/ui/stats"
import _ from "@/lib/translate"
import { toast } from "sonner"
import { useCopyToClipboard } from "usehooks-ts"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
const BankReconciliationStatement = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
const dates = useAtomValue(bankRecDateAtom)
if (!bankAccount) {
return <MissingFiltersBanner text={_("Please select a bank account to view the bank reconciliation statement.")} />
}
if (!dates) {
return <MissingFiltersBanner text={_("Please select dates to view the bank reconciliation statement.")} />
}
return <BankReconciliationStatementView />
}
interface BankClearanceSummaryEntry {
payment_document: string
payment_entry: string
posting_date: string,
reference_no: string,
credit: number,
debit: number,
against_account: string,
ref_date: string,
account_currency: string,
clearance_date: string
}
const BankReconciliationStatementView = () => {
const companyID = useCurrentCompany()
const bankAccount = useAtomValue(selectedBankAccountAtom)
const dates = useAtomValue(bankRecDateAtom)
const filters = useMemo(() => {
return JSON.stringify({
account: bankAccount?.account,
report_date: dates.toDate,
company: companyID
})
}, [bankAccount, dates, companyID])
const { data, error } = useFrappeGetCall<{ message: QueryReportReturnType }>('frappe.desk.query_report.run', {
report_name: 'Bank Reconciliation Statement',
filters,
ignore_prepared_report: 1,
are_default_filters: false,
}, `Report-Bank Reconciliation Statement-${filters}`, { keepPreviousData: true, revalidateOnFocus: false }, 'POST')
const [, copyToClipboard] = useCopyToClipboard()
const onCopy = useCallback(
(text: string) => {
copyToClipboard(text).then(() => {
toast.success(_("Copied to clipboard"))
})
},
[copyToClipboard, _],
)
const statementColumns = useMemo<ColumnDef<BankClearanceSummaryEntry, unknown>[]>(
() => [
{
accessorKey: "posting_date",
header: _("Posting Date"),
size: 118,
meta: { tabularNums: true } satisfies ListViewColumnMeta,
cell: ({ row }) => formatDate(row.original.posting_date),
},
{
accessorKey: "payment_document",
header: _("Document Type"),
size: 140,
cell: ({ row }) => _(row.original.payment_document),
},
{
id: "payment_entry",
header: _("Payment Document"),
size: 300,
meta: {
getTooltipText: (r) => {
const x = r as BankClearanceSummaryEntry
const parts = [x.payment_document, x.payment_entry].filter(Boolean)
return parts.length ? parts.join(" · ") : undefined
},
} satisfies ListViewColumnMeta,
cell: ({ row }) => {
const { payment_document, payment_entry } = row.original
return payment_document ? (
<a
target="_blank"
rel="noreferrer"
className="text-ink-gray-8 block min-w-0 w-full underline underline-offset-4"
href={`/desk/${slug(payment_document)}/${payment_entry}`}
>
{payment_entry}
</a>
) : (
payment_entry
)
},
},
{
accessorKey: "debit",
header: _("Debit"),
size: 112,
meta: { align: "right" } satisfies ListViewColumnMeta,
cell: ({ row }) => <span className="font-numeric">{formatCurrency(row.original.debit, row.original.account_currency)}</span>,
},
{
accessorKey: "credit",
header: _("Credit"),
size: 112,
meta: { align: "right" } satisfies ListViewColumnMeta,
cell: ({ row }) => <span className="font-numeric">{formatCurrency(row.original.credit, row.original.account_currency)}</span>,
},
{
accessorKey: "against_account",
header: _("Against Account"),
meta: { gridWidth: "minmax(0,1.25fr)" } satisfies ListViewColumnMeta,
cell: ({ row }) => (
<a
target="_blank"
rel="noreferrer"
className="text-ink-gray-8 block min-w-0 w-full underline underline-offset-4"
href={`/desk/account/${row.original.against_account}`}
>
{row.original.against_account}
</a>
),
},
{
accessorKey: "reference_no",
header: _("Reference #"),
cell: ({ row }) => {
const ref = row.original.reference_no
return (
<button
type="button"
className="text-ink-gray-8 hover:underline min-w-0 w-full cursor-pointer truncate text-start underline-offset-4"
onClick={() => onCopy(ref)}
>
{ref}
</button>
)
},
},
{
accessorKey: "ref_date",
header: _("Reference Date"),
size: 118,
meta: { tabularNums: true } satisfies ListViewColumnMeta,
cell: ({ row }) => formatDate(row.original.ref_date),
},
{
accessorKey: "clearance_date",
header: _("Clearance Date"),
size: 118,
meta: { tabularNums: true } satisfies ListViewColumnMeta,
cell: ({ row }) => formatDate(row.original.clearance_date),
},
],
[_, onCopy],
)
const statementRows = useMemo(() => {
if (!data?.message.result) return []
return data.message.result.filter((row: BankClearanceSummaryEntry) => Boolean(row.payment_entry))
}, [data])
return <div className="space-y-4 py-2">
<div>
<Paragraph className="text-sm">
<span dangerouslySetInnerHTML={{
__html: _("Below is a list of all entries posted against the bank account {0} which have not been cleared till {1}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formatDate(dates.toDate)}</strong>`])
}} />
</Paragraph>
</div>
{error && <ErrorBanner error={error} />}
{data && <SummarySection data={data} />}
{data && data.message.result.length > 0 && (
<div className="space-y-2">
<p className="text-ink-gray-5 text-sm">{_("Bank Reconciliation Statement")}</p>
<ListView
data={statementRows}
columns={statementColumns}
getRowId={(row) => row.payment_entry}
maxHeight="min(70vh, 640px)"
emptyState={_("No entries with a payment document in this list.")}
/>
</div>
)}
{data && data.message.result.length === 0 &&
<Empty>
<EmptyMedia>
<ScrollTextIcon />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>{_("No entries found")}</EmptyTitle>
<EmptyDescription>{_("There are no accounting entries in the system for the selected account and dates.")}</EmptyDescription>
</EmptyHeader>
</Empty>
}
</div>
}
const SummarySection = ({ data }: { data: { message: QueryReportReturnType } }) => {
const company = useCurrentCompany()
const bankAccount = useAtomValue(selectedBankAccountAtom)
const { bankStatementBalanceAsPerGL, outstandingChecksDebit, outstandingChecksCredit, incorrectlyClearedEntriesDebit, incorrectlyClearedEntriesCredit, calculatedBankStatementBalance } = useMemo(() => {
// Loop over the results and find the corresponding rows
let bankStatementBalanceAsPerGL = 0
let outstandingChecksDebit = 0
let outstandingChecksCredit = 0
let incorrectlyClearedEntriesDebit = 0
let incorrectlyClearedEntriesCredit = 0
let calculatedBankStatementBalance = 0
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data?.message.result.forEach((r: any) => {
if (r.payment_entry === 'Bank Statement balance as per General Ledger') {
bankStatementBalanceAsPerGL = r.debit - r.credit
}
if (r.payment_entry === 'Outstanding Checks and Deposits to clear') {
outstandingChecksDebit = r.debit
outstandingChecksCredit = r.credit
}
if (r.payment_entry === 'Checks and Deposits incorrectly cleared') {
incorrectlyClearedEntriesDebit = r.debit
incorrectlyClearedEntriesCredit = r.credit
}
if (r.payment_entry === 'Calculated Bank Statement balance') {
calculatedBankStatementBalance = r.debit - r.credit
}
})
return {
bankStatementBalanceAsPerGL,
outstandingChecksDebit,
outstandingChecksCredit,
incorrectlyClearedEntriesDebit,
incorrectlyClearedEntriesCredit,
calculatedBankStatementBalance
}
}, [data])
const currency = bankAccount?.account_currency ?? getCompanyCurrency(company)
return <div className="flex gap-4 items-start justify-between">
<StatContainer>
<StatLabel>{_("Bank Statement Balance as per General Ledger")}</StatLabel>
<StatValue className="font-numeric">{formatCurrency(bankStatementBalanceAsPerGL, currency)}</StatValue>
</StatContainer>
<StatContainer>
<StatLabel>{_("Outstanding Checks and Deposits to clear")}</StatLabel>
<StatValue className="font-numeric">{formatCurrency(outstandingChecksDebit - outstandingChecksCredit, currency)}</StatValue>
</StatContainer>
{(incorrectlyClearedEntriesDebit > 0 || incorrectlyClearedEntriesCredit > 0) && <StatContainer>
<StatLabel className="text-ink-red-3">{_("Checks and Deposits incorrectly cleared")}</StatLabel>
<StatValue className="text-ink-red-3 font-numeric">{formatCurrency(incorrectlyClearedEntriesDebit - incorrectlyClearedEntriesCredit)}</StatValue>
{/* <div className="" divider={<StackDivider height='20px' />}>
{incorrectlyClearedEntriesDebit !== 0 && <StatHelpText>Debit: {formatCurrency(incorrectlyClearedEntriesDebit)}</StatHelpText>}
{incorrectlyClearedEntriesCredit !== 0 && <StatHelpText>Credit: {formatCurrency(incorrectlyClearedEntriesCredit)}</StatHelpText>}
</div> */}
</StatContainer>}
<StatContainer>
<StatLabel>{_("Calculated Bank Statement Balance")}</StatLabel>
<StatValue className="font-numeric">{formatCurrency(calculatedBankStatementBalance)}</StatValue>
</StatContainer>
</div>
}
export default BankReconciliationStatement

View File

@@ -1,422 +0,0 @@
import { useAtomValue, useSetAtom } from "jotai"
import { MissingFiltersBanner } from "./MissingFiltersBanner"
import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
import { Paragraph } from "@/components/ui/typography"
import { formatDate } from "@/lib/date"
import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view"
import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers"
import { getCompanyCurrency } from "@/lib/company"
import { ArrowDownRight, ArrowUpRight, CheckCircle2, ChevronDown, DollarSign, ExternalLink, ImportIcon, ListIcon, Search, Undo2, XCircle } from "lucide-react"
import ErrorBanner from "@/components/ui/error-banner"
import { Badge } from "@/components/ui/badge"
import { useGetBankTransactions } from "./utils"
import { BankTransaction } from "@/types/Accounts/BankTransaction"
import { Button } from "@/components/ui/button"
import _ from "@/lib/translate"
import { Input } from "@/components/ui/input"
import CurrencyInput from "react-currency-input-field"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { getCurrencySymbol } from "@/lib/currency"
import { useDebounceValue } from "usehooks-ts"
import type { ColumnDef } from "@tanstack/react-table"
import { useCallback, useMemo, useState } from "react"
import { Link } from "react-router"
import { Empty, EmptyTitle, EmptyHeader, EmptyMedia, EmptyDescription, EmptyContent } from "@/components/ui/empty"
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"
const BankTransactions = () => {
const selectedBank = useAtomValue(selectedBankAccountAtom)
const dates = useAtomValue(bankRecDateAtom)
if (!selectedBank || !dates) {
return <MissingFiltersBanner text={_("Please select a bank and set the date range")} />
}
return <>
<BankTransactionListView />
</>
}
const BankTransactionListView = () => {
const { data, error } = useGetBankTransactions()
const bankAccount = useAtomValue(selectedBankAccountAtom)
const dates = useAtomValue(bankRecDateAtom)
const formattedFromDate = formatDate(dates.fromDate)
const formattedToDate = formatDate(dates.toDate)
const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom)
const onUndo = useCallback(
(transaction: BankTransaction) => {
setBankRecUnreconcileModalAtom(transaction.name)
},
[setBankRecUnreconcileModalAtom],
)
const accountCurrency = useMemo(
() => bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ""),
[bankAccount?.account_currency, bankAccount?.company],
)
const transactionColumns = useMemo<ColumnDef<BankTransaction, unknown>[]>(
() => [
{
accessorKey: "date",
header: _("Date"),
size: 112,
meta: { tabularNums: true } satisfies ListViewColumnMeta,
cell: ({ row }) => formatDate(row.original.date),
},
{
accessorKey: "description",
header: _("Description"),
size: 250,
// meta: { gridWidth: "minmax(0,2fr)" } satisfies ListViewColumnMeta,
cell: ({ row }) => row.original.description,
},
{
accessorKey: "reference_number",
header: _("Reference #"),
size: 128,
cell: ({ row }) => row.original.reference_number,
},
{
accessorKey: "withdrawal",
header: _("Withdrawal"),
size: 120,
meta: { align: "right" } satisfies ListViewColumnMeta,
cell: ({ row }) => <span className="font-numeric">{formatCurrency(row.original.withdrawal, accountCurrency)}</span>,
},
{
accessorKey: "deposit",
header: _("Deposit"),
size: 120,
meta: { align: "right" } satisfies ListViewColumnMeta,
cell: ({ row }) => <span className="font-numeric">{formatCurrency(row.original.deposit, accountCurrency)}</span>,
},
{
accessorKey: "unallocated_amount",
header: _("Unallocated"),
size: 120,
meta: { align: "right" } satisfies ListViewColumnMeta,
cell: ({ row }) => <span className="font-numeric">{formatCurrency(row.original.unallocated_amount, accountCurrency)}</span>,
},
{
accessorKey: "transaction_type",
header: _("Type"),
size: 112,
cell: ({ row }) =>
row.original.transaction_type ? <Badge>{row.original.transaction_type}</Badge> : null,
},
{
id: "status",
header: _("Status"),
size: 168,
meta: { truncate: false, truncateTooltip: false } satisfies ListViewColumnMeta,
cell: ({ row }) => {
const tx = row.original
if (!tx.allocated_amount || (tx.allocated_amount && tx.allocated_amount === 0)) {
return (
<Badge theme="red">
<XCircle />
{_("Not Reconciled")}
</Badge>
)
}
if (tx.allocated_amount && tx.allocated_amount > 0 && tx.unallocated_amount !== 0) {
return (
<Badge theme="orange">
<CheckCircle2 />
{_("Partially Reconciled")}
</Badge>
)
}
return (
<Badge theme="green">
<CheckCircle2 />
{_("Reconciled")}
</Badge>
)
},
},
{
id: "actions",
header: _("Actions"),
size: 200,
enableResizing: false,
meta: { truncate: false, truncateTooltip: false } satisfies ListViewColumnMeta,
cell: ({ row }) => (
<div className="flex gap-2 ps-0.5 items-center">
<Button variant="ghost" asChild size='sm'>
<a
href={`/desk/bank-transaction/${row.original.name}`}
target="_blank"
rel="noreferrer"
// className="text-ink-gray-8 underline underline-offset-4 inline-flex gap-2"
>
{_("View")} <ExternalLink className="w-4 h-4" />
</a>
</Button>
{row.original.allocated_amount && row.original.allocated_amount > 0 ? (
<Button
variant="ghost"
onClick={() => onUndo(row.original)}
size="sm"
theme='red'
>
<Undo2 />
{_("Undo")}
</Button>
) : null}
</div>
),
},
],
[_, accountCurrency, onUndo],
)
const [search, setSearch] = useDebounceValue('', 250)
const [amountFilter, setAmountFilter] = useState<{ value: number, stringValue?: string | number }>({ value: 0, stringValue: '0.00' })
const [typeFilter, setTypeFilter] = useState('All')
const [status, setStatus] = useState<'Reconciled' | 'Unreconciled' | 'All' | 'Partially Reconciled'>('All')
const onSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value)
}
const filteredResults = useMemo(() => {
if (!data) {
return []
}
return data.message.filter((transaction) => {
if (search && !transaction.description?.toLowerCase().includes(search.toLowerCase())) {
return false
}
if (typeFilter !== 'All') {
if (typeFilter === 'Debits' && transaction.deposit && transaction.deposit > 0) {
return false
}
if (typeFilter === 'Credits' && transaction.withdrawal && transaction.withdrawal > 0) {
return false
}
}
if (status !== 'All') {
if (status === 'Reconciled' && transaction.status !== 'Reconciled') {
return false
}
if (status === 'Unreconciled') {
if (transaction.status === 'Reconciled') {
return false
}
// Filter out partially reconciled transactions
if (transaction.allocated_amount && transaction.allocated_amount > 0 && transaction.unallocated_amount !== 0) {
return false
}
}
if (status === 'Partially Reconciled') {
if (transaction.status === 'Reconciled') {
return false
}
if ((transaction.allocated_amount ?? 0) === 0) {
return false
}
}
}
if (amountFilter.value > 0 && transaction.withdrawal !== amountFilter.value && transaction.deposit !== amountFilter.value) {
return false
}
return true
})
}, [data, search, amountFilter, typeFilter, status])
return <div className="space-y-2 py-2">
<div className="flex gap-2 justify-between items-center">
<Paragraph className="text-sm">
<span dangerouslySetInnerHTML={{
__html: _("Below is a list of all bank transactions imported in the system for the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account_name}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
}} />
</Paragraph>
<Button size='md' variant='subtle' asChild>
<Link to="/statement-importer">
<ImportIcon />
{_("Import Bank Statement")}
</Link>
</Button>
</div>
{error && <ErrorBanner error={error} />}
<Filters
onSearchChange={onSearchChange}
search={search}
results={filteredResults}
setAmountFilter={setAmountFilter}
amountFilter={amountFilter}
onTypeFilterChange={setTypeFilter}
typeFilter={typeFilter}
status={status}
setStatus={setStatus}
/>
<ListView
data={filteredResults}
columns={transactionColumns}
getRowId={(row) => row.name}
maxHeight="calc(100vh - 200px)"
scrollAreaClassName="min-h-[calc(100vh-200px)]"
emptyState={<Empty>
<EmptyMedia>
<ListIcon />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>{_("No bank transactions found")}</EmptyTitle>
<EmptyDescription>{_("There are no transactions in the system for the selected bank account and dates that match the filters.")}</EmptyDescription>
</EmptyHeader>
{data && data.message.length === 0 ? <EmptyContent>
<Button type='button' asChild variant='outline'>
<Link to="/statement-importer">
{_("Import Bank Statement")}
</Link>
</Button>
</EmptyContent> : null}
</Empty>}
/>
</div>
}
interface FilterProps {
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void
search: string
results: BankTransaction[]
setAmountFilter: (value: { value: number, stringValue?: string | number }) => void
amountFilter: { value: number, stringValue?: string | number }
onTypeFilterChange: (type: string) => void
typeFilter: string
status: 'Reconciled' | 'Unreconciled' | 'All' | 'Partially Reconciled'
setStatus: (status: 'Reconciled' | 'Unreconciled' | 'All' | 'Partially Reconciled') => void
}
const Filters = ({
onSearchChange,
search,
results,
setAmountFilter,
amountFilter,
onTypeFilterChange,
typeFilter,
status,
setStatus,
}: FilterProps) => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
const currency = bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '')
const currencySymbol = getCurrencySymbol(currency)
const formatInfo = getCurrencyFormatInfo(currency)
const groupSeparator = formatInfo.group_sep || ","
const decimalSeparator = formatInfo.decimal_str || "."
return <div className="flex py-2 w-full gap-2">
<InputGroup variant='outline'>
<label className="sr-only">{_("Search transactions")}</label>
<InputGroupAddon>
<Search className="w-4 h-4 text-ink-gray-5" />
</InputGroupAddon>
<Input
placeholder={_("Search")} type='search' onChange={onSearchChange} variant='outline' defaultValue={search}
className="border-none px-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" />
<InputGroupAddon align='inline-end'>
<span className="text-sm text-ink-gray-5 text-nowrap whitespace-nowrap">{results?.length} {_(results?.length === 1 ? "result" : "results")}</span>
</InputGroupAddon>
</InputGroup>
<div className="w-[25%]">
<label className="sr-only">{_("Filter by amount")}</label>
<CurrencyInput
groupSeparator={groupSeparator}
decimalSeparator={decimalSeparator}
placeholder={`${currencySymbol}0${decimalSeparator}00`}
decimalsLimit={2}
value={amountFilter.stringValue}
maxLength={12}
decimalScale={2}
prefix={currencySymbol}
onValueChange={(v, _n, values) => {
// If the input ends with a decimal or a decimal with trailing zeroes, store the string since we need the user to be able to type the decimals.
// When the user eventually types the decimals or blurs out, the value is formatted anyway.
// Otherwise store the float value
// Check if the value ends with a decimal or a decimal with trailing zeroes
const isDecimal = v?.endsWith(decimalSeparator) || v?.endsWith(decimalSeparator + '0')
const newValue = isDecimal ? v : values?.float ?? ''
setAmountFilter({
value: Number(newValue),
stringValue: newValue
})
}}
// @ts-expect-error - CurrencyInputProps doesn't have a variant prop but Input does
variant={"outline"}
customInput={Input}
/>
</div>
<div className="w-[25%]">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size='md' className="min-w-32 w-full text-start justify-between">
<div className="flex gap-2 items-center">
{typeFilter === 'All' ? <DollarSign className="w-4 h-4 text-ink-gray-5" /> : typeFilter === 'Debits' ? <ArrowUpRight className="w-4 h-4 text-ink-red-3" /> : <ArrowDownRight className="w-4 h-4 text-ink-green-3" />}
{_(typeFilter)}
</div>
<ChevronDown className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => onTypeFilterChange('All')}><DollarSign /> {_("All")}</DropdownMenuItem>
<DropdownMenuItem onClick={() => onTypeFilterChange('Debits')}><ArrowUpRight className="text-ink-red-3" /> {_("Debits")}</DropdownMenuItem>
<DropdownMenuItem onClick={() => onTypeFilterChange('Credits')}><ArrowDownRight className="text-ink-green-3" /> {_("Credits")}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="w-[25%]">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size='md' className="min-w-32 w-full text-start justify-between">
<div className="flex gap-2 items-center">
{status === 'All' ? <ListIcon className="w-4 h-4 text-ink-gray-5" /> :
status === 'Reconciled' ? <CheckCircle2 className="w-4 h-4 text-ink-green-3" /> :
status === 'Unreconciled' ? <XCircle className="w-4 h-4 text-ink-red-3" /> :
<CheckCircle2 className="w-4 h-4 text-yellow-500" />}
{_(status)}
</div>
<ChevronDown className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => setStatus('All')}>{<ListIcon className="w-4 h-4 text-ink-gray-5" />} {_("All")}</DropdownMenuItem>
<DropdownMenuItem onClick={() => setStatus('Reconciled')}>{<CheckCircle2 className="w-4 h-4 text-ink-green-3" />} {_("Reconciled")}</DropdownMenuItem>
<DropdownMenuItem onClick={() => setStatus('Unreconciled')}>{<XCircle className="w-4 h-4 text-ink-red-3" />} {_("Unreconciled")}</DropdownMenuItem>
<DropdownMenuItem onClick={() => setStatus('Partially Reconciled')}>{<CheckCircle2 className="w-4 h-4 text-yellow-500" />} {_("Partially Reconciled")}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
}
export default BankTransactions

View File

@@ -1,125 +0,0 @@
import { AlertDialog, AlertDialogOverlay, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from "@/components/ui/alert-dialog"
import { useAtom, useAtomValue } from "jotai"
import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
import { useMemo } from "react"
import { useFrappeGetDoc, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
import { BankTransaction } from "@/types/Accounts/BankTransaction"
import { toast } from "sonner"
import ErrorBanner from "@/components/ui/error-banner"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { formatCurrency } from "@/lib/numbers"
import { Badge } from "@/components/ui/badge"
import { slug } from "@/lib/frappe"
import SelectedTransactionDetails from "./SelectedTransactionDetails"
import _ from "@/lib/translate"
const BankTransactionUnreconcileModal = () => {
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
const onOpenChange = (v: boolean) => {
if (!v) {
setBankRecUnreconcileModal('')
}
}
return <AlertDialog open={!!unreconcileModal} onOpenChange={onOpenChange}>
<AlertDialogOverlay />
<AlertDialogContent className="min-w-2xl">
<AlertDialogHeader>
<AlertDialogTitle>{_("Undo Transaction Reconciliation")}</AlertDialogTitle>
<AlertDialogDescription>
{_("Are you sure you want to unreconcile this transaction?")}
</AlertDialogDescription>
</AlertDialogHeader>
<BankTransactionUnreconcileModalContent />
</AlertDialogContent>
</AlertDialog>
}
const BankTransactionUnreconcileModalContent = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
const dates = useAtomValue(bankRecDateAtom)
const { mutate } = useSWRConfig()
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
const { data: transaction, error } = useFrappeGetDoc<BankTransaction>('Bank Transaction', unreconcileModal)
const { call, loading, error: unreconcileError } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction')
const onUnreconcile = (event: React.MouseEvent<HTMLButtonElement>) => {
call({
transaction_name: unreconcileModal
}).then(() => {
// Mutate the transactions list, unreconciled transactions list and account closing balance
mutate(`bank-reconciliation-bank-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`)
mutate(`bank-reconciliation-unreconciled-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`)
mutate(`bank-reconciliation-account-closing-balance-${bankAccount?.name}-${dates.toDate}`)
toast.success(_("Transaction Unreconciled"))
setBankRecUnreconcileModal('')
})
event.preventDefault()
}
const vouchersWhichWillBeCancelled = useMemo(() => {
return transaction?.payment_entries?.filter((payment) => payment.reconciliation_type === 'Voucher Created')
}, [transaction])
return <div>
<div className="flex flex-col gap-3">
{error && <ErrorBanner error={error} />}
{unreconcileError && <ErrorBanner error={unreconcileError} />}
{transaction && <SelectedTransactionDetails transaction={transaction} />}
<span className="font-medium text-sm">{_("This transaction has been reconciled with the following document(s):")}</span>
<Table>
<TableHeader>
<TableRow>
<TableHead>{_("Document")}</TableHead>
<TableHead>{_("Amount")}</TableHead>
<TableHead>{_("Reconciliation Type")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transaction?.payment_entries?.map((voucher) => {
return <TableRow key={voucher.name}>
<TableCell>
<a className="underline underline-offset-4"
target="_blank"
rel="noopener noreferrer"
href={`/desk/${slug(voucher.payment_document as string)}/${voucher.payment_entry}`}
>
{`${_(voucher.payment_document)}: ${voucher.payment_entry}`}
</a>
</TableCell>
<TableCell>{formatCurrency(voucher.allocated_amount)}</TableCell>
<TableCell>{voucher.reconciliation_type === 'Voucher Created' ?
<Badge theme="green">{_(voucher.reconciliation_type)}</Badge> :
<Badge theme="blue">{_(voucher.reconciliation_type ?? "Matched")}</Badge>}</TableCell>
</TableRow>
})}
</TableBody>
</Table>
<div className="py-4">
{vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && <span>The following documents will be <strong>cancelled</strong>:</span>}
{vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && <ol className="ms-6 list-disc [&>li]:mt-2">
{vouchersWhichWillBeCancelled?.map((voucher) => {
return <li key={voucher.name}>{_(voucher.payment_document)}: {voucher.payment_entry}</li>
})}
</ol>}
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>{_("Cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={onUnreconcile} theme="red" disabled={loading}>
{_("Unreconcile")}
</AlertDialogAction>
</AlertDialogFooter>
</div>
}
export default BankTransactionUnreconcileModal

View File

@@ -1,92 +0,0 @@
import { Button } from "@/components/ui/button"
import { selectedCompanyAtom, useCurrentCompany } from "@/hooks/useCurrentCompany"
import { useSetAtom } from "jotai"
import { Building2, Check, ChevronDown } from "lucide-react"
import { useState } from "react"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { cn } from "@/lib/utils"
import _ from "@/lib/translate"
import { selectedBankAccountAtom } from "./bankRecAtoms"
const CompanySelector = ({ onChange }: { onChange?: (company: string) => void }) => {
const [open, setOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const options = window.frappe?.boot?.docs?.filter((doc: Record<string, any>) => doc.doctype === ":Company").map((company: Record<string, any>) => company.name) || []
const setSelectedCompany = useSetAtom(selectedCompanyAtom)
const setSelectedBankAccount = useSetAtom(selectedBankAccountAtom)
const selectedCompany = useCurrentCompany()
const handleSelectCompany = (company: string) => {
setSelectedCompany(company)
setSearchQuery("")
setOpen(false)
// Only reset bank account if the company is changed
if (selectedCompany !== company) {
setSelectedBankAccount(null)
onChange?.(company)
}
}
return (<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
type='button'
role="combobox"
size='md'
aria-expanded={open}
className="justify-between"
>
<div className="flex items-center gap-2">
<Building2 />
{selectedCompany}
</div>
<ChevronDown className="text-ink-gray-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="min-w-56 w-fit p-0">
<Command value={selectedCompany}>
{options.length > 5 && <CommandInput placeholder={_("Search company...")} className="h-9" />}
<CommandList>
<CommandEmpty>{_("No company found.")}</CommandEmpty>
<CommandGroup>
{options.map((option: string) => (
<CommandItem
key={option}
value={option}
onSelect={(currentValue) => {
handleSelectCompany(currentValue)
}}
>
{option}
<Check
className={cn(
"ms-auto",
searchQuery === option ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>)
}
export default CompanySelector

View File

@@ -1,229 +0,0 @@
import { useAtomValue } from "jotai"
import { MissingFiltersBanner } from "./MissingFiltersBanner"
import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms"
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
import { Paragraph } from "@/components/ui/typography"
import type { ColumnDef } from "@tanstack/react-table"
import { useCallback, useMemo } from "react"
import { useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk"
import { QueryReportReturnType } from "@/types/custom/Reports"
import { formatDate } from "@/lib/date"
import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view"
import { formatCurrency } from "@/lib/numbers"
import { getCompanyCurrency } from "@/lib/company"
import { getErrorMessage, slug } from "@/lib/frappe"
import { Button } from "@/components/ui/button"
import { toast } from "sonner"
import { PartyPopper } from "lucide-react"
import ErrorBanner from "@/components/ui/error-banner"
import _ from "@/lib/translate"
import { Empty, EmptyTitle, EmptyDescription, EmptyMedia, EmptyHeader } from "@/components/ui/empty"
const IncorrectlyClearedEntries = () => {
const companyID = useCurrentCompany()
const bankAccount = useAtomValue(selectedBankAccountAtom)
const dates = useAtomValue(bankRecDateAtom)
if (!companyID || !bankAccount || !dates) {
const missingFields = []
if (!companyID) {
missingFields.push('Company')
}
if (!bankAccount) {
missingFields.push('Bank Account')
}
if (!dates) {
missingFields.push('Dates')
}
return <MissingFiltersBanner text={`Please select ${missingFields.join(', ')} to view the incorrectly cleared entries.`} />
}
return <IncorrectlyClearedEntriesView />
}
interface IncorrectlyClearedEntry {
payment_document: string
payment_entry: string
debit: number
credit: number
posting_date: string,
clearance_date: string,
}
const IncorrectlyClearedEntriesView = () => {
const companyID = useCurrentCompany()
const bankAccount = useAtomValue(selectedBankAccountAtom)
const dates = useAtomValue(bankRecDateAtom)
const filters = useMemo(() => {
return JSON.stringify({
company: companyID,
account: bankAccount?.account,
report_date: dates.toDate
})
}, [companyID, bankAccount, dates])
const { data, error, mutate } = useFrappeGetCall<{ message: QueryReportReturnType<IncorrectlyClearedEntry> }>('frappe.desk.query_report.run', {
report_name: 'Cheques and Deposits Incorrectly cleared',
filters,
ignore_prepared_report: 1,
are_default_filters: false,
}, `Report-Cheques and Deposits Incorrectly cleared-${filters}`, { keepPreviousData: true, revalidateOnFocus: false }, 'POST')
const formattedToDate = formatDate(dates.toDate)
const { call: clearClearingDate } = useFrappePostCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.clear_clearing_date')
const onClearClick = useCallback(
(voucher_type: string, voucher_name: string) => {
clearClearingDate({ voucher_type, voucher_name })
.then(() => {
toast.success(_("Cleared"), {
duration: 1000,
})
mutate()
})
.catch((e) => {
toast.error(_("There was an error while performing the action."), {
description: getErrorMessage(e),
duration: 5000,
})
})
},
[clearClearingDate, mutate, _],
)
const accountCurrency = useMemo(
() => bankAccount?.account_currency ?? getCompanyCurrency(companyID),
[bankAccount?.account_currency, companyID],
)
const incorrectlyClearedColumns = useMemo<ColumnDef<IncorrectlyClearedEntry, unknown>[]>(
() => [
{
accessorKey: "payment_document",
header: _("Document Type"),
size: 128,
cell: ({ row }) => _(row.original.payment_document),
},
{
id: "payment_entry",
header: _("Payment Document"),
size: 160,
meta: {
getTooltipText: (r) => {
const x = r as IncorrectlyClearedEntry
return [x.payment_document, x.payment_entry].filter(Boolean).join(" · ") || undefined
},
} satisfies ListViewColumnMeta,
cell: ({ row }) => (
<a
target="_blank"
rel="noreferrer"
className="text-ink-gray-8 block min-w-0 w-full underline underline-offset-4"
href={`/desk/${slug(row.original.payment_document)}/${row.original.payment_entry}`}
>
{row.original.payment_entry}
</a>
),
},
{
accessorKey: "debit",
header: _("Debit"),
size: 120,
meta: { align: "right" } satisfies ListViewColumnMeta,
cell: ({ row }) => formatCurrency(row.original.debit, accountCurrency),
},
{
accessorKey: "credit",
header: _("Credit"),
size: 120,
meta: { align: "right" } satisfies ListViewColumnMeta,
cell: ({ row }) => formatCurrency(row.original.credit, accountCurrency),
},
{
accessorKey: "posting_date",
header: _("Posting Date"),
size: 118,
meta: { tabularNums: true } satisfies ListViewColumnMeta,
cell: ({ row }) => formatDate(row.original.posting_date),
},
{
accessorKey: "clearance_date",
header: _("Clearance Date"),
size: 118,
meta: { tabularNums: true } satisfies ListViewColumnMeta,
cell: ({ row }) => formatDate(row.original.clearance_date),
},
{
id: "actions",
header: _("Actions"),
size: 180,
enableResizing: false,
meta: { truncate: false, truncateTooltip: false } satisfies ListViewColumnMeta,
cell: ({ row }) => (
<Button
variant="link"
size="sm"
className="text-ink-red-3 px-0"
onClick={() => onClearClick(row.original.payment_document, row.original.payment_entry)}
>
{_("Reset Clearing Date")}
</Button>
),
},
],
[_, accountCurrency, onClearClick],
)
return <div className="space-y-4 py-2">
<div>
<Paragraph className="text-sm">
<span dangerouslySetInnerHTML={{
__html: _("This report shows all entries in the system where the <strong>clearance date is before the posting date</strong> which is incorrect.")
}} />
<br />
{data && data.message.result.length > 0 && <span>
<span dangerouslySetInnerHTML={{
__html: _("Entries below have a posting date after {0} but the clearance date is before {1}.", [`<strong>${formattedToDate}</strong>`, `<strong>${formattedToDate}</strong>`])
}} />
<br />
{_("You can reset the clearing dates of these entries here.")}
</span>}
</Paragraph>
</div>
{error && <ErrorBanner error={error} />}
{data && data.message.result.length > 0 && (
<div className="space-y-2">
<p className="text-ink-gray-5 text-sm">{_("Incorrectly cleared entries as per the report.")}</p>
<ListView
data={data.message.result}
columns={incorrectlyClearedColumns}
getRowId={(row) => `${row.payment_entry}-${row.posting_date}`}
maxHeight="min(70vh, 640px)"
emptyState={_("No rows to display.")}
/>
</div>
)}
{data && data.message.result.length === 0 &&
<Empty>
<EmptyMedia>
<PartyPopper />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>{_("It's all good!")}</EmptyTitle>
<EmptyDescription>{_("There are no entries in the system where the clearance date is before the posting date.")}</EmptyDescription>
</EmptyHeader>
</Empty>
}
</div>
}
export default IncorrectlyClearedEntries

View File

@@ -1,949 +0,0 @@
import { useAtom, useAtomValue, useSetAtom } from "jotai"
import { bankRecAmountFilter, bankRecDateAtom, bankRecRecordJournalEntryModalAtom, bankRecRecordPaymentModalAtom, bankRecSelectedTransactionAtom, bankRecTransactionTypeFilter, bankRecTransferModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
import { H4 } from "@/components/ui/typography"
import { useMemo, useRef } from "react"
import { getCompanyCurrency } from "@/lib/company"
import ErrorBanner from "@/components/ui/error-banner"
import { Separator } from "@/components/ui/separator"
import Fuse from 'fuse.js'
import { getSearchResults, LinkedPayment, UnreconciledTransaction, useGetRuleForTransaction, useGetUnreconciledTransactions, useGetVouchersForTransaction, useIsTransactionWithdrawal, useReconcileTransaction, useTransactionSearch } from "./utils"
import { Input } from "@/components/ui/input"
import { AlertCircleIcon, ArrowDownRight, ArrowRightIcon, ArrowRightLeft, ArrowUpRight, BadgeCheck, ChevronDown, DollarSign, Landmark, LandmarkIcon, ListIcon, Loader2, Receipt, ReceiptIcon, Search, User, XCircle, ZapIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
import CurrencyInput from 'react-currency-input-field'
import { getCurrencySymbol } from "@/lib/currency"
import { Virtuoso } from 'react-virtuoso'
import { formatDate } from "@/lib/date"
import { Badge } from "@/components/ui/badge"
import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers"
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/components/ui/tooltip"
import { Skeleton } from "@/components/ui/skeleton"
import { slug } from "@/lib/frappe"
import _ from "@/lib/translate"
import TransferModal from "./TransferModal"
import BankEntryModal from "./BankEntryModal"
import RecordPaymentModal from "./RecordPaymentModal"
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import SelectedTransactionsTable from "./SelectedTransactionsTable"
import MatchFilters from "./MatchFilters"
import { useHotkeys } from "react-hotkeys-hook"
import { KeyboardMetaKeyIcon } from "@/components/ui/keyboard-keys"
import { Kbd, KbdGroup } from "@/components/ui/kbd"
import { useFrappeGetCall } from "frappe-react-sdk"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Link } from "react-router"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { InputGroup, InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
const MatchAndReconcile = ({ contentHeight }: { contentHeight: number }) => {
const selectedBank = useAtomValue(selectedBankAccountAtom)
if (!selectedBank) {
return <Empty>
<EmptyMedia>
<LandmarkIcon />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>{_("Select a bank account to reconcile")}</EmptyTitle>
</EmptyHeader>
</Empty>
}
return <>
<div className={`flex items-start space-x-2`} >
<div className="flex-1">
<H4 className="text-sm font-medium">{_("Unreconciled Transactions")}</H4>
<UnreconciledTransactions contentHeight={contentHeight} />
</div>
<Separator orientation="vertical" style={{ minHeight: `${contentHeight}px` }} />
<div className="flex-1 px-1">
<H4 className="text-sm font-medium">{_("Match or Create")}</H4>
<VouchersSection contentHeight={contentHeight} />
</div>
</div>
<TransferModal />
<BankEntryModal />
<RecordPaymentModal />
</>
}
const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number }) => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
const currency = bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '')
const currencySymbol = getCurrencySymbol(currency)
const formatInfo = getCurrencyFormatInfo(currency)
const groupSeparator = formatInfo.group_sep || ","
const decimalSeparator = formatInfo.decimal_str || "."
const inputRef = useRef<HTMLInputElement>(null)
const { data: unreconciledTransactions, isLoading, error } = useGetUnreconciledTransactions()
const [typeFilter, setTypeFilter] = useAtom(bankRecTransactionTypeFilter)
const [amountFilter, setAmountFilter] = useAtom(bankRecAmountFilter)
const [search, setSearch] = useTransactionSearch()
const searchIndex = useMemo(() => {
if (!unreconciledTransactions) {
return null
}
return new Fuse(unreconciledTransactions.message, {
keys: ['description', 'reference_number'],
threshold: 0.5,
includeScore: true
})
}, [unreconciledTransactions])
const results = useMemo(() => {
return getSearchResults(searchIndex, search, typeFilter, amountFilter.value, unreconciledTransactions?.message)
}, [searchIndex, search, typeFilter, amountFilter.value, unreconciledTransactions?.message])
const setSelectedTransaction = useSetAtom(bankRecSelectedTransactionAtom(bankAccount?.name || ''))
const onFilterChange = () => {
setSelectedTransaction([])
}
const onSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value)
onFilterChange()
}
const onTypeFilterChange = (type: string) => {
setTypeFilter(type)
onFilterChange()
}
const onClearFilters = () => {
setSearch('')
if (inputRef.current) {
inputRef.current.value = ''
}
setTypeFilter('All')
setAmountFilter({ value: 0, stringValue: '' })
onFilterChange()
}
const hasFilters = search !== '' || typeFilter !== 'All' || amountFilter.value !== 0
if (isLoading) {
return <UnreconciledTransactionsLoadingState />
}
return <div className="space-y-1">
<div className="flex py-2 w-full gap-2">
<InputGroup variant='outline'>
<label className="sr-only">{_("Search transactions")}</label>
<InputGroupAddon>
<Search className="w-4 h-4 text-ink-gray-5" />
</InputGroupAddon>
<Input
placeholder={_("Search")}
// type='search'
variant='outline'
onChange={onSearchChange}
defaultValue={search}
ref={inputRef}
/>
<InputGroupAddon align='inline-end'>
<InputGroupText>{results?.length} {_(results?.length === 1 ? "result" : "results")}</InputGroupText>
</InputGroupAddon>
</InputGroup>
<div>
<label className="sr-only">{_("Filter by amount")}</label>
<CurrencyInput
groupSeparator={groupSeparator}
decimalSeparator={decimalSeparator}
placeholder={`${currencySymbol}0${decimalSeparator}00`}
decimalsLimit={2}
value={amountFilter.stringValue}
maxLength={12}
decimalScale={2}
prefix={currencySymbol}
onValueChange={(v, _n, values) => {
// If the input ends with a decimal or a decimal with trailing zeroes, store the string since we need the user to be able to type the decimals.
// When the user eventually types the decimals or blurs out, the value is formatted anyway.
// Otherwise store the float value
// Check if the value ends with a decimal or a decimal with trailing zeroes
const isDecimal = v?.endsWith(decimalSeparator) || v?.endsWith(decimalSeparator + '0')
const newValue = isDecimal ? v : values?.float ?? ''
const nextAmountFilter = {
value: Number(newValue),
stringValue: newValue
}
const hasAmountFilterChanged = amountFilter.value !== nextAmountFilter.value || amountFilter.stringValue !== nextAmountFilter.stringValue
setAmountFilter(nextAmountFilter)
// `onValueChange` also fires on blur; avoid clearing selected transaction unless filter value actually changed.
if (hasAmountFilterChanged) {
onFilterChange()
}
}}
// @ts-expect-error - CurrencyInputProps doesn't have a variant prop but Input does
variant={"outline"}
customInput={Input}
/>
</div>
<div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size='md' className="min-w-32 text-start">
{typeFilter === 'All' ? <DollarSign className="text-ink-gray-5" /> : typeFilter === 'Debits' ? <ArrowUpRight className="text-ink-red-3" /> : <ArrowDownRight className="text-ink-green-3" />}
{_(typeFilter)}
<ChevronDown className="text-ink-gray-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => onTypeFilterChange('All')}><DollarSign /> {_("All")}</DropdownMenuItem>
<DropdownMenuItem onClick={() => onTypeFilterChange('Debits')}><ArrowUpRight className="text-ink-red-3" /> {_("Debits")}</DropdownMenuItem>
<DropdownMenuItem onClick={() => onTypeFilterChange('Credits')}><ArrowDownRight className="text-ink-green-3" /> {_("Credits")}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{error && <ErrorBanner error={error} />}
<OlderUnreconciledTransactionsBanner />
{results.length === 0 && <NoTransactionsFoundBanner
onClearFilters={hasFilters ? onClearFilters : undefined}
text={hasFilters ? _("No transactions found for the given filters.") : _("No unreconciled transactions found")}
description={hasFilters ? _("Try adjusting your search or filter criteria.") : _("Import your bank statement to get started.")} />}
<Virtuoso
data={results}
itemContent={(_index, transaction) => (
<UnreconciledTransactionItem transaction={transaction} />
)}
style={{ minHeight: Math.max(contentHeight - 80, 400) }}
totalCount={results?.length}
/>
</div>
}
const NoTransactionsFoundBanner = ({ text, description, onClearFilters }: { text: string, description?: string, onClearFilters?: () => void }) => {
return <Empty>
<EmptyMedia>
<ListIcon />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>{text}</EmptyTitle>
{description && <EmptyDescription>{description}</EmptyDescription>}
</EmptyHeader>
<EmptyContent>
{onClearFilters ? <Button type='button' size='sm' variant='subtle' onClick={onClearFilters}>Clear Filters</Button> :
<Button type='button' asChild size='sm' variant='subtle'>
<Link to="/statement-importer">
{_("Import Bank Statement")}
</Link>
</Button>}
</EmptyContent>
</Empty>
}
const UnreconciledTransactionsLoadingState = () => {
return <div className="flex flex-col gap-2 py-2">
<div className="flex items-center gap-2 pb-2">
<Skeleton className="h-9.5 w-full" />
<Skeleton className="h-9.5 min-w-36" />
<Skeleton className="h-9.5 min-w-32" />
</div>
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} className="h-16 w-full" />
))}
</div>
}
const UnreconciledTransactionItem = ({ transaction }: { transaction: UnreconciledTransaction }) => {
const selectedBank = useAtomValue(selectedBankAccountAtom)
const [selectedTransaction, setSelectedTransaction] = useAtom(bankRecSelectedTransactionAtom(selectedBank?.name || ''))
const { amount, isWithdrawal } = useIsTransactionWithdrawal(transaction)
const isSelected = selectedTransaction?.some((t) => t.name === transaction.name)
const currency = transaction.currency ?? selectedBank?.account_currency ?? getCompanyCurrency(selectedBank?.company ?? '')
const handleSelectTransaction = (event: React.MouseEvent<HTMLDivElement>) => {
// If the user is pressing the shift key, add/remove the transaction from the selected transactions
if (event.shiftKey) {
setSelectedTransaction(isSelected ? selectedTransaction.filter((t) => t.name !== transaction.name) : [...selectedTransaction, transaction])
} else {
setSelectedTransaction([transaction])
}
}
return <div className="py-1">
<div className={cn("border outline rounded-md p-2 mx-0.5 cursor-pointer transition-[color,box-shadow, bg] hover:bg-surface-gray-1",
isSelected ? "bg-surface-gray-1 border-outline-gray-5 outline-outline-gray-5" : "border-outline-gray-2 outline-none"
)}
role='button'
tabIndex={0}
onClick={handleSelectTransaction}>
<div className="flex justify-between items-start w-full">
<div className="space-y-1 overflow-hidden whitespace-pre-wrap">
<div className="flex items-center gap-1">
<span className="font-medium text-sm">{formatDate(transaction.date)}</span>
{transaction.transaction_type &&
<Badge theme="blue">{transaction.transaction_type}</Badge>}
{transaction.reference_number && <Badge
title={transaction.reference_number}
className="max-w-[300px] text-ellipsis"
>
{_("Ref")}: {transaction.reference_number}</Badge>}
{transaction.matched_transaction_rule && <Badge
theme="violet"
title={_("Matched by rule")}>
<ZapIcon className="w-4 h-4" /> {transaction.matched_transaction_rule}</Badge>}
</div>
<span className="text-sm wrap-anywhere" title={transaction.description}>{transaction.description}</span>
</div>
<div className="gap-1 flex flex-col items-end min-w-36 h-full text-end">
{isWithdrawal ? <ArrowUpRight className="size-5 text-ink-red-3" /> : <ArrowDownRight className="size-5 text-ink-green-3" />}
{amount && amount > 0 && <span className="font-semibold font-numeric text-base">{formatCurrency(amount, currency)}</span>}
{amount !== transaction.unallocated_amount && <span className="text-xs leading-normal text-ink-gray-5">{formatCurrency(transaction.unallocated_amount, currency)} {_("Unallocated")}</span>}
</div>
</div>
</div>
</div>
}
const VouchersSection = ({ contentHeight }: { contentHeight: number }) => {
const selectedBank = useAtomValue(selectedBankAccountAtom)
const selectedTransactions = useAtomValue(bankRecSelectedTransactionAtom(selectedBank?.name || ''))
if (selectedTransactions.length === 0) {
return <Empty>
<EmptyMedia>
<ReceiptIcon />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>{_("Select a transaction to match and reconcile with vouchers")}</EmptyTitle>
</EmptyHeader>
</Empty>
}
if (selectedTransactions.length > 1) {
return <OptionsForMultipleTransactions transactions={selectedTransactions} />
}
return <div style={{ minHeight: contentHeight }} className="mt-2">
<OptionsForSingleTransaction transaction={selectedTransactions[0]} contentHeight={contentHeight} />
</div>
}
const useKeyboardShortcuts = () => {
const setTransferModalOpen = useSetAtom(bankRecTransferModalAtom)
const setRecordPaymentModalOpen = useSetAtom(bankRecRecordPaymentModalAtom)
const setRecordJournalEntryModalOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
useHotkeys('meta+p', () => {
//
setRecordPaymentModalOpen(true)
}, {
enabled: true,
enableOnFormTags: false,
preventDefault: true
})
useHotkeys('meta+b', () => {
//
setRecordJournalEntryModalOpen(true)
}, {
enabled: true,
enableOnFormTags: false,
preventDefault: true
})
useHotkeys('meta+i', () => {
//
setTransferModalOpen(true)
}, {
enabled: true,
enableOnFormTags: false,
preventDefault: true
})
return {
setTransferModalOpen,
setRecordPaymentModalOpen,
setRecordJournalEntryModalOpen
}
}
const OptionsForMultipleTransactions = ({ transactions }: { transactions: UnreconciledTransaction[] }) => {
const { setTransferModalOpen, setRecordPaymentModalOpen, setRecordJournalEntryModalOpen } = useKeyboardShortcuts()
return <div className="flex flex-col py-4">
<Card className="gap-2">
<CardHeader>
<CardTitle>
<div className="flex items-center justify-between">
<span className="text-md font-medium">{transactions.length} {_(transactions.length === 1 ? _("transaction selected") : _("transactions selected"))}</span>
<span className="text-md font-medium font-numeric">
{formatCurrency(transactions.reduce((acc, transaction) => acc + (transaction.unallocated_amount ?? 0), 0), transactions[0].currency ?? '')}
</span>
</div>
</CardTitle>
</CardHeader>
<CardContent>
<SelectedTransactionsTable />
<CardAction className="mt-4 justify-self-center">
<div className="flex gap-3 justify-center">
<TooltipProvider>
<div className="flex gap-4 justify-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
size='md'
aria-label={_("Record a bank journal entry for expenses, income or split transactions")}
onClick={() => setRecordJournalEntryModalOpen(true)}>
<Landmark /> {_("Bank Entry")}
</Button>
</TooltipTrigger>
<TooltipContent>
{_("Record a journal entry for expenses, income or split transactions")}
<KbdGroup className="ms-2">
<Kbd><KeyboardMetaKeyIcon /></Kbd>
<Kbd>B</Kbd>
</KbdGroup>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='outline'
size='md'
aria-label={_("Record a payment entry against a customer or supplier")}
onClick={() => setRecordPaymentModalOpen(true)}>
<Receipt /> {_("Record Payment")}
</Button>
</TooltipTrigger>
<TooltipContent>
{_("Record a payment entry against a customer or supplier")}
<KbdGroup className="ms-2">
<Kbd><KeyboardMetaKeyIcon /></Kbd>
<Kbd>P</Kbd>
</KbdGroup>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='outline'
size='md'
aria-label={_("Record an internal transfer to another bank/credit card/cash account")}
onClick={() => setTransferModalOpen(true)}>
<ArrowRightLeft /> {_("Transfer")}
</Button>
</TooltipTrigger>
<TooltipContent>
{_("Record an internal transfer to another bank/credit card/cash account")}
<KbdGroup className="ms-2">
<Kbd><KeyboardMetaKeyIcon /></Kbd>
<Kbd>I</Kbd>
</KbdGroup>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div>
</CardAction>
</CardContent>
</Card>
</div>
}
const OptionsForSingleTransaction = ({ transaction, contentHeight }: { transaction: UnreconciledTransaction, contentHeight: number }) => {
const { setTransferModalOpen, setRecordPaymentModalOpen, setRecordJournalEntryModalOpen } = useKeyboardShortcuts()
return <div className="flex flex-col gap-3">
<TooltipProvider>
<div className="flex items-center justify-between pt-2">
<div className="flex gap-4 justify-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='outline'
size='md'
aria-label={_("Record a payment entry against a customer or supplier")}
onClick={() => setRecordPaymentModalOpen(true)}>
<Receipt /> {_("Record Payment")}
</Button>
</TooltipTrigger>
<TooltipContent>
{_("Record a payment entry against a customer or supplier")}
<KbdGroup className="ms-2">
<Kbd><KeyboardMetaKeyIcon /></Kbd>
<Kbd>P</Kbd>
</KbdGroup>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='outline'
size='md'
aria-label={_("Record a bank journal entry for expenses, income or split transactions")}
onClick={() => setRecordJournalEntryModalOpen(true)}>
<Landmark /> {_("Bank Entry")}
</Button>
</TooltipTrigger>
<TooltipContent>
{_("Record a journal entry for expenses, income or split transactions")}
<KbdGroup className="ms-2">
<Kbd><KeyboardMetaKeyIcon /></Kbd>
<Kbd>B</Kbd>
</KbdGroup>
</TooltipContent>
</Tooltip>
<Tooltip >
<TooltipTrigger asChild>
<Button
variant='outline'
size='md'
aria-label={_("Record an internal transfer to another bank/credit card/cash account")}
onClick={() => setTransferModalOpen(true)}>
<ArrowRightLeft /> {_("Transfer")}
</Button>
</TooltipTrigger>
<TooltipContent>
{_("Record an internal transfer to another bank/credit card/cash account")}
<KbdGroup className="ms-2">
<Kbd><KeyboardMetaKeyIcon /></Kbd>
<Kbd>I</Kbd>
</KbdGroup>
</TooltipContent>
</Tooltip>
</div>
<MatchFilters />
</div>
</TooltipProvider>
{transaction.matched_transaction_rule && <RuleAction transaction={transaction} />}
<VouchersForTransaction transaction={transaction} contentHeight={contentHeight} />
</div>
}
const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) => {
const { data: rule } = useGetRuleForTransaction(transaction)
const setTransferModalOpen = useSetAtom(bankRecTransferModalAtom)
const setRecordPaymentModalOpen = useSetAtom(bankRecRecordPaymentModalAtom)
const setRecordJournalEntryModalOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
if (!rule) {
return null
}
const getActionIcon = () => {
switch (rule.classify_as) {
case "Bank Entry":
return <Landmark />
case "Payment Entry":
return <Receipt className="w-6 h-6" />
case "Transfer":
return <ArrowRightLeft />
default:
return <ZapIcon />
}
}
const getActionStyles = () => {
switch (rule.classify_as) {
case "Bank Entry":
return {
border: "border-outline-blue-3",
bg: "bg-surface-blue-1/50",
text: "text-ink-blue-4",
theme: "blue",
}
case "Payment Entry":
return {
border: "border-outline-green-3",
bg: "bg-surface-green-1/50",
text: "text-ink-green-4",
theme: "green",
}
case "Transfer":
return {
border: "border-outline-violet-3",
bg: "bg-surface-violet-2/50",
text: "text-ink-violet-4",
theme: "violet",
}
default:
return {
border: "border-outline-amber-3",
bg: "bg-surface-amber-1/50",
text: "text-ink-amber-4",
theme: "orange",
}
}
}
const handleActionClick = () => {
switch (rule.classify_as) {
case "Bank Entry":
setRecordJournalEntryModalOpen(true)
break
case "Payment Entry":
setRecordPaymentModalOpen(true)
break
case "Transfer":
setTransferModalOpen(true)
break
}
}
const getActionDescription = () => {
switch (rule.classify_as) {
case "Bank Entry":
return _("Create a journal entry for expenses, income or split transactions")
case "Payment Entry":
return _("Record a payment entry against a customer or supplier")
case "Transfer":
return _("Record an internal transfer to another bank/credit card/cash account")
default:
return _("Create a new entry based on the rule")
}
}
useHotkeys('meta+r', () => {
//
handleActionClick()
}, {
enabled: true,
enableOnFormTags: false,
preventDefault: true
})
const styles = getActionStyles()
return (
<Card className={`border ${styles.border} ${styles.bg} shadow-sm hover:shadow-md transition-all duration-200`}>
<CardHeader className="pb-0">
<CardTitle className="flex justify-between items-center gap-3">
<div className="flex items-center gap-3">
<div className={`px-2.5 rounded-lg ${styles.bg} ${styles.text}`}>
{getActionIcon()}
</div>
<div className="flex flex-col gap-0.5">
<span className="font-semibold text-lg">{rule.rule_name}</span>
<span className="text-sm text-ink-gray-5 font-normal">
{rule.rule_description || _("Rule matched based on transaction description and other criteria.")}
</span>
</div>
</div>
<div className="flex items-center gap-0.5">
<Badge size='lg'
theme={rule.classify_as === "Bank Entry" ? "blue" : rule.classify_as === "Payment Entry" ? "green" : rule.classify_as === "Transfer" ? "violet" : "orange"}>
{rule.classify_as}
</Badge>
</div>
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-3">
<div className="flex items-center justify-between p-2 bg-surface-white rounded-lg border border-outline-gray-1">
<div className="flex items-center gap-2">
<BadgeCheck className="w-4 h-4 text-ink-green-3" />
<span className="text-sm font-medium text-ink-gray-8">{_("Recommended Action")}</span>
</div>
<Badge variant="ghost" theme={styles.theme as "blue" | "green" | "violet" | "orange"}>
{_("Priority")} {rule.priority}
</Badge>
</div>
<div className="space-y-2">
{rule.account && (
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-ink-gray-8">{_("Account")}:</span>
<span className="text-sm">{rule.account}</span>
</div>
)}
{rule.party_type && rule.party && (
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-ink-gray-8">{_("Party")}:</span>
<span className="text-sm">{rule.party} ({_(rule.party_type)})</span>
</div>
)}
</div>
<div className="pt-1">
<Button
onClick={handleActionClick}
className={`w-full`}
theme={styles.theme as "blue" | "green" | "violet"}
size="md"
>
{getActionIcon()}
<span>{_("Create")} {rule.classify_as}</span>
</Button>
<p className="text-sm text-ink-gray-5 mt-2 text-center leading-relaxed">
{getActionDescription()}
</p>
</div>
</CardContent>
</Card>
)
}
const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: UnreconciledTransaction, contentHeight: number }) => {
const { data: vouchers, isLoading, error } = useGetVouchersForTransaction(transaction)
if (error) {
return <ErrorBanner error={error} />
}
if (isLoading) {
return <div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-sm text-ink-gray-5">
<Separator className="flex-1" />
<span>or</span>
<Separator className="flex-1" />
</div>
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
}
return <div className="relative space-y-2">
<div className="flex items-center gap-2 text-sm text-ink-gray-5">
<Separator className="flex-1" />
<span>or</span>
<Separator className="flex-1" />
</div>
{vouchers?.message.length === 0 && <Empty className="my-4">
<EmptyMedia>
<ReceiptIcon />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>{_("No vouchers found for this transaction")}</EmptyTitle>
</EmptyHeader>
</Empty>}
<Virtuoso
data={vouchers?.message}
itemContent={(index, voucher) => (
<VoucherItem voucher={voucher} index={index} />
)}
style={{ height: contentHeight }}
totalCount={vouchers?.message.length}
/>
</div >
}
const VoucherItem = ({ voucher, index }: { voucher: LinkedPayment, index: number }) => {
const selectedBank = useAtomValue(selectedBankAccountAtom)
const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBank?.name || ''))
const { amountMatches, postingDateMatches, referenceDateMatches, referenceMatchesFull, referenceMatchesPartial, isSuggested } = useMemo(() => {
const transaction = selectedTransaction?.[0]
// We need to check if the following details match:
// Amount
// Date
// Reference/Description: Full or partial
// Whether this is suggested or not - depends on the above scores
const amountMatches = voucher.paid_amount === transaction?.unallocated_amount
const postingDateMatches = voucher.posting_date === transaction?.date
const referenceDateMatches = voucher.reference_date === transaction?.date
const referenceMatchesFull = voucher.reference_no === transaction?.reference_number || voucher.reference_no === transaction?.description
const referenceMatchesPartial = transaction?.reference_number?.includes(voucher.reference_no) || transaction?.description?.includes(voucher.reference_no)
const isSuggested = amountMatches && (postingDateMatches || referenceDateMatches || referenceMatchesPartial) && index === 0
return { isSelected: false, amountMatches, postingDateMatches, referenceDateMatches, referenceMatchesFull, referenceMatchesPartial, isSuggested: isSuggested }
}, [voucher, selectedTransaction, index])
const { reconcileTransaction, loading } = useReconcileTransaction()
const onClick = () => {
if (!selectedTransaction) {
return
}
reconcileTransaction(selectedTransaction[0], voucher)
}
return <div className="py-1 px-1">
<div
className={cn("border outline overflow-hidden relative rounded-md p-2",
isSuggested ? "border-outline-green-4 bg-surface-green-1/40 outline-outline-green-4" : "border-outline-gray-2 outline-transparent"
)}
>
<div className="flex justify-between items-end gap-2">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Badge size='md'>{_(voucher.doctype)}</Badge>
<a target="_blank"
href={`/desk/${slug(voucher.doctype)}/${voucher.name}`}
className="underline underline-offset-2 text-base"
>{voucher.name}</a>
</div>
{voucher.party && voucher.party_type && <div className="flex items-center gap-1.5 text-base">
<User size='18px' />
<span>{_(voucher.party_type)}</span>
<a target="_blank"
href={`/desk/${slug(voucher.party_type)}/${voucher.party}`}
className="underline underline-offset-2"
>{voucher.party}</a>
</div>}
<TooltipProvider>
<div className="flex items-start gap-8 py-0.5">
<div className="flex flex-col gap-1 min-w-24">
<div className="text-xs text-ink-gray-6">{_("Amount")}</div>
<div className="text-base font-medium flex items-center gap-1">{formatCurrency(voucher.paid_amount, voucher.currency)} {amountMatches ? <MatchBadge matchType="full" label={_("Amount matches the selected transaction")} /> : <MatchBadge matchType="none" label={_("Amount does not match the selected transaction")} />}</div>
</div>
<div className="flex flex-col gap-1 min-w-24">
<div className="text-xs text-ink-gray-6">{_("Posted On")}</div>
<div className="text-base font-medium flex items-center gap-1">{formatDate(voucher.posting_date)} {postingDateMatches ? <MatchBadge matchType="full" label={_("Posting date matches the selected transaction")} /> : <MatchBadge matchType="none" label={_("Posting date does not match the selected transaction")} />}</div>
</div>
{voucher.reference_date && <div className="flex flex-col gap-1 min-w-24">
<div className="text-xs text-ink-gray-6">{_("Reference Date")}</div>
<div className="text-base font-medium flex items-center gap-1">{formatDate(voucher.reference_date)} {referenceDateMatches ? <MatchBadge matchType="full" label={_("Reference date matches the selected transaction")} /> : <MatchBadge matchType="none" label={_("Reference date does not match the selected transaction")} />}</div>
</div>}
</div>
{voucher.reference_no && <div className="flex items-start gap-1">
<span className="text-p-base">
{voucher.reference_no}
&nbsp;&nbsp;
<Tooltip>
<TooltipTrigger>
<Badge theme={referenceMatchesFull ? "green" : referenceMatchesPartial ? "orange" : "red"} variant={referenceMatchesFull || referenceMatchesPartial ? "subtle" : "outline"}>
{referenceMatchesFull ? `${_("Complete Match")}` : referenceMatchesPartial ? `${_("Partial Match")}` : `${_("No Match")}`}</Badge>
</TooltipTrigger>
<TooltipContent side="top">
{referenceMatchesFull ? `${_("Reference matches the selected transaction")}` : referenceMatchesPartial ? `${_("Reference matches the selected transaction partially")}` : `${_("Reference does not match the selected transaction")}`}
</TooltipContent>
</Tooltip>
</span>
</div>}
</TooltipProvider>
</div>
<div>
<Button
variant={isSuggested || amountMatches ? "solid" : "outline"}
theme={isSuggested || amountMatches ? "green" : "gray"}
onClick={onClick} disabled={loading}>{loading ? <><Loader2 className="w-4 h-4 animate-spin" /> {_("Reconciling")}...</> : `${_("Reconcile")}`}</Button>
</div>
</div>
{isSuggested && <div className="absolute top-1.5 end-2 flex items-center gap-1 justify-center">
<Badge theme="green" variant="subtle" size='md'>{_("Suggested")}</Badge>
</div>}
</div>
</div>
}
const MatchBadge = ({ matchType, label }: { matchType: 'full' | 'partial' | 'none', label: string }) => {
return <Tooltip>
<TooltipTrigger>
{matchType === 'full' ? <BadgeCheck className="text-ink-white fill-surface-green-5 size-4" /> : matchType === 'partial' ?
<Badge theme="orange" variant="subtle">{_("Partial Match")}</Badge> :
<XCircle className="text-ink-red-4 size-4" />}
</TooltipTrigger>
<TooltipContent>
{label}
</TooltipContent>
</Tooltip>
}
const OlderUnreconciledTransactionsBanner = () => {
// A banner to show when there are unreconciled transactions for the given bank account before the current selected date
const [dates, setDates] = useAtom(bankRecDateAtom)
const selectedBank = useAtomValue(selectedBankAccountAtom)
const { data } = useFrappeGetCall<{
message: {
count: number,
oldest_date: string
}
}>("erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_older_unreconciled_transactions", {
bank_account: selectedBank?.name,
from_date: dates.fromDate,
}, undefined, {
revalidateOnFocus: false,
})
if (data && data.message.count > 0) {
return <Alert theme='gray' variant='subtle'>
<AlertCircleIcon />
<div className="flex justify-between items-center gap-1.5">
<div>
<AlertTitle> {data.message.count > 1 ? (
<span>{_("There are {0} unreconciled transactions before {1}.", [data.message.count.toString(), formatDate(dates.fromDate)])}</span>
) : (
<span>{_("There is one unreconciled transaction before {0}.", [formatDate(dates.fromDate)])}</span>
)}</AlertTitle>
<AlertDescription className="flex justify-between text-balance">
{_("The opening balance might not match your bank statement. Would you like to reconcile them?")}
</AlertDescription>
</div>
<div>
<Button
size='sm'
type='button'
theme='gray'
variant='outline'
onClick={() => setDates({ fromDate: data.message.oldest_date, toDate: dates.toDate })}>
<span>{data.message.count > 1 ? _("View older transactions") : _("View older transaction")}</span>
<ArrowRightIcon />
</Button>
</div>
</div>
</Alert>
}
return null
}
export default MatchAndReconcile

View File

@@ -1,93 +0,0 @@
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import _ from '@/lib/translate'
import { FilterIcon } from 'lucide-react'
import { bankRecMatchFilters } from './bankRecAtoms'
import { useAtom } from 'jotai'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { useFrappeGetCall } from 'frappe-react-sdk'
import { scrub } from '@/lib/frappe'
import { useMemo } from 'react'
const MatchFilters = () => {
return (
<Popover>
<Tooltip>
<PopoverTrigger asChild>
<TooltipTrigger asChild>
<Button size='md' isIconButton variant='outline' aria-label={_("Configure match filters for vouchers")}>
<FilterIcon />
</Button>
</TooltipTrigger>
</PopoverTrigger>
<TooltipContent>
{_("Configure match filters for vouchers")}
</TooltipContent>
</Tooltip>
<PopoverContent>
<div className="flex flex-col gap-4">
<ToggleSwitch label={_("Show Only Exact Amount")} id="exact_match" />
<Separator />
<MatchFiltersContent />
<ToggleSwitch label={_("Bank Transaction")} id="bank_transaction" />
</div>
</PopoverContent>
</Popover>
)
}
const MatchFiltersContent = () => {
const { data } = useFrappeGetCall<{ message: string[] }>("erpnext.accounts.doctype.bank_transaction.bank_transaction.get_doctypes_for_bank_reconciliation", undefined,
"bank_rec_doctypes", {
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
}
)
const doctypes = useMemo(() => {
const STANDARD_DOCTYPES = ["Payment Entry", "Journal Entry", "Purchase Invoice", "Sales Invoice"]
if (data) {
return data.message.map(doctype => ({
label: doctype,
id: scrub(doctype),
}))
} else {
return STANDARD_DOCTYPES.map(doctype => ({
label: doctype,
id: scrub(doctype),
}))
}
}, [data])
return (
<div className="flex flex-col gap-4">
{doctypes.map((doctype) => (
<ToggleSwitch key={doctype.id} label={doctype.label} id={doctype.id} />
))}
</div>
)
}
const ToggleSwitch = ({ label, id }: { label: string, id: string }) => {
const [matchFilters, setMatchFilters] = useAtom(bankRecMatchFilters)
return <div className="flex items-center space-x-2">
<Switch id={id} checked={matchFilters.includes(id)} onCheckedChange={(checked) => {
if (checked) {
setMatchFilters([...matchFilters, id])
} else {
setMatchFilters(matchFilters.filter(filter => filter !== id))
}
}} />
<Label htmlFor={id}>{label}</Label>
</div>
}
export default MatchFilters

View File

@@ -1,10 +0,0 @@
import { Paragraph } from "@/components/ui/typography"
import { cn } from "@/lib/utils"
import { ReactNode } from "react"
export const MissingFiltersBanner = ({ text, className }: { text: ReactNode, className?: string }) => {
return <div className={cn("min-h-[50vh] flex items-center justify-center", className)}>
<Paragraph>{text}</Paragraph>
</div>
}

View File

@@ -1,89 +0,0 @@
import { Button } from "@/components/ui/button"
import ErrorBanner from "@/components/ui/error-banner"
import { Form } from "@/components/ui/form"
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
import _ from "@/lib/translate"
import { BankTransactionRule } from "@/types/Accounts/BankTransactionRule"
import { useFrappeCreateDoc } from "frappe-react-sdk"
import { toast } from "sonner"
import { RuleForm } from "./RuleForm"
import { useForm } from "react-hook-form"
import { SettingsPanelHeader, SettingsPanelDescription, SettingsPanelTitle, SettingsPanelContent } from "@/components/ui/settings-dialog"
import { useHotkeys } from "react-hotkeys-hook"
type Props = {
onCreate: VoidFunction
}
const CreateNewRule = ({ onCreate }: Props) => {
const currentCompany = useCurrentCompany()
const form = useForm<BankTransactionRule>({
defaultValues: {
rule_name: "",
company: currentCompany,
rule_description: "",
transaction_type: "Any",
classify_as: 'Bank Entry',
bank_entry_type: "Single Account",
description_rules: [{
check: "Contains",
}]
}
})
const { createDoc, loading, error } = useFrappeCreateDoc<BankTransactionRule>()
const onSubmit = (data: BankTransactionRule) => {
createDoc("Bank Transaction Rule", data)
.then(() => {
toast.success(_("Rule created successfully"))
onCreate()
})
}
useHotkeys('meta+s', () => {
form.handleSubmit(onSubmit)()
}, {
enabled: true,
preventDefault: true,
enableOnFormTags: true
})
return (
<>
<SettingsPanelHeader
actions={
<div className="flex items-center gap-2">
<Button variant='outline' size='md' type='button' onClick={() => onCreate()}>{_("Cancel")}</Button>
<Button type='submit' form='rule-form' size='md' disabled={loading}>
{_("Save")}
</Button>
</div>
}
>
<SettingsPanelTitle>
{_("New Rule")}
</SettingsPanelTitle>
<SettingsPanelDescription>
{_("Create a new rule to automatically classify transactions.")}
</SettingsPanelDescription>
</SettingsPanelHeader>
<SettingsPanelContent className="px-0">
<Form {...form}>
<form id='rule-form' onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col justify-between h-full overflow-y-auto px-2">
<div className="flex flex-col gap-4">
{error && <ErrorBanner error={error} />}
<RuleForm />
</div>
</form>
</Form>
</SettingsPanelContent>
</>
)
}
export default CreateNewRule

View File

@@ -1,101 +0,0 @@
import { Button } from "@/components/ui/button"
import ErrorBanner from "@/components/ui/error-banner"
import { Form } from "@/components/ui/form"
import _ from "@/lib/translate"
import { BankTransactionRule } from "@/types/Accounts/BankTransactionRule"
import { FrappeError, useFrappeGetDoc, useFrappeUpdateDoc } from "frappe-react-sdk"
import { toast } from "sonner"
import { RuleForm } from "./RuleForm"
import { useForm } from "react-hook-form"
import { Skeleton } from "@/components/ui/skeleton"
import { SettingsPanelContent, SettingsPanelDescription, SettingsPanelHeader, SettingsPanelTitle } from "@/components/ui/settings-dialog"
import { useHotkeys } from "react-hotkeys-hook"
type Props = {
onClose: VoidFunction,
ruleID: string
}
const EditRule = ({ onClose, ruleID }: Props) => {
const { data: rule, isValidating, error, mutate } = useFrappeGetDoc<BankTransactionRule>("Bank Transaction Rule", ruleID, undefined, {
revalidateOnMount: true
})
const { updateDoc, loading, error: updateError } = useFrappeUpdateDoc<BankTransactionRule>()
const onSubmit = (data: BankTransactionRule) => {
updateDoc("Bank Transaction Rule", ruleID, data)
.then(() => {
toast.success(_("Rule updated."))
mutate()
onClose()
})
}
return <>
<SettingsPanelHeader
actions={
<div className="flex items-center gap-2">
<Button variant='outline' size='md' type='button' onClick={() => onClose()}>{_("Cancel")}</Button>
<Button type='submit' form='rule-form' size='md' disabled={isValidating || loading}>
{_("Save")}
</Button>
</div>
}
>
<SettingsPanelTitle>
{rule?.rule_name}
</SettingsPanelTitle>
<SettingsPanelDescription className="sr-only">
{_("Edit this rule")}
</SettingsPanelDescription>
</SettingsPanelHeader>
<SettingsPanelContent className="px-0">
{isValidating && <div className="px-4 flex flex-col gap-4 h-full">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>}
{error && <div className="px-4 flex flex-col gap-4 h-full">
<ErrorBanner error={error} />
</div>}
{rule && <EditRuleForm rule={rule} onSubmit={onSubmit} error={updateError} />}
</SettingsPanelContent>
</>
}
const EditRuleForm = ({ rule, onSubmit, error }: { rule: BankTransactionRule, onSubmit: (data: BankTransactionRule) => void, error?: FrappeError | null }) => {
const form = useForm<BankTransactionRule>({
defaultValues: {
...rule,
}
})
useHotkeys('meta+s', () => {
form.handleSubmit(onSubmit)()
}, {
enabled: true,
preventDefault: true,
enableOnFormTags: true
})
return (
<Form {...form}>
<form id='rule-form' onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col justify-between h-full overflow-y-auto px-2">
<div className="flex flex-col gap-4">
{error && <ErrorBanner error={error} />}
<RuleForm isEdit />
</div>
</form>
</Form>
)
}
export default EditRule

View File

@@ -1,799 +0,0 @@
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Dialog, DialogTitle, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog"
import { FormField, FormItem, FormLabel, FormControl } from "@/components/ui/form"
import { AccountFormField, CurrencyFormField, DataField, LinkFormField, PartyTypeFormField, SelectFormField, SmallTextField } from "@/components/ui/form-elements"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { SelectItem } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { H4, Paragraph } from "@/components/ui/typography"
import { today } from "@/lib/date"
import _ from "@/lib/translate"
import { cn } from "@/lib/utils"
import { BankTransactionRule } from "@/types/Accounts/BankTransactionRule"
import { BankTransactionRuleAccounts } from "@/types/Accounts/BankTransactionRuleAccounts"
import { FrappeConfig, FrappeContext } from "frappe-react-sdk"
import { ArrowDownRight, ArrowDownUp, ArrowRightLeftIcon, ArrowUpRight, LandmarkIcon, Plus, PlusCircleIcon, ReceiptIcon, Settings, Trash2 } from "lucide-react"
import { ChangeEvent, useCallback, useContext, useMemo, useRef, useState } from "react"
import { useFieldArray, useFormContext, useWatch } from "react-hook-form"
export const RuleForm = ({ isEdit = false }: { isEdit?: boolean }) => {
return <div className="flex flex-col gap-4">
<DataField
name='rule_name'
label={_("Rule Name")}
disabled={isEdit}
isRequired
inputProps={{
maxLength: 140,
disabled: isEdit,
placeholder: _("Bank Charges, Salary, etc."),
autoFocus: true,
className: "dark:disabled:bg-surface-gray-2"
}}
rules={{
required: _("Rule name is required")
}}
/>
<CompanySelector />
<SmallTextField
name='rule_description'
label={_("Rule Description")}
inputProps={{
placeholder: _("Any debit transaction with the keyword 'Bank Fee'.")
}}
/>
<TransactionTypeSelector />
<div className="grid grid-cols-2 gap-2 pt-1">
<CurrencyFormField
name='min_amount'
label={_("Minimum Amount")}
/>
<CurrencyFormField
name='max_amount'
label={_("Maximum Amount")}
/>
</div>
<DescriptionRules />
<Separator />
<RuleAction />
</div>
}
const CompanySelector = () => {
const { setValue } = useFormContext<BankTransactionRule>()
return <LinkFormField
name='company'
label={_("Company")}
doctype="Company"
isRequired
rules={{
required: _("Company is required"),
onChange: () => {
setValue('account', '')
}
}}
/>
}
/** Component to render a radio group as a toggle group with options for All, Withdrawal, Deposit */
const TransactionTypeSelector = () => {
const { control } = useFormContext<BankTransactionRule>()
return (
<FormField
control={control}
name='transaction_type'
render={({ field }) => (
<FormItem className="space-y-1">
<FormLabel className="text-sm font-medium">
{_("Transaction Type")}<span className="text-ink-red-3">*</span>
</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
value={field.value}
className="grid grid-cols-3 gap-2 w-full"
>
<FormItem className="flex items-center">
<FormControl>
<RadioGroupItem
value="Any"
className="peer sr-only hidden"
/>
</FormControl>
<FormLabel
className={cn(
"w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-md border cursor-pointer transition-all hover:bg-surface-gray-1 hover:text-ink-gray-8",
"peer-data-[state=checked]:bg-surface-gray-7 peer-data-[state=checked]:text-ink-white peer-data-[state=checked]:border-outline-gray-5 peer-data-[state=checked]:hover:bg-surface-gray-7 peer-data-[state=checked]:hover:text-ink-white"
)}
>
<ArrowDownUp className="w-5 h-5" />
{_("All")}
</FormLabel>
</FormItem>
<FormItem className="flex items-center">
<FormControl>
<RadioGroupItem
value="Withdrawal"
className="peer sr-only hidden"
/>
</FormControl>
<FormLabel
className={cn(
"w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-md border cursor-pointer transition-all hover:bg-surface-gray-1 hover:text-ink-gray-8",
"peer-data-[state=checked]:bg-surface-red-5 peer-data-[state=checked]:text-white peer-data-[state=checked]:border-bg-surface-red-5 peer-data-[state=checked]:hover:bg-surface-red-5 peer-data-[state=checked]:hover:text-white"
)}
>
<ArrowUpRight className="w-5 h-5 peer-data-[state=checked]:text-ink-red-3" />
{_("Withdrawal")}
</FormLabel>
</FormItem>
<FormItem className="flex items-center">
<FormControl>
<RadioGroupItem
value="Deposit"
className="peer sr-only hidden"
/>
</FormControl>
<FormLabel
className={cn(
"w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-md border cursor-pointer transition-all hover:bg-surface-gray-1 hover:text-ink-gray-8",
"peer-data-[state=checked]:bg-surface-green-5 peer-data-[state=checked]:text-white peer-data-[state=checked]:border-surface-green-5 peer-data-[state=checked]:hover:bg-surface-green-5 peer-data-[state=checked]:hover:text-white"
)}
>
<ArrowDownRight className="w-5 h-5 peer-data-[state=checked]:text-white" />
{_("Deposit")}
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
)
}
const DescriptionRules = () => {
const { control } = useFormContext<BankTransactionRule>()
const { fields, append, remove } = useFieldArray({
control,
name: "description_rules"
})
const addRow = () => {
// @ts-expect-error - we don't need all fields here
append({ check: "Contains" })
}
return (
<div className="flex flex-col gap-2 pt-1">
<span className="text-sm font-medium">{_("Rules to match against the transaction description")} <span className="text-ink-red-3">*</span></span>
{fields.map((field, index) => (
<div key={field.id} className="flex w-full items-center gap-2">
<div className="min-w-36">
<SelectFormField
label={_("Type of check")}
hideLabel
name={`description_rules.${index}.check`}
rules={{
required: _("This is required")
}}>
<SelectItem value="Contains">{_("Contains")}</SelectItem>
<SelectItem value="Starts With">{_("Starts with")}</SelectItem>
<SelectItem value="Ends With">{_("Ends with")}</SelectItem>
<SelectItem value="Regex">{_("Regex")}</SelectItem>
</SelectFormField>
</div>
<div className="w-full">
<DataField
name={`description_rules.${index}.value`}
label={_("Value")}
hideLabel
inputProps={{
placeholder: _("Bank Fee, Salary, etc."),
}}
/>
</div>
<div>
<Button variant="ghost" theme='red' type='button' isIconButton onClick={() => remove(index)} disabled={fields.length === 1}>
<Trash2 />
</Button>
</div>
</div>
))}
<div>
<Button variant="outline" type='button' onClick={addRow}>
<PlusCircleIcon />
{_("Add Rule")}
</Button>
</div>
</div>
)
}
const RuleAction = () => {
const { control } = useFormContext<BankTransactionRule>()
const classify_as = useWatch({ control, name: "classify_as" })
const party_type = useWatch({ control, name: "party_type" })
const bank_entry_type = useWatch({ control, name: "bank_entry_type" })
const accountType = useMemo(() => {
if (classify_as === "Payment Entry") {
return party_type === "Supplier" ? ["Payable"] : ["Receivable"]
}
if (classify_as === "Transfer") {
return ["Bank", "Cash", "Temporary"]
}
return undefined
}, [classify_as, party_type])
return (
<div className="flex flex-col gap-4">
<H4 className="text-base text-ink-gray-7">{_("If rule matches, then:")}</H4>
<SelectFormField
name='classify_as'
isRequired
label={_("Suggest creating a")}
formDescription={_("This will just suggest creating a new entry, and will not automatically create it.")}
rules={{
required: _("This is required")
}}
>
<SelectItem value="Bank Entry"><LandmarkIcon /> {_("Bank Entry")}</SelectItem>
<SelectItem value="Payment Entry"><ReceiptIcon /> {_("Payment Entry")}</SelectItem>
<SelectItem value="Transfer"><ArrowRightLeftIcon /> {_("Transfer")}</SelectItem>
</SelectFormField>
{classify_as === "Bank Entry" && (<SelectFormField
name='bank_entry_type'
isRequired
label={_("Create Bank Entry against")}
rules={{
required: _("This is required")
}}
>
<SelectItem value="Single Account">{_("Single Account")}</SelectItem>
<SelectItem value="Multiple Accounts">{_("Multiple Accounts (Journal Template)")}</SelectItem>
</SelectFormField>)}
{classify_as === "Payment Entry" && (
<div className='grid grid-cols-4 gap-4'>
<div className="col-span-1">
<PartyTypeFormField
name='party_type'
label={_("Party Type")}
isRequired
inputProps={{
triggerProps: {
className: 'w-full'
},
}}
rules={{
required: "Party Type is required"
}}
/>
</div>
<div className="col-span-3">
<PartyField />
</div>
</div>
)}
{(((bank_entry_type === "Single Account" || !bank_entry_type) && classify_as === "Bank Entry") || classify_as !== "Bank Entry") && (<AccountFormField
name='account'
label={_("Account")}
isRequired
rules={{
required: _("Account is required")
}}
account_type={accountType}
/>)}
{bank_entry_type === "Multiple Accounts" && classify_as === "Bank Entry" && <MultipleAccountsSelection />}
</div>
)
}
const PartyField = () => {
const { control, setValue } = useFormContext<BankTransactionRule>()
const party_type = useWatch({
control,
name: `party_type`
})
const { call } = useContext(FrappeContext) as FrappeConfig
const company = useWatch({ control, name: 'company' })
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
// Fetch the party and account
if (event.target.value) {
call.get('erpnext.accounts.doctype.payment_entry.payment_entry.get_party_details', {
company: company,
party_type: party_type,
party: event.target.value,
date: today()
}).then((res) => {
setValue('account', res.message.party_account)
})
} else {
// Clear the account
setValue('account', '')
}
}
if (!party_type) {
return <DataField
name={`party`}
label={_("Party")}
isRequired
inputProps={{
disabled: true,
}}
/>
}
return <LinkFormField
name={`party`}
label={_("Party")}
rules={{
onChange
}}
doctype={party_type}
/>
}
const MultipleAccountsSelection = () => {
const { control } = useFormContext<BankTransactionRule>()
const accounts = useWatch({
control,
name: 'accounts'
}) ?? []
const [isConfigureAccountsModalOpen, setIsConfigureAccountsModalOpen] = useState(false)
return <div className="flex flex-col gap-2">
<div className="flex justify-between gap-2">
<Label>{_("Journal Template Accounts")}<span className="text-ink-red-3">*</span></Label>
<Button variant="outline" type="button" onClick={() => setIsConfigureAccountsModalOpen(true)}><Settings /> {_("Configure Accounts")}</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>{_("Account")}</TableHead>
<TableHead className="text-end">{_("Debit")}</TableHead>
<TableHead className="text-end">{_("Credit")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{accounts.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-center">
<div className="py-2 flex flex-col gap-2 items-center">
<span>{_("No accounts configured")}</span>
<Button variant="subtle" type="button" onClick={() => setIsConfigureAccountsModalOpen(true)}>{_("Configure Accounts")}</Button>
</div>
</TableCell>
</TableRow>
)}
{accounts.map((account, index) => (
<TableRow key={index}>
<TableCell>{account.account}</TableCell>
{index === accounts.length - 1 ? <TableCell className="text-end bg-surface-gray-1" colSpan={2}>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-ink-gray-5">{_("This is auto computed to balance the journal entry.")}</span>
</TooltipTrigger>
<TooltipContent>
{_("Based on the above entries, the balance amount (debit or credit) will be set for the last row to balance the journal entry.")}
</TooltipContent>
</Tooltip>
</TableCell> : <>
<TableCell className="font-numeric text-end"><AmountFormulaRenderer value={account.debit} /></TableCell>
<TableCell className="font-numeric text-end"><AmountFormulaRenderer value={account.credit} /></TableCell>
</>}
</TableRow>
))}
</TableBody>
</Table>
<ConfigureAccountsModal open={isConfigureAccountsModalOpen} onClose={() => setIsConfigureAccountsModalOpen(false)} />
</div>
}
const AmountFormulaRenderer = ({ value }: { value?: string }) => {
// If it's a string and cannot be a number, then show it as a formula
if (isNaN(Number(value))) {
let calculatedValue = "";
try {
calculatedValue = window.eval(`const transaction_amount = 200; ${value}`);
} catch (error: unknown) {
console.error(error);
calculatedValue = "Error";
}
const isComputationValid = !isNaN(Number(calculatedValue)) && calculatedValue !== undefined && calculatedValue !== null;
return <Tooltip>
<TooltipTrigger asChild>
<span className={cn("font-numeric text-end tabular-nums underline underline-offset-4", isComputationValid ? "" : "text-ink-red-3")}>{value}</span>
</TooltipTrigger>
<TooltipContent className={isComputationValid ? "" : "bg-surface-red-5"} arrowClassName={isComputationValid ? "" : "bg-surface-red-5 fill-surface-red-5"}>
<p className="text-sm">
{isComputationValid ? _("This is a formula based value.") : _("This is not a valid formula. Check the variable used in the formula.")}
<br /><br />
{_("Example: If the transaction amount is 200, then this will be calculated as {} = {}", [value ?? "", calculatedValue])}
</p>
</TooltipContent>
</Tooltip>
}
return <span className="font-numeric text-end tabular-nums">{value}</span>
}
const ConfigureAccountsModal = ({ open, onClose }: { open: boolean, onClose: () => void }) => {
return <Dialog
open={open}
onOpenChange={onClose}
>
<DialogContent className='min-w-[95vw]'>
<ConfigureAccountsModalContent />
</DialogContent>
</Dialog>
}
const ConfigureAccountsModalContent = () => {
const { control, getValues, setValue } = useFormContext<BankTransactionRule>()
const { call } = useContext(FrappeContext) as FrappeConfig
// const costCenterMapRef = useRef<Record<string, string>>({})
const partyMapRef = useRef<Record<string, string>>({})
const onPartyChange = (value: string, index: number) => {
// Get the account for the party type
if (value) {
if (partyMapRef.current[value]) {
setValue(`accounts.${index}.account`, partyMapRef.current[value])
} else {
call.get('erpnext.accounts.party.get_party_account', {
party: value,
party_type: getValues(`accounts.${index}.party_type`),
company: company
}).then((result: { message: string }) => {
setValue(`accounts.${index}.account`, result.message)
partyMapRef.current[value] = result.message
})
}
} else {
setValue(`accounts.${index}.account`, '')
}
}
const transaction_type = useWatch({
name: 'transaction_type',
control,
})
const { fields, append, remove } = useFieldArray({
control,
name: 'accounts'
})
const [selectedRows, setSelectedRows] = useState<number[]>([])
const onSelectRow = useCallback((index: number) => {
setSelectedRows(prev => {
if (prev.includes(index)) {
return prev.filter(i => i !== index)
}
return [...prev, index]
})
}, [])
const onSelectAll = useCallback(() => {
setSelectedRows(prev => {
if (prev.length === fields.length) {
return []
}
return [...fields.map((_, index) => index)]
})
}, [fields])
const onAdd = () => {
append({
party_type: '',
party: '',
account: '',
debit: '',
credit: '',
user_remark: ''
} as BankTransactionRuleAccounts, {
focusName: `accounts.${fields.length}.account`
})
}
const onRemove = useCallback(() => {
remove(selectedRows)
setSelectedRows([])
}, [remove, selectedRows])
const isWithdrawal = transaction_type === 'Withdrawal'
const company = useWatch({
name: 'company',
control,
})
return <>
<DialogHeader>
<DialogTitle>{_("Configure Accounts for Bank Entry")}</DialogTitle>
<DialogDescription>{_("Add all accounts that you want to split the transaction into.")}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<Table>
<TableHeader>
<TableRow>
<TableHead><Checkbox
disabled={fields.length === 0}
// Make this accessible to screen readers
aria-label={_("Select all")}
checked={selectedRows.length > 0 && selectedRows.length === fields.length}
onCheckedChange={onSelectAll} /></TableHead>
<TableHead>{_("Party")}</TableHead>
<TableHead>{_("Account")} <span className="text-ink-red-3">*</span></TableHead>
{/* <TableHead>{_("Cost Center")}</TableHead> */}
<TableHead>{_("Remarks")}</TableHead>
<TableHead className="text-end">{_("Debit")}</TableHead>
<TableHead className="text-end">{_("Credit")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow className="bg-surface-gray-1 cursor-not-allowed" title={_("This is the row for the bank account. It will be auto populated based on the bank transaction.")}>
<TableCell>
<Checkbox disabled />
</TableCell>
<TableCell className="align-top">
</TableCell>
<TableCell className="align-top text-ink-gray-5">
<span className="px-2">
Bank GL Account
</span>
</TableCell>
<TableCell className="align-top">
</TableCell>
<TableCell className={"align-top text-end"}>
<span className="text-ink-gray-5 text-sm">
{transaction_type === "Withdrawal" || transaction_type === "Any" ? _("Will be auto-populated") : ""}
</span>
</TableCell>
<TableCell className={"text-end align-top"}>
<span className="text-ink-gray-5 text-sm">
{transaction_type === "Deposit" || transaction_type === "Any" ? _("Will be auto-populated") : ""}
</span>
</TableCell>
</TableRow>
{fields.map((field, index) => (
<TableRow key={field.id}>
<TableCell>
<Checkbox
checked={selectedRows.includes(index)}
onCheckedChange={() => onSelectRow(index)}
// Make this accessible to screen readers
aria-label={_("Select row {0}", [String(index + 1)])}
/>
</TableCell>
<TableCell className="align-top">
<div className="flex">
<PartyTypeFormField
name={`accounts.${index}.party_type`}
label={_("Party Type")}
isRequired
hideLabel
inputProps={{
type: isWithdrawal ? 'Payable' : 'Receivable',
triggerProps: {
className: 'rounded-e-none',
tabIndex: -1
},
}} />
<PartyRowField index={index} onChange={onPartyChange} />
</div>
</TableCell>
<TableCell className="align-top">
<AccountFormField
name={`accounts.${index}.account`}
label={_("Account")}
rules={{
required: _("Account is required"),
// onChange: (event) => {
// onAccountChange(event.target.value, index)
// }
}}
buttonClassName="min-w-64"
isRequired
hideLabel
/>
</TableCell>
{/* <TableCell className="align-top">
<LinkFormField
doctype="Cost Center"
name={`accounts.${index}.cost_center`}
label={_("Cost Center")}
filters={[["company", "=", company], ["is_group", "=", 0], ["disabled", "=", 0]]}
buttonClassName="min-w-48"
readOnly={index === 0}
hideLabel
/>
</TableCell> */}
<TableCell className="align-top">
<DataField
name={`accounts.${index}.user_remark`}
label={_("Remarks")}
inputProps={{
placeholder: _("e.g. Bank Charges"),
className: 'min-w-64',
}}
hideLabel
/>
</TableCell>
<TableCell
className={cn("text-end align-top", index === fields.length - 1 ? "cursor-not-allowed" : "")}
title={index === fields.length - 1 ? _("This is the last row. It will be auto populated based on the bank transaction.") : ""}>
<DataField
name={`accounts.${index}.debit`}
label={_("Debit")}
disabled={index === fields.length - 1}
inputProps={{
className: 'text-end',
placeholder: _("0.00"),
disabled: index === fields.length - 1
}}
hideLabel
/>
</TableCell>
<TableCell
className={cn("text-end align-top", index === fields.length - 1 ? "cursor-not-allowed" : "")}
title={index === fields.length - 1 ? _("This is the last row. It will be auto populated based on the bank transaction.") : ""}>
<DataField
name={`accounts.${index}.credit`}
label={_("Credit")}
disabled={index === fields.length - 1}
inputProps={{
className: 'text-end',
placeholder: _("0.00"),
disabled: index === fields.length - 1
}}
hideLabel
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex justify-between gap-2">
<div className="flex gap-2 justify-end">
<div>
<Button size='sm' type='button' variant={'outline'} onClick={onAdd}><Plus /> {_("Add Row")}</Button>
</div>
{selectedRows.length > 0 && <div>
<Button size='sm' type='button' theme="red" onClick={onRemove}><Trash2 /> {_("Remove")}</Button>
</div>}
</div>
</div>
<div className="py-4">
<Separator />
</div>
<div className="flex flex-col gap-2">
<H4 className="text-base text-ink-gray-7">{_("Help")}</H4>
<Paragraph className="text-p-sm">{(_("You can set up the rule to split the transaction across multiple accounts."))}
<br />{_("You can also add credit or debit values to pre-fill - these support both static values (like 200) or formulas (like transaction_amount * 0.25).")}
<br />
<br />
<span className="font-medium">{_("Example")}:</span>
<br />
<span className="font-numeric text-sm">
transaction_amount * 0.25
</span>
<br />
<span>
{_("In this case, the amount will be calculated as 25% of the transaction amount. If the transaction amount is 200, then this will be calculated as 200 * 0.25 = 50.")}
</span>
</Paragraph>
</div>
</div>
</>
}
const PartyRowField = ({ index, onChange }: { index: number, onChange: (value: string, index: number) => void }) => {
const { control } = useFormContext<BankTransactionRule>()
const party_type = useWatch({
control,
name: `accounts.${index}.party_type`
})
if (!party_type) {
return <DataField
name={`accounts.${index}.party`}
label={_("Party")}
isRequired
inputProps={{
disabled: true,
className: 'rounded-s-none border-s-0 min-w-64'
}}
hideLabel
/>
}
return <LinkFormField
name={`accounts.${index}.party`}
label={_("Party")}
rules={{
onChange: (event) => {
onChange(event.target.value, index)
},
}}
hideLabel
buttonClassName="rounded-s-none border-s-0 min-w-64"
doctype={party_type}
/>
}

View File

@@ -1,73 +0,0 @@
import { useMemo } from 'react'
import { ArrowDownRight, ArrowUpRight, Calendar } from 'lucide-react'
import { formatCurrency } from '@/lib/numbers'
import { formatDate } from '@/lib/date'
import { UnreconciledTransaction, useGetBankAccounts } from './utils'
import { getCompanyCurrency } from '@/lib/company'
import { Card, CardContent } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import _ from '@/lib/translate'
import BankLogo from '@/components/common/BankLogo'
type Props = {
transaction: UnreconciledTransaction,
showAccount?: boolean,
account?: string
}
const SelectedTransactionDetails = ({ transaction, showAccount = false, account }: Props) => {
const isWithdrawal = transaction.withdrawal && transaction.withdrawal > 0
const { banks } = useGetBankAccounts()
const bank = useMemo(() => {
if (transaction.bank_account) {
return banks?.find((bank) => bank.name === transaction.bank_account)
}
return null
}, [transaction.bank_account, banks])
const amount = transaction.withdrawal ? transaction.withdrawal : transaction.deposit
const currency = transaction.currency || getCompanyCurrency(transaction.company ?? '')
return (
<Card className='py-4'>
<CardContent className='px-4'>
<div className='flex flex-col gap-2'>
<div className='flex justify-between'>
<div className='flex flex-col gap-2'>
<div className='flex flex-col gap-1'>
<BankLogo bank={bank} iconSize='30px' imageClassName='h-10 max-w-20' />
<span className='font-medium text-sm'>{transaction.bank_account}</span>
</div>
<div className='flex items-center gap-1'>
<Calendar size='16px' />
<span className='text-sm'>{formatDate(transaction.date, 'Do MMM YYYY')}</span>
</div>
</div>
<div className='flex flex-col gap-1'>
<div className={cn('flex items-center gap-1 text-end px-0 justify-end py-1 rounded-sm',
isWithdrawal ? 'text-ink-red-3' : 'text-ink-green-3'
)}>
{isWithdrawal ? <ArrowUpRight className="w-5 h-5 text-ink-red-3" /> : <ArrowDownRight className="w-5 h-5 text-ink-green-3" />}
<span className='text-sm font-semibold uppercase'>{isWithdrawal ? _('Spent') : _('Received')}</span>
</div>
<span className='font-semibold font-numeric text-lg text-end pe-0.5'>{formatCurrency(amount, currency)}</span>
{transaction.unallocated_amount && transaction.unallocated_amount !== amount ? <span className='text-ink-gray-5'>{_("Unallocated")}: {formatCurrency(transaction.unallocated_amount)}</span> : null}
</div>
</div>
<div className='flex flex-col gap-1'>
<span className='text-sm'>{transaction.description}</span>
{transaction.reference_number ? <span className='text-sm text-ink-gray-5'>{_("Ref")}: {transaction.reference_number}</span> : null}
{showAccount && account ? <span className='text-sm text-ink-gray-5'>{_("GL Account")}: {account}</span> : null}
</div>
</div>
</CardContent >
</Card >
)
}
export default SelectedTransactionDetails

View File

@@ -1,47 +0,0 @@
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import _ from '@/lib/translate'
import { useAtomValue } from 'jotai'
import { bankRecSelectedTransactionAtom, selectedBankAccountAtom } from './bankRecAtoms'
import { formatDate } from '@/lib/date'
import { formatCurrency } from '@/lib/numbers'
import { ArrowDownRight, ArrowUpRight } from 'lucide-react'
const SelectedTransactionsTable = () => {
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
const transactions = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? ''))
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>
{_("Date")}
</TableHead>
<TableHead>
{_("Description")}
</TableHead>
<TableHead className="text-end">
{_("Amount")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transactions.map((transaction) => (
<TableRow key={transaction.name}>
<TableCell>{formatDate(transaction.date)}</TableCell>
<TableCell className="max-w-96 text-ellipsis overflow-hidden" title={transaction.description}>{transaction.description}</TableCell>
<TableCell className="text-end flex items-center justify-end gap-1">
{transaction.withdrawal && transaction.withdrawal > 0 ? <ArrowUpRight className="w-4 h-4 text-ink-red-3" /> : <ArrowDownRight className="w-4 h-4 text-ink-green-3" />}
<span className="font-numeric font-medium">
{formatCurrency(transaction.unallocated_amount, transaction.currency ?? '')}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
export default SelectedTransactionsTable

View File

@@ -1,555 +0,0 @@
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { bankRecSelectedTransactionAtom, bankRecTransferModalAtom, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from './bankRecAtoms'
import { Dialog, DialogContent, DialogHeader, DialogFooter, DialogClose, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import _ from '@/lib/translate'
import { UnreconciledTransaction, useGetBankAccounts, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from './utils'
import { Button } from '@/components/ui/button'
import SelectedTransactionDetails from './SelectedTransactionDetails'
import { PaymentEntry } from '@/types/Accounts/PaymentEntry'
import { useForm, useFormContext, useWatch } from 'react-hook-form'
import { FrappeConfig, FrappeContext, useFrappeGetCall, useFrappePostCall } from 'frappe-react-sdk'
import { toast } from 'sonner'
import ErrorBanner from '@/components/ui/error-banner'
import { H4 } from '@/components/ui/typography'
import { cn } from '@/lib/utils'
import { ArrowRight, Banknote, BadgeCheck, Calendar, ArrowUpRight, ArrowDownRight, CheckIcon, CheckCircle, ArrowLeft } from 'lucide-react'
import { Separator } from '@/components/ui/separator'
import { Form } from '@/components/ui/form'
import { AccountFormField, DataField, DateField, SmallTextField } from '@/components/ui/form-elements'
import SelectedTransactionsTable from './SelectedTransactionsTable'
import { useCurrentCompany } from '@/hooks/useCurrentCompany'
import { formatDate } from '@/lib/date'
import { useContext, useMemo, useState } from 'react'
import { formatCurrency } from '@/lib/numbers'
import { Label } from '@/components/ui/label'
import { FileDropzone } from '@/components/ui/file-dropzone'
import FileUploadBanner from '@/components/common/FileUploadBanner'
import { BankTransaction } from '@/types/Accounts/BankTransaction'
import { useHotkeys } from 'react-hotkeys-hook'
import { useDirection } from '@/components/ui/direction'
import BankLogo from '@/components/common/BankLogo'
const TransferModal = () => {
const [isOpen, setIsOpen] = useAtom(bankRecTransferModalAtom)
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className='min-w-7xl'>
<DialogHeader>
<DialogTitle>{_("Transfer")}</DialogTitle>
<DialogDescription>
{_("Record an internal transfer to another bank/credit card/cash account.")}
</DialogDescription>
</DialogHeader>
<TransferModalContent />
</DialogContent>
</Dialog>
)
}
const TransferModalContent = () => {
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? ''))
if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) {
return <div className='p-4'>
<span className='text-center'>{_("No transaction selected")}</span>
</div>
}
if (selectedTransaction.length === 1) {
return <InternalTransferForm
selectedBankAccount={selectedBankAccount}
selectedTransaction={selectedTransaction[0]} />
}
return <BulkInternalTransferForm transactions={selectedTransaction} />
}
const BulkInternalTransferForm = ({ transactions }: { transactions: UnreconciledTransaction[] }) => {
const form = useForm<{
bank_account: string
}>()
const setIsOpen = useSetAtom(bankRecTransferModalAtom)
const { call: createPaymentEntry, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_internal_transfer')
const onReconcile = useRefreshUnreconciledTransactions()
const addToActionLog = useUpdateActionLog()
const onSubmit = (data: { bank_account: string }) => {
createPaymentEntry({
bank_transaction_names: transactions.map((transaction) => transaction.name),
bank_account: data.bank_account
}).then(({ message }) => {
addToActionLog({
type: 'transfer',
timestamp: (new Date()).getTime(),
isBulk: true,
items: message.map((item) => ({
bankTransaction: item.transaction,
voucher: {
reference_doctype: "Payment Entry",
reference_name: item.payment_entry.name,
posting_date: item.payment_entry.posting_date,
doc: item.payment_entry,
}
})),
bulkCommonData: {
bank_account: data.bank_account,
}
})
toast.success(_("Transfer Recorded"), {
duration: 4000,
closeButton: true,
})
onReconcile(transactions[transactions.length - 1])
setIsOpen(false)
})
}
const onAccountChange = (account: string) => {
form.setValue('bank_account', account)
}
const selectedAccount = useWatch({ control: form.control, name: 'bank_account' })
const currentCompany = useCurrentCompany()
const company = transactions && transactions.length > 0 ? transactions[0].company : (currentCompany ?? '')
console.log("This is here", transactions)
return <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className='flex flex-col gap-4'>
{error && <ErrorBanner error={error} />}
<SelectedTransactionsTable />
<BankOrCashPicker company={company} bankAccount={transactions[0]?.bank_account ?? ''} onAccountChange={onAccountChange} selectedAccount={selectedAccount} />
<DialogFooter>
<DialogClose asChild>
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
</DialogClose>
<Button size='md' type='submit' disabled={loading}>{_("Transfer")}</Button>
</DialogFooter>
</div>
</form>
</Form>
}
interface InternalTransferFormFields extends PaymentEntry {
mirror_transaction_name?: string
}
const InternalTransferForm = ({ selectedBankAccount, selectedTransaction }: { selectedBankAccount: SelectedBank, selectedTransaction: UnreconciledTransaction }) => {
const setIsOpen = useSetAtom(bankRecTransferModalAtom)
const onClose = () => {
setIsOpen(false)
}
const { data: rule } = useGetRuleForTransaction(selectedTransaction)
const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false
const form = useForm<InternalTransferFormFields>({
defaultValues: {
payment_type: 'Internal Transfer',
company: selectedTransaction?.company,
// If the transaction is a withdrawal, set the paid from to the selected bank account
paid_from: isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''),
// If the transaction is a deposit, set the paid to to the selected bank account
paid_to: !isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''),
// Set the amount to the amount of the selected transaction
paid_amount: selectedTransaction.unallocated_amount,
received_amount: selectedTransaction.unallocated_amount,
reference_date: selectedTransaction.date,
posting_date: selectedTransaction.date,
reference_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140),
}
})
const onReconcile = useRefreshUnreconciledTransactions()
const { call: createPaymentEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_internal_transfer')
const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom)
const addToActionLog = useUpdateActionLog()
const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig
const [isUploading, setIsUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [files, setFiles] = useState<File[]>([])
const onSubmit = (data: InternalTransferFormFields) => {
createPaymentEntry({
bank_transaction_name: selectedTransaction.name,
...data,
custom_remarks: data.remarks ? true : false,
// Pass this to reconcile both at the same time
mirror_transaction_name: data.mirror_transaction_name
}).then(async ({ message }) => {
addToActionLog({
type: 'transfer',
timestamp: (new Date()).getTime(),
isBulk: false,
items: [
{
bankTransaction: message.transaction,
voucher: {
reference_doctype: "Payment Entry",
reference_name: message.payment_entry.name,
reference_no: message.payment_entry.reference_no,
reference_date: message.payment_entry.reference_date,
posting_date: message.payment_entry.posting_date,
doc: message.payment_entry,
}
}
]
})
toast.success(_("Transfer Recorded"), {
duration: 4000,
closeButton: true,
action: {
label: _("Undo"),
onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name)
},
actionButtonStyle: {
backgroundColor: "rgb(0, 138, 46)"
}
})
if (files.length > 0) {
setIsUploading(true)
const uploadPromises = files.map(f => {
return frappeFile.uploadFile(f, {
isPrivate: true,
doctype: "Payment Entry",
docname: message.payment_entry.name,
}, (_bytesUploaded, _totalBytes, progress) => {
setUploadProgress((currentProgress) => {
//If there are multiple files, we need to add the progress to the current progress
return currentProgress + ((progress?.progress ?? 0) / files.length)
})
})
})
return Promise.all(uploadPromises).then(() => {
setUploadProgress(0)
setIsUploading(false)
})
} else {
return Promise.resolve()
}
}).then(() => {
setUploadProgress(0)
setIsUploading(false)
onReconcile(selectedTransaction)
onClose()
})
}
useHotkeys('meta+s', () => {
form.handleSubmit(onSubmit)()
}, {
enabled: true,
preventDefault: true,
enableOnFormTags: true
})
const onAccountChange = (account: string, is_mirror: boolean = false) => {
//If the transaction is a withdrawal, set the paid to to the selected account - since this is the account where the money is deposited into
if (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) {
form.setValue('paid_to', account)
} else {
form.setValue('paid_from', account)
}
if (!is_mirror) {
// Reset the mirror transaction name
form.setValue('mirror_transaction_name', '')
}
}
const selectedAccount = useWatch({ control: form.control, name: (selectedTransaction.deposit && selectedTransaction.deposit > 0) ? 'paid_from' : 'paid_to' })
const direction = useDirection()
if (isUploading && isCompleted) {
return <FileUploadBanner uploadProgress={uploadProgress} />
}
return <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className='flex flex-col gap-4'>
{error && <ErrorBanner error={error} />}
<div className='grid grid-cols-2 gap-4'>
<SelectedTransactionDetails transaction={selectedTransaction} />
<div className='flex flex-col gap-4'>
<div className='grid grid-cols-2 gap-4'>
<DateField
name='posting_date'
label={_("Posting Date")}
isRequired
inputProps={{ autoFocus: false }}
/>
<DateField
name='reference_date'
label={_("Reference Date")}
isRequired
inputProps={{ autoFocus: false }}
/>
</div>
<DataField name='reference_no' label={_("Reference")} isRequired inputProps={{ autoFocus: false }} />
</div>
</div>
<div className='flex flex-col gap-2'>
<H4 className='text-base'>{isWithdrawal ? _('Transferred to') : _('Transferred from')}</H4>
<RecommendedTransferAccount transaction={selectedTransaction} onAccountChange={onAccountChange} />
<BankOrCashPicker company={selectedTransaction.company ?? ''} bankAccount={selectedTransaction.bank_account ?? ''} onAccountChange={onAccountChange} selectedAccount={selectedAccount} />
</div>
<div className='flex flex-col gap-2 py-2'>
<div className='flex items-end justify-between gap-4'>
<div className='flex-1'>
<AccountFormField
name="paid_from"
label={_("Paid From")}
account_type={['Bank', 'Cash']}
readOnly={isWithdrawal}
filterFunction={(account) => account.name !== selectedBankAccount.account}
isRequired
/>
</div>
<div className='pb-2'>
{direction === 'ltr' ? <ArrowRight /> : <ArrowLeft />}
</div>
<div className='flex-1'>
<AccountFormField
name="paid_to"
label={_("Paid To")}
account_type={['Bank', 'Cash']}
isRequired
readOnly={!isWithdrawal}
filterFunction={(account) => account.name !== selectedBankAccount.account}
/>
</div>
</div>
</div>
<Separator />
<div className='flex flex-col gap-2'>
<div className='grid grid-cols-2 gap-4'>
<SmallTextField
name='remarks'
label={_("Custom Remarks")}
formDescription={_("This will be auto-populated if not set.")}
/>
<div
data-slot="form-item"
className="flex flex-col gap-2"
>
<Label>{_("Attachments")}</Label>
<FileDropzone files={files} setFiles={setFiles} />
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
</DialogClose>
<Button size='md' type='submit' disabled={loading}>{_("Transfer")}</Button>
</DialogFooter>
</div>
</form>
</Form>
}
const BankOrCashPicker = ({ bankAccount, onAccountChange, selectedAccount, company }: { selectedAccount: string, bankAccount: string, onAccountChange: (account: string) => void, company: string }) => {
const { banks } = useGetBankAccounts(undefined, (bank) => bank.name !== bankAccount)
return <div className='grid grid-cols-4 gap-4'>
{banks.map((bank) => (
<div
className={cn('border p-2 rounded-md flex items-center gap-2 cursor-pointer outline-[0.5px] transition-all duration-200 hover:bg-surface-gray-1 dark:hover:bg-surface-gray-3',
selectedAccount === bank.account ? 'border-outline-gray-5 outline-outline-gray-5 bg-surface-gray-1 dark:bg-surface-gray-3' : 'border-outline-gray-2 outline-outline-gray-2'
)}
role='button'
key={bank.account}
onClick={() => onAccountChange(bank.account ?? '')}
>
<BankLogo bank={bank} iconSize='24px' imageClassName='w-12 h-12' />
<div className='flex flex-col gap-1'>
<span className='font-semibold text-sm'>{bank.account_name} {bank.bank_account_no && <span className='text-xs text-ink-gray-5'>({bank.bank_account_no})</span>}</span>
<span className='text-xs text-ink-gray-5'>{bank.account}</span>
</div>
</div>
))}
<CashPicker company={company ?? ''} selectedAccount={selectedAccount} setSelectedAccount={onAccountChange} />
</div>
}
const CashPicker = ({ company, selectedAccount, setSelectedAccount }: { company: string, selectedAccount: string, setSelectedAccount: (account: string) => void }) => {
const { data } = useFrappeGetCall('frappe.client.get_value', {
doctype: 'Company',
filters: company,
fieldname: 'default_cash_account'
}, undefined, {
revalidateOnFocus: false,
revalidateIfStale: false,
})
const account = data?.message?.default_cash_account
if (account) {
return <div className={cn('border p-2 rounded-md flex items-center gap-2 cursor-pointer outline-[0.5px] transition-all duration-200 hover:bg-surface-gray-1 dark:hover:bg-surface-gray-3',
selectedAccount === account ? 'border-outline-gray-5 outline-outline-gray-5 bg-surface-gray-1 dark:bg-surface-gray-3' : 'border-outline-gray-2 outline-outline-gray-2'
)}
role='button'
onClick={() => setSelectedAccount(account ?? '')}
>
<div className='flex items-center justify-center h-10 w-10'>
<Banknote size='24px' />
</div>
<div className='flex flex-col gap-1'>
<span className='font-semibold text-sm'>Cash</span>
<span className='text-xs text-ink-gray-5'>{data?.message?.default_cash_account}</span>
</div>
</div>
}
return null
}
const RecommendedTransferAccount = ({ transaction, onAccountChange }: { transaction: UnreconciledTransaction, onAccountChange: (account: string, is_mirror: boolean) => void }) => {
const { setValue, watch } = useFormContext<InternalTransferFormFields>()
const mirrorTransactionName = watch('mirror_transaction_name')
const paid_from = watch('paid_from')
const paid_to = watch('paid_to')
const { data } = useFrappeGetCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.search_for_transfer_transaction', {
transaction_id: transaction.name
}, undefined, {
revalidateOnFocus: false,
revalidateIfStale: false,
})
// Get bank accounts to find the logo
const { banks } = useGetBankAccounts()
const bank = useMemo(() => {
if (data?.message?.bank_account && banks) {
return banks.find(bank => bank.name === data.message.bank_account)
}
return null
}, [data?.message?.bank_account, banks])
const selectTransaction = () => {
if (data?.message) {
setValue('mirror_transaction_name', data.message.name)
onAccountChange(data.message.account, true)
}
}
if (data?.message) {
const isWithdrawal = data.message.withdrawal && data.message.withdrawal > 0
const amount = isWithdrawal ? data.message.withdrawal : data.message.deposit
const currency = data.message.currency
const isAccountSelected = isWithdrawal ? paid_from === data.message.account : paid_to === data.message.account
const isSuggested = mirrorTransactionName === data?.message?.name && isAccountSelected
return (<div className='pb-2'>
<div className={cn("flex justify-between items-start gap-3 p-3 border rounded-lg shadow-sm",
isSuggested ? "border-outline-green-4 bg-surface-green-1" : "border-outline-violet-2 bg-surface-violet-2/50")}>
<div>
<div className='flex flex-col gap-3'>
<div className={cn("flex items-center gap-2 shrink-0",
isSuggested ? "text-ink-green-4" : "text-ink-violet-4"
)}>
<BadgeCheck className="w-4 h-4" />
<span className="text-sm font-medium">{_("Suggested Transfer to {0}", [data.message.account])}</span>
</div>
<div className='flex flex-col gap-1'>
<span className='text-p-sm'>{_("The system found a mirror transaction ({0}) in another account with the same amount and date.", [data.message.name])}</span>
<span className='text-p-sm'>{_("Accepting the suggestion will reconcile both transactions.")}</span>
</div>
<div className='flex flex-col gap-1.5'>
<div className='flex items-center gap-1'>
<Calendar size='16px' />
<span className='text-sm'>{formatDate(data.message.date, 'Do MMM YYYY')}</span>
</div>
<span className='text-sm line-clamp-1' title={data.message.description}>{data.message.description}</span>
</div>
</div>
</div>
<div className='flex flex-col items-end justify-between gap-2 h-full w-[30%]'>
<div className="flex items-center gap-2">
<BankLogo bank={bank} iconSize='24px' imageClassName='h-8 max-w-24' iconClassName={cn(isSuggested ? "text-ink-green-3" : "text-purple-600")} />
</div>
<div className='flex gap-1'>
<div className={cn('flex items-center gap-1 text-end px-0 justify-end py-1 rounded-sm',
isWithdrawal ? 'text-ink-red-3' : 'text-ink-green-3'
)}>
{isWithdrawal ? <ArrowUpRight className="w-5 h-5 text-ink-red-3" /> : <ArrowDownRight className="w-5 h-5 text-ink-green-3" />}
<span className='text-sm font-semibold uppercase'>{isWithdrawal ? _('Transferred Out') : _('Received')}</span>
</div>
</div>
<span className='font-semibold font-numeric text-lg text-end pe-0.5'>{formatCurrency(amount, currency)}</span>
<div className='pt-1'>
<Button
onClick={selectTransaction}
theme={isSuggested ? "green" : "violet"}
size="md"
type='button'
>
{isSuggested ? <CheckCircle /> : <CheckIcon />}
{isSuggested ? _("Accepted") : _("Use Suggestion")}
</Button>
</div>
</div>
</div>
</div>
)
}
return null
}
export default TransferModal

View File

@@ -1,85 +0,0 @@
import { BankAccount } from "@/types/Accounts/BankAccount";
import { getDatesForTimePeriod } from "@/lib/date";
import { atom } from "jotai";
import { atomWithStorage, createJSONStorage } from "jotai/utils";
import { atomFamily } from 'jotai-family'
import { UnreconciledTransaction } from "./utils";
import { BankTransaction } from "@/types/Accounts/BankTransaction";
import { PaymentEntry } from "@/types/Accounts/PaymentEntry";
import { JournalEntry } from "@/types/Accounts/JournalEntry";
export interface SelectedBank extends Pick<BankAccount, 'name' | 'bank' | 'is_credit_card' | 'company' | 'account_name' | 'bank_account_no' | 'account' | 'account_type' | 'integration_id' | 'is_default' | 'last_integration_date'> {
logo?: string,
logoDark?: string,
darkModeInvert?: boolean,
logoClassName?: string,
account_currency?: string
}
export const selectedBankAccountAtom = atomWithStorage<SelectedBank | null>('bank-rec-selected-bank', null, undefined, {
getOnInit: true
})
export const bankRecDateAtom = atomWithStorage<{ fromDate: string, toDate: string }>("bank-rec-date", {
fromDate: getDatesForTimePeriod('This Month').fromDate,
toDate: getDatesForTimePeriod('This Month').toDate
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const bankRecClosingBalanceAtom = atomFamily((_id: string) => {
return atom<{ value: number, stringValue: string | number | undefined }>({
value: 0,
stringValue: '0.00'
})
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const bankRecSelectedTransactionAtom = atomFamily((_id: string) => {
return atom<UnreconciledTransaction[]>([])
})
/** Action Modals */
export const bankRecTransferModalAtom = atom(false)
export const bankRecRecordPaymentModalAtom = atom(false)
export const bankRecRecordJournalEntryModalAtom = atom(false)
export const bankRecUnreconcileModalAtom = atom<string>('')
export const bankRecMatchFilters = atomWithStorage<string[]>('bank-rec-match-filters', ['payment_entry', 'journal_entry'])
export const bankRecSearchText = atom<string>('')
export const bankRecAmountFilter = atom<{ value: number, stringValue?: string | number }>({
value: 0,
stringValue: '0.00'
})
export const bankRecTransactionTypeFilter = atom<string>('All')
export interface ActionLog {
type: 'match' | 'payment' | 'transfer' | 'bank_entry'
isBulk: boolean
timestamp: number,
items: ActionLogItem[],
bulkCommonData?: {
party_type?: string,
party?: string,
account?: string,
bank_account?: string,
}
}
export interface ActionLogItem {
bankTransaction: BankTransaction,
voucher: {
reference_doctype: string,
reference_name: string,
reference_no?: string,
reference_date?: string,
posting_date: string,
doc?: PaymentEntry | JournalEntry
},
}
const actionLogStorage = createJSONStorage<ActionLog[]>(() => sessionStorage)
export const bankRecActionLog = atomWithStorage<ActionLog[]>('bank-rec-action-log', [], actionLogStorage, {
getOnInit: true,
})

View File

@@ -1,410 +0,0 @@
export const BANK_LOGOS: { keywords: string[], logo: string, locale?: string[], logoDark?: string, darkModeInvert?: boolean, logoClassName?: string }[] = [
// United States + International
{
keywords: ['American Express', 'Amex'],
logo: 'Amex.svg',
locale: ['Global', 'United States']
},
{
keywords: ['Bank of America', 'BOA'],
logo: 'Bank_of_America.png',
darkModeInvert: true,
locale: ['United States']
},
{
keywords: ['Barclays'],
logo: 'Barclays.svg',
locale: ['Global', 'United Kingdom'],
logoClassName: 'h-12',
},
{
keywords: ['BNP Paribas'],
logo: 'BNP_Paribas.svg',
logoDark: 'BNP_Paribas-Dark.svg',
locale: ['Global', 'France'],
logoClassName: 'max-w-24'
},
{
keywords: ['Bank of New York Mellon', 'BNY Mellon', 'BNY'],
logo: 'BNY_Mellon.svg',
locale: ['Global', 'United States'],
logoDark: 'BNY_Mellon-Dark.svg',
},
{
keywords: ['Capital One'],
logo: 'Capital_One.png',
locale: ['United States'],
darkModeInvert: true
},
{
keywords: ['Charles Schwab', 'Schwab'],
logo: 'Charles_Schwab.svg',
locale: ['United States'],
logoClassName: 'h-7'
},
{
keywords: ['Chase'],
logo: 'chase.svg',
locale: ['Global', 'United States'],
logoDark: 'chase-Dark.svg',
},
{
keywords: ['Citi', 'Citibank', 'Citi Group', 'Citi Financial Services'],
logo: 'Citi.svg',
locale: ['Global', 'United States']
},
{
keywords: ['Deutsche Bank'],
logo: 'Deutsche_Bank.svg',
locale: ['Global', 'Germany'],
darkModeInvert: true,
},
{
keywords: ['Goldman Sachs'],
logo: 'Goldman_Sachs.svg',
locale: ['Global', 'United States'],
darkModeInvert: true,
},
{
keywords: ['HSBC'],
logo: 'HSBC.svg',
locale: ['Global', 'United Kingdom'],
logoDark: 'HSBC-dark.svg',
},
{
keywords: ['JPMorgan Chase', 'JPMorgan', 'JP Morgan', 'JP Morgan Chase', 'JPMorgan Chase & Co', 'JPM', 'JPMC'],
logo: 'jpmc.svg',
locale: ['Global', 'United States'],
darkModeInvert: true,
},
{
keywords: ['Morgan Stanley'],
logo: 'Morgan_Stanley.png',
locale: ['Global', 'United States'],
darkModeInvert: true,
},
{
keywords: ['PNC', 'PNC Financial Services Group', 'PNC Financial Services', 'Pittsburgh National Corporation'],
logo: 'PNC.png',
locale: ['United States']
},
{
keywords: ['Santander'],
logo: 'Santander.svg',
locale: ['Global']
},
{
keywords: ['TD Bank', 'Toronto Dominion Bank'],
logo: 'Toronto_Dominion_Bank.png',
locale: ['Canada']
},
{
keywords: ['Truist'],
logo: 'Truist.svg',
locale: ['United States'],
darkModeInvert: true,
logoClassName: 'h-8'
},
{
keywords: ['UBS'],
logo: 'UBS.svg',
locale: ['Global', 'Switzerland'],
logoDark: 'UBS-dark.svg',
},
{
keywords: ['US Bank', 'USBank', 'U.S. Bank', 'U.S. Bancorp'],
logo: 'USBank.svg',
locale: ['United States'],
logoDark: 'USBank-dark.svg',
},
{
keywords: ['Wells Fargo', 'Wells Fargo'],
logo: 'Wells_Fargo.svg',
locale: ['United States'],
logoClassName: 'h-7'
},
{
keywords: ['OakStar', 'Oakstar', 'Oakstar'],
logo: 'Oakstar.png',
logoDark: 'Oakstar-dark.webp',
locale: ['United States'],
logoClassName: 'h-7'
},
{
keywords: ['PlainsCapital', 'Plains Capital'],
logo: 'PlainsCapitalBank.png',
locale: ['United States'],
logoClassName: 'h-7'
},
{
keywords: ["Standard Chartered"],
logo: 'Standard_Chartered.png',
logoDark: 'Standard_Chartered-dark.png',
locale: ['Global'],
},
// India
{
keywords: ['HDFC Bank', 'HDFC'],
logo: 'HDFC.svg',
locale: ['India'],
},
{
keywords: ['ICICI Bank', 'ICICI'],
logo: 'ICICI.svg',
logoDark: 'ICICI-dark.svg',
locale: ['India'],
},
{
keywords: ['SBI', 'State Bank of India'],
logo: 'State_Bank_of_India.svg',
logoDark: 'State_bank_of_India-Dark.svg',
locale: ['India'],
logoClassName: 'h-4.5'
},
{
keywords: ['Punjab National Bank', 'PNB'],
logo: 'Punjab_National_Bank.svg',
locale: ['India']
},
{
keywords: ['Union Bank of India', 'Union Bank'],
logo: 'Union_Bank_of_India.svg',
locale: ['India']
},
{
keywords: ['Yes Bank', 'Yes'],
logo: 'Yes_Bank.svg',
locale: ['India'],
logoDark: 'Yes_Bank-dark.svg',
},
{
keywords: ['RBL Bank', 'RBL'],
logo: 'RBL_Bank.svg',
locale: ['India'],
logoDark: 'RBL_Bank-dark.svg',
},
{
keywords: ['Axis Bank', 'Axis'],
logo: 'Axis_Bank.svg',
locale: ['India'],
darkModeInvert: true
},
{
keywords: ['Bank of Baroda', 'BOB'],
logo: 'Bank_of_Baroda.svg',
locale: ['India', 'Kenya'],
logoClassName: 'h-7'
},
{
keywords: ['Bank of India', 'BOI'],
logo: 'Bank_of_India.png',
locale: ['India'],
logoClassName: 'h-7'
},
{
keywords: ['Bank of Maharashtra', 'BOM'],
logo: 'Bank_of_Maharashtra.png',
locale: ['India'],
logoClassName: 'min-w-24'
},
{
keywords: ['Kotak Mahindra Bank', 'Kotak'],
logo: 'Kotak_Mahindra.svg',
locale: ['India']
},
{
keywords: ['IndusInd Bank', 'IndusInd'],
logo: 'IndusInd_Bank.svg',
locale: ['India'],
darkModeInvert: true,
},
{
keywords: ['IDBI Bank', 'IDBI'],
logo: 'IDBI_Bank.svg',
locale: ['India']
},
{
keywords: ['IDFC First Bank', 'IDFC First'],
logo: 'IDFC_First_Bank.svg',
locale: ['India']
},
{
keywords: ['Federal Bank'],
logo: 'Federal_Bank.png',
logoDark: 'Federal_Bank-dark.png',
locale: ['India']
},
{
keywords: ['Fi Bank'],
logo: 'Fi_Bank.svg',
locale: ['India']
},
{
keywords: ['RazorpayX', 'Razorpay'],
logo: 'Razorpay.svg',
logoDark: 'Razorpay-dark.svg',
locale: ['India']
},
{
keywords: ['Revolut'],
logo: 'Revolut.png',
locale: ['Global'],
darkModeInvert: true
},
{
keywords: ['Starling Bank'],
logo: 'Starling_Bank.png',
logoDark: 'Starling_Bank-dark.png',
locale: ['Global', 'UK'],
logoClassName: 'h-10'
},
// Australia and New Zealand
{
keywords: ["Commonwealth Bank", "CBA"],
logo: "Commonwealth_Bank.svg",
locale: ['Australia', 'New Zealand'],
},
{
keywords: ["Airwallex"],
logo: "Airwallex.png",
logoDark: "Airwallex-dark.png",
locale: ['Global']
},
{
keywords: ["Judo Bank"],
logo: "Judo_Bank.svg",
logoDark: "Judo_Bank-dark.svg",
locale: ['Australia', 'New Zealand']
},
{
keywords: ["Alpha"], // This might conflict with Alpha Bank in Greece
logo: "Alpha_Bank.svg",
darkModeInvert: true,
logoClassName: 'h-4.5',
locale: ['Australia', 'New Zealand']
},
{
keywords: ["Australian Tax Office", "Australian Taxation Office"],
logo: "Australian_Tax_Office.png",
darkModeInvert: true,
locale: ['Australia']
},
{
keywords: ["Westpac"],
logo: "Westpac.svg",
locale: ['Australia']
},
{
keywords: ["ANZ", "ANZ Bank", "Australia and New Zealand Banking Group"],
logo: "ANZ.png",
locale: ['Australia', 'New Zealand']
},
{
keywords: ["Macquarie Group", "Macquarie Bank"],
logo: "Macquarie.svg",
darkModeInvert: true,
locale: ['Australia']
},
// Nicaragua
{
keywords: ["Banco Atlantida", "Banco Atlántida"],
logo: "Banco_Atlantida.png",
locale: ['Nicaragua']
},
{
keywords: ["Banco de Finanzas"],
logo: "Banco_de_Finanzas.svg",
locale: ['Nicaragua'],
logoClassName: 'h-4.5'
},
{
keywords: ["Avanz"],
logo: "Avanz.svg",
logoDark: "Avanz-dark.svg",
locale: ['Nicaragua'],
logoClassName: 'h-7'
},
{
keywords: ["Ficohsa"],
logo: "Ficohsa.svg",
locale: ['Nicaragua']
},
{
keywords: ["BAC", "BAC Credomatic"],
logo: "BAC_Credomatic.svg",
locale: ['Nicaragua'],
logoClassName: 'h-4.5'
},
{
keywords: ["Banco Lafise"],
logo: "Banco_Lafise.png",
darkModeInvert: true,
locale: ['Nicaragua']
},
// German
{
keywords: ["Sparkasse"],
logo: "Sparkasse.png",
locale: ['Germany']
},
{
keywords: ["Volksbank", "Raiffeisenbank", "VR-Bank"],
logo: "Volksbanken_Raiffeisenbanken.svg",
locale: ['Germany'],
logoClassName: 'min-w-32'
},
// Kenya
{
keywords: ["KCB Bank", "KCB"],
logo: "KCB_Bank_Kenya.png",
locale: ['Kenya']
},
{
keywords: ["Equity Bank"],
logo: "Equity_Bank.png",
logoDark: "Equity_Bank-dark.png",
locale: ['Kenya'],
},
{
keywords: ["I&M"],
logo: "I&M.png",
locale: ['Kenya']
},
{
keywords: ["ABSA"],
logo: "ABSA.png",
locale: ['Kenya'],
darkModeInvert: true,
logoClassName: 'h-7'
},
{
keywords: ["Stanbic"],
logo: "Stanbic.png",
locale: ['Kenya'],
logoClassName: 'h-7'
},
{
keywords: ["DTB", "Diamond Trust Bank"],
logo: "Diamond_Trust_Bank.png",
locale: ['Kenya']
},
{
keywords: ["Prime Bank"],
logo: "Prime_Bank.png",
locale: ['Kenya'],
logoClassName: 'max-w-28'
},
{
keywords: ["Stripe"],
logo: "Stripe.svg",
locale: ['Global'],
logoClassName: 'h-6',
darkModeInvert: true,
},
{
keywords: ["PayPal"],
logo: "PayPal.png",
locale: ['Global'],
logoClassName: 'h-6',
}
]

View File

@@ -1,457 +0,0 @@
import { ActionLog, bankRecActionLog, bankRecAmountFilter, bankRecDateAtom, bankRecMatchFilters, bankRecSearchText, bankRecSelectedTransactionAtom, bankRecTransactionTypeFilter, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from './bankRecAtoms'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useMemo } from 'react'
import { SWRConfiguration, useFrappeGetCall, useFrappeGetDoc, useFrappePostCall, useSWRConfig } from 'frappe-react-sdk'
import { BankTransaction } from '@/types/Accounts/BankTransaction'
import { BankAccount } from '@/types/Accounts/BankAccount'
import dayjs from 'dayjs'
import { toast } from 'sonner'
import { BANK_LOGOS } from './logos'
import { getErrorMessage } from '@/lib/frappe'
import { useCurrentCompany } from '@/hooks/useCurrentCompany'
import _ from '@/lib/translate'
import { BankTransactionRule } from '@/types/Accounts/BankTransactionRule'
import { useRef } from 'react'
import type { DebouncedState } from 'usehooks-ts'
import { useDebounceCallback } from 'usehooks-ts'
import Fuse from 'fuse.js'
export const useGetAccountOpeningBalance = () => {
const companyID = useCurrentCompany()
const bankAccount = useAtomValue(selectedBankAccountAtom)
const dates = useAtomValue(bankRecDateAtom)
const args = useMemo(() => {
return {
bank_account: bankAccount?.name,
company: companyID,
till_date: dayjs(dates.fromDate).subtract(1, 'days').format('YYYY-MM-DD'),
}
}, [companyID, bankAccount?.name, dates.fromDate])
return useFrappeGetCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_account_balance', args, undefined, {
revalidateOnFocus: false
})
}
export const useGetAccountClosingBalance = () => {
const companyID = useCurrentCompany()
const bankAccount = useAtomValue(selectedBankAccountAtom)
const dates = useAtomValue(bankRecDateAtom)
const args = useMemo(() => {
return {
bank_account: bankAccount?.name,
company: companyID,
till_date: dates.toDate,
}
}, [companyID, bankAccount?.name, dates.toDate])
return useFrappeGetCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_account_balance', args,
`bank-reconciliation-account-closing-balance-${bankAccount?.name}-${dates.toDate}`,
{
revalidateOnFocus: false
}
)
}
/**
* Hook to fetch the closing balance set in the database for the given bank and date
*/
export const useGetAccountClosingBalanceAsPerStatement = (swrConfig: SWRConfiguration = {}) => {
const dates = useAtomValue(bankRecDateAtom)
const bankAccount = useAtomValue(selectedBankAccountAtom)
return useFrappeGetCall<{ message: { balance: number, date?: string } }>("erpnext.accounts.doctype.bank_account.bank_account.get_closing_balance_as_per_statement", {
bank_account: bankAccount?.name,
date: dates.toDate
}, `bank-reconciliation-account-closing-balance-as-per-statement-${bankAccount?.name}-${dates.toDate}`, {
revalidateOnFocus: false,
...swrConfig
})
}
export type UnreconciledTransaction = Pick<BankTransaction, 'name' | 'matched_transaction_rule' | 'date' | 'withdrawal' | 'deposit' | 'currency' | 'description' | 'status' | 'transaction_type' | 'reference_number' | 'party_type' | 'party' | 'bank_account' | 'company' | 'unallocated_amount'>
export const useGetUnreconciledTransactions = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
const dates = useAtomValue(bankRecDateAtom)
return useFrappeGetCall<{ message: UnreconciledTransaction[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_bank_transactions', {
bank_account: bankAccount?.name,
from_date: dates.fromDate,
to_date: dates.toDate
}, bankAccount ? `bank-reconciliation-unreconciled-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}` : null, {
revalidateOnFocus: false,
revalidateIfStale: false
})
}
export interface LinkedPayment {
rank: number,
doctype: string,
name: string,
paid_amount: number,
reference_no: string,
reference_date: string,
posting_date: string,
party_type?: string,
party?: string,
currency: string
}
export const useGetBankTransactions = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
const dates = useAtomValue(bankRecDateAtom)
return useFrappeGetCall<{ message: BankTransaction[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_bank_transactions', {
bank_account: bankAccount?.name,
from_date: dates.fromDate,
to_date: dates.toDate,
all_transactions: true
}, bankAccount ? `bank-reconciliation-bank-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}` : null)
}
export const useGetVouchersForTransaction = (transaction: UnreconciledTransaction) => {
const dates = useAtomValue(bankRecDateAtom)
const matchFilters = useAtomValue(bankRecMatchFilters)
return useFrappeGetCall<{ message: LinkedPayment[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_linked_payments', {
bank_transaction_name: transaction.name,
document_types: matchFilters ?? ['payment_entry', 'journal_entry'],
from_date: dates.fromDate,
to_date: dates.toDate,
filter_by_reference_date: 0
}, `bank-reconciliation-vouchers-${transaction.name}-${dates.fromDate}-${dates.toDate}-${matchFilters.join(',')}`, {
revalidateOnFocus: false
})
}
/**
* Common hook to refresh the unreconciled transactions list after a transaction is reconciled
* @returns function to call to refresh the unreconciled transactions list AFTER the operation is done
*/
export const useRefreshUnreconciledTransactions = () => {
const selectedBank = useAtomValue(selectedBankAccountAtom)
const dates = useAtomValue(bankRecDateAtom)
const matchFilters = useAtomValue(bankRecMatchFilters)
const setSelectedTransaction = useSetAtom(bankRecSelectedTransactionAtom(selectedBank?.name || ''))
const { mutate } = useSWRConfig()
const searchString = useAtomValue(bankRecSearchText)
const typeFilter = useAtomValue(bankRecTransactionTypeFilter)
const amountFilter = useAtomValue(bankRecAmountFilter)
const { data: unreconciledTransactions } = useGetUnreconciledTransactions()
/**
* This function should be called after a transaction is reconciled
* It will get the next unreconciled transaction and select it
* And then refresh the balance + unreconciled transactions list
*/
const onReconcileTransaction = (transaction: UnreconciledTransaction, updatedTransaction?: BankTransaction) => {
// If the updated transaction has an unallocated amount of 0, then we need to select the next unreconciled transaction
if (updatedTransaction && updatedTransaction?.unallocated_amount !== 0) {
mutate(`bank-reconciliation-unreconciled-transactions-${selectedBank?.name}-${dates.fromDate}-${dates.toDate}`)
mutate(`bank-reconciliation-account-closing-balance-${selectedBank?.name}-${dates.toDate}`)
// Update the matching vouchers for the selected transaction
mutate(`bank-reconciliation-vouchers-${transaction.name}-${dates.fromDate}-${dates.toDate}-${matchFilters.join(',')}`)
return
}
// From unreconciled transactions list, first apply the filters based on the search criteria and other filters
const searchIndex = unreconciledTransactions ? new Fuse(unreconciledTransactions.message, {
keys: ['description', 'reference_number'],
threshold: 0.5,
includeScore: true
}) : null
const results = getSearchResults(searchIndex, searchString, typeFilter, amountFilter.value, unreconciledTransactions?.message)
const currentIndex = results.findIndex(t => t.name === transaction.name)
let nextTransaction = null
if (currentIndex !== -1) {
// Check if there is a next transaction
if (currentIndex < (results.length || 0) - 1) {
nextTransaction = results[currentIndex + 1]
}
}
// We need to select the next unreconciled transaction for a better UX
mutate(`bank-reconciliation-unreconciled-transactions-${selectedBank?.name}-${dates.fromDate}-${dates.toDate}`)
.then(res => {
if (nextTransaction) {
// Check if next transaction is there in the response
const nextTransactionObj = res?.message.find((t: UnreconciledTransaction) => t.name === nextTransaction.name)
if (nextTransactionObj) {
setSelectedTransaction([nextTransactionObj])
} else {
// If the next transaction is not there in the response, we need to clear the selection
setSelectedTransaction([])
}
} else {
// If there is no next transaction, we need to clear the selection
setSelectedTransaction([])
}
})
mutate(`bank-reconciliation-account-closing-balance-${selectedBank?.name}-${dates.toDate}`)
}
return onReconcileTransaction
}
export const useReconcileTransaction = () => {
const { call, loading } = useFrappePostCall<{ message: BankTransaction }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.reconcile_vouchers')
const onReconcileTransaction = useRefreshUnreconciledTransactions()
const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom)
const addToActionLog = useUpdateActionLog()
const reconcileTransaction = (transaction: UnreconciledTransaction, voucher: LinkedPayment) => {
call({
bank_transaction_name: transaction.name,
vouchers: JSON.stringify([{
"payment_doctype": voucher.doctype,
"payment_name": voucher.name,
"amount": voucher.paid_amount
}])
}).then((res) => {
addToActionLog({
type: 'match',
timestamp: (new Date()).getTime(),
isBulk: false,
items: [
{
bankTransaction: res.message,
voucher: {
reference_doctype: voucher.doctype,
reference_name: voucher.name,
reference_no: voucher.reference_no,
reference_date: voucher.reference_date,
posting_date: voucher.posting_date,
}
}
]
})
onReconcileTransaction(transaction, res.message)
toast.success(_("Reconciled"), {
duration: 4000,
closeButton: true,
action: {
label: _("Undo"),
onClick: () => setBankRecUnreconcileModalAtom(transaction.name)
},
actionButtonStyle: {
backgroundColor: "rgb(0, 138, 46)"
}
})
}).catch((error) => {
console.error(error)
toast.error(_("Error"), {
duration: 5000,
description: getErrorMessage(error)
})
})
}
return { reconcileTransaction, loading }
}
interface BankAccountWithCurrency extends Pick<BankAccount, 'name' | 'bank' | 'account_name' | 'is_credit_card' | 'company' | 'account' | 'account_type' | 'account_subtype' | 'bank_account_no' | 'last_integration_date'> {
account_currency?: string
}
type BankLogoEntry = (typeof BANK_LOGOS)[number]
/** Prefer the longest keyword match so short tokens (e.g. "anz" in "finanzas") do not beat full bank names. */
function findBankLogoForName(bankName: string | undefined | null): BankLogoEntry | undefined {
if (!bankName) return undefined
const haystack = bankName.toLowerCase()
let best: BankLogoEntry | undefined
let bestKeywordLen = 0
for (const entry of BANK_LOGOS) {
for (const keyword of entry.keywords) {
const needle = keyword.toLowerCase()
if (needle.length === 0) continue
if (haystack.includes(needle) && needle.length > bestKeywordLen) {
bestKeywordLen = needle.length
best = entry
}
}
}
return best
}
export const useGetBankAccounts = (onSuccess?: (data?: Omit<SelectedBank, 'logo'>[]) => void, filterFn?: (bank: SelectedBank) => boolean) => {
const company = useCurrentCompany()
const { data, isLoading, error } = useFrappeGetCall<{ message: BankAccountWithCurrency[] }>('erpnext.accounts.doctype.bank_account.bank_account.get_list', {
company: company
}, undefined, {
revalidateOnFocus: false,
revalidateIfStale: false,
onSuccess: (data) => {
onSuccess?.(data?.message)
}
})
const banks = useMemo(() => {
// Match the bank account to the logo
const banksWithLogos = data?.message.map((bank) => {
const logo = findBankLogoForName(bank.bank)
return {
...bank,
logo: logo?.logo,
logoDark: logo?.logoDark,
darkModeInvert: logo?.darkModeInvert,
logoClassName: logo?.logoClassName
}
}) ?? []
if (filterFn) {
return banksWithLogos.filter(filterFn)
}
return banksWithLogos
}, [data, filterFn])
return {
banks,
isLoading,
error
}
}
export const useIsTransactionWithdrawal = (transaction: UnreconciledTransaction) => {
return useMemo(() => {
const isWithdrawal = transaction.withdrawal && transaction.withdrawal > 0
const isDeposit = transaction.deposit && transaction.deposit > 0
return {
amount: isWithdrawal ? transaction.withdrawal : transaction.deposit,
isWithdrawal,
isDeposit
}
}, [transaction])
}
export const useGetRuleForTransaction = (transaction: UnreconciledTransaction) => {
return useFrappeGetDoc<BankTransactionRule>('Bank Transaction Rule', transaction.matched_transaction_rule,
transaction.matched_transaction_rule ? undefined : null, {
revalidateOnFocus: false,
revalidateIfStale: false
}
)
}
/** Hook to handle the search input while maintaining debouncing and global state. */
export function useTransactionSearch(): [string, DebouncedState<(value: string) => void>] {
const delay = 500
const unwrappedInitialValue = ''
const eq = (left: string, right: string) => left === right
const [debouncedValue, setDebouncedValue] = useAtom(bankRecSearchText)
const previousValueRef = useRef<string | undefined>(unwrappedInitialValue)
const updateDebouncedValue = useDebounceCallback(
setDebouncedValue,
delay,
)
// Update the debounced value if the initial value changes
if (!eq(previousValueRef.current as string, unwrappedInitialValue)) {
updateDebouncedValue(unwrappedInitialValue)
previousValueRef.current = unwrappedInitialValue
}
return [debouncedValue, updateDebouncedValue]
}
/** Utility function to get the search results based on the search index, search string, type filter, amount filter and unreconciled transactions */
export const getSearchResults = (
/** Fuse index of the unreconciled transactions */
searchIndex: Fuse<UnreconciledTransaction> | null,
/** Search string */
search: string,
/** Type filter */
typeFilter: string,
/** Amount filter */
amountFilter: number,
/** Unreconciled transactions */
unreconciledTransactions?: UnreconciledTransaction[]) => {
let r = []
if (!searchIndex || !search) {
r = unreconciledTransactions ?? []
} else {
r = searchIndex.search(search).map((result) => result.item)
}
if (typeFilter !== 'All') {
r = r.filter((transaction) => {
if (typeFilter === 'Debits') {
return transaction.withdrawal && transaction.withdrawal > 0
}
if (typeFilter === 'Credits') {
return transaction.deposit && transaction.deposit > 0
}
})
}
if (amountFilter > 0) {
r = r.filter((transaction) => {
if (transaction.withdrawal && transaction.withdrawal > 0) {
return transaction.withdrawal === amountFilter
}
if (transaction.deposit && transaction.deposit > 0) {
return transaction.deposit === amountFilter
}
return false
})
}
return r
}
export const useUpdateActionLog = () => {
const setActionLog = useSetAtom(bankRecActionLog)
const addToActionLog = (action: ActionLog) => {
// Store at max 100 actions
setActionLog((prev) => {
const newActions = [action, ...prev]
if (newActions.length > 100) {
return newActions.slice(0, 100)
}
return newActions
})
}
return addToActionLog
}

View File

@@ -1,22 +0,0 @@
import CSVRawDataPreview from './CSVRawDataPreview'
import StatementDetails from './StatementDetails'
import _ from '@/lib/translate'
import { GetStatementDetailsResponse } from '../import_utils'
const CSVImport = ({ data }: { data: { message: GetStatementDetailsResponse } }) => {
return (
<div className="w-full flex">
<div className="w-[50%] p-4 h-[calc(100vh-72px)] overflow-scroll">
<StatementDetails data={data.message} />
</div>
<div className="w-[50%] border-s border-t pe-1 ps-0 border-outline-gray-2 h-[calc(100vh-72px)] overflow-scroll">
<CSVRawDataPreview data={data.message} />
</div>
</div>
)
}
export default CSVImport

View File

@@ -1,151 +0,0 @@
import { Table, TableBody, TableCell, TableHead, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { ArrowDownRightIcon, ArrowUpDownIcon, ArrowUpRightIcon, BanknoteIcon, CalendarIcon, DollarSignIcon, FileTextIcon, ListIcon, ReceiptIcon } from "lucide-react"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import _ from "@/lib/translate"
import { GetStatementDetailsResponse } from "../import_utils"
import { useMemo } from "react"
import { BankStatementImportLogColumnMap } from "@/types/Accounts/BankStatementImportLogColumnMap"
const CSVRawDataPreview = ({ data }: { data: GetStatementDetailsResponse }) => {
const column_mapping: Record<StandardColumnTypes, number> = useMemo(() => {
const col_map: Record<string, number> = {}
data.doc.column_mapping?.forEach(col => {
if (col.maps_to && col.maps_to !== "Do not import") {
col_map[col.maps_to] = col.index;
}
})
return col_map
}, [data])
const validColumns = Object.values(column_mapping)
// Reverse the column mapping to get a map of column index to variable name
const columnIndexMap: Record<number, StandardColumnTypes> = Object.fromEntries(Object.entries(column_mapping).map(([variable, columnIndex]) => [columnIndex, variable as StandardColumnTypes]))
// Loop over the contents of the CSV file and show a preview - highlight the header row and the transaction rows
return (
<Table containerClassName="rounded-none">
<TableBody>
{data.raw_data.map((row, index) => {
const isHeaderRow = index === data.doc.detected_header_index;
const isTransactionRow = index >= (data.doc.detected_transaction_starting_index ?? 0) && index <= (data.doc.detected_transaction_ending_index ?? 0);
return <TableRow key={index}
title={isHeaderRow ? "Header Row" : ""}
className={cn({
// "bg-yellow-100": isHeaderRow,
// "hover:bg-yellow-100": isHeaderRow,
"bg-green-50 hover:bg-green-50 dark:bg-green-700 dark:hover:bg-green-700": isTransactionRow,
"text-ink-gray-5/70": !isTransactionRow && !isHeaderRow,
})}>
{isHeaderRow ? <TableHead className="bg-yellow-100 hover:bg-yellow-100 dark:bg-yellow-400 text-center font-semibold text-ink-gray-8">
{index + 1}
</TableHead> :
<TableCell className="text-center px-1 py-0.5">
{index + 1}
</TableCell>
}
{row.map((cell, cellIndex) => {
const isValidColumn = validColumns.includes(cellIndex);
const columnType = columnIndexMap[cellIndex];
const isAmountColumn = ["Amount", "Withdrawal", "Deposit", "Balance"].includes(columnType);
if (isHeaderRow) {
return <TableHead key={cellIndex} className={cn("max-w-[250px] w-fit overflow-hidden text-ellipsis py-0.5",
isValidColumn ? "bg-yellow-100 hover:bg-yellow-100 dark:bg-yellow-400" : "bg-surface-gray-2",
)}>
<div className={cn("flex items-center text-xs gap-1 px-1 text-ink-gray-8 font-medium", {
"justify-end": isAmountColumn && isValidColumn
})}>
{columnType && <Tooltip>
<TooltipTrigger>
<ColumnHeaderIcon columnType={columnType} />
</TooltipTrigger>
<TooltipContent>
{_(columnType)}
</TooltipContent>
</Tooltip>
}
{cell}
</div>
</TableHead>
} else {
return <TableCell key={cellIndex} className={cn("max-w-[200px] w-fit overflow-hidden text-ellipsis py-0.5",
{
"bg-green-100 dark:bg-green-400 hover:bg-green-100 dark:hover:bg-green-400": isValidColumn && isTransactionRow,
"text-ink-gray-5": !isValidColumn && isTransactionRow,
}
)} >
<div className={cn("min-h-5 flex items-center text-xs px-1", {
"justify-end": isAmountColumn && isValidColumn && isTransactionRow
})} title={cell}>
{cell}
</div>
</TableCell>
}
}
)}
</TableRow>
})}
</TableBody>
</Table >
)
}
type StandardColumnTypes = BankStatementImportLogColumnMap['maps_to'];
const ColumnHeaderIcon = ({ columnType }: { columnType?: StandardColumnTypes }) => {
if (!columnType) {
return null
}
if (columnType === 'Amount') {
return <DollarSignIcon className="w-4 h-4" />
}
if (columnType === 'Withdrawal') {
return <ArrowUpRightIcon className="w-4 h-4 text-ink-red-3" />
}
if (columnType === 'Deposit') {
return <ArrowDownRightIcon className="w-4 h-4 text-ink-green-3" />
}
if (columnType === 'Balance') {
return <BanknoteIcon className="w-4 h-4" />
}
if (columnType === 'Date') {
return <CalendarIcon className="w-4 h-4" />
}
if (columnType === 'Description') {
return <FileTextIcon className="w-4 h-4" />
}
if (columnType === 'Reference') {
return <ReceiptIcon className="w-4 h-4" />
}
if (columnType === 'Transaction Type') {
return <ListIcon className="w-4 h-4" />
}
if (columnType === 'Debit/Credit') {
return <ArrowUpDownIcon className="w-4 h-4" />
}
return null
}
export default CSVRawDataPreview

View File

@@ -1,351 +0,0 @@
import _ from '@/lib/translate'
import { GetStatementDetailsResponse } from '../import_utils'
import { flt, formatCurrency } from '@/lib/numbers'
import { formatDate } from '@/lib/date'
import { bankRecDateAtom } from '../../BankReconciliation/bankRecAtoms'
import { AlertCircleIcon, ChevronLeftIcon, ChevronRightIcon, ExternalLinkIcon, InfoIcon, Loader2Icon } from 'lucide-react'
import { H2, H3, Paragraph } from '@/components/ui/typography'
import { FileTypeIcon } from '@/components/ui/file-dropzone'
import { getFileExtension } from '@/lib/file'
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Separator } from '@/components/ui/separator'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useFrappeEventListener, useFrappePostCall } from 'frappe-react-sdk'
import { toast } from 'sonner'
import ErrorBanner from '@/components/ui/error-banner'
import { Link, useNavigate } from 'react-router-dom'
import { useMemo, useState } from 'react'
import { Progress } from '@/components/ui/progress'
import { useSetAtom } from 'jotai'
import { useDirection } from '@/components/ui/direction'
import BankLogo from '@/components/common/BankLogo'
import { useGetBankAccounts } from '../../BankReconciliation/utils'
import { BankStatementImportLog } from '@/types/Accounts/BankStatementImportLog'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
const parseDateFormat = (dateFormat: string) => {
const charMap = {
"%d": "DD",
"%m": "MM",
"%Y": "YYYY",
"%y": "YY",
"%b": "MMM",
"%B": "MMMM",
}
let label = dateFormat
Object.keys(charMap).forEach((char) => {
label = label.replace(char, charMap[char as keyof typeof charMap])
})
return dateFormat
}
type Props = {
data: GetStatementDetailsResponse,
}
const StatementDetails = ({ data }: Props) => {
const dateFormat = parseDateFormat(data.date_format)
const { call, loading, error } = useFrappePostCall<{ docs: BankStatementImportLog[] }>('run_doc_method')
const navigate = useNavigate()
const setDates = useSetAtom(bankRecDateAtom)
const direction = useDirection()
const onImport = () => {
call({
docs: data.doc,
method: 'insert_transactions'
}).then((response) => {
const doc = response.docs ? response.docs[0] : undefined
if (doc && doc.start_date && doc.end_date) {
setDates({
fromDate: doc.start_date,
toDate: doc.end_date,
})
}
toast.success(_("Bank statement imported."))
navigate(`/`)
}).catch(() => {
toast.error(_("There was an error while importing the bank statement."))
})
}
const [progress, setProgress] = useState(0)
useFrappeEventListener("bank-rec-statement-import-progress", (event) => {
setProgress(event.progress)
})
const file_name = data.doc.file.split("/").pop() ?? ""
const { banks } = useGetBankAccounts()
const bank = useMemo(() => {
return banks?.find((bank) => bank.name === data.doc.bank_account)
}, [data.doc.bank_account, banks])
return (
<div className='flex flex-col gap-4'>
<div className='flex flex-col gap-4'>
<div className='flex justify-between items-center'>
<Button size='sm' variant='outline' asChild>
<Link to="/statement-importer">
{direction === 'ltr' ? <ChevronLeftIcon /> : <ChevronRightIcon />}
{_("Back")}
</Link>
</Button>
{data.doc.status === 'Completed' ? <Badge theme='green'>{_("Completed")}</Badge> :
<Button onClick={onImport} disabled={loading || data.final_transactions?.length === 0} size='sm' type='button'>
{loading ? <Loader2Icon className='size-4 animate-spin' /> : null}
{loading ? _("Importing...") : _("Import {0} transactions", [data.final_transactions?.length?.toString() || "0"])}</Button>
}
</div>
<div className='flex items-start gap-4'>
<div className='flex flex-col gap-1'>
<H2 className='text-lg border-0 p-0'>{_("Statement Details")}</H2>
<Paragraph className='text-p-sm'><span>
{_("We've auto-detected the details of the statement file.")}
</span><br />
<span>
{_("Please review the details below and click the 'Import' button to proceed.")}
</span>
</Paragraph>
</div>
</div>
{progress > 0 && <div className='flex flex-col gap-2'><Progress value={progress} max={100} size="lg" />
<span className='text-sm'>{_("Importing {0} transactions", [progress.toString()])}
</span>
</div>}
{error && <ErrorBanner error={error} />}
<Table>
<TableBody>
<TableRow>
<TableHead>{_("Bank Account")}</TableHead>
<TableCell>
<div className='flex items-center gap-2'>
<BankLogo bank={bank} />
<span className="tracking-tight text-sm font-medium">{bank?.account_name}</span>
<span title="GL Account" className="text-sm">{bank?.account}</span>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Statement File")}</TableHead>
<TableCell>
<div className='flex items-center gap-2'>
<FileTypeIcon fileType={getFileExtension(file_name)} size='md' showBackground={false} />
{file_name}
</div>
</TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Transaction Dates")}</TableHead>
<TableCell>{_("{0} to {1}", [formatDate(data.doc.start_date, "Do MMMM YYYY"), formatDate(data.doc.end_date, "Do MMMM YYYY")])}</TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Number of Transactions")}</TableHead>
<TableCell>{data.doc.number_of_transactions}</TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Total Debits")}</TableHead>
<TableCell><span className='font-numeric'>{formatCurrency(flt(data.doc.total_debits, 2), data.currency)}</span> <span className='text-ink-gray-5 font-sans'>({data.doc.total_debit_transactions} {data.doc.total_debit_transactions === 1 ? _("transaction") : _("transactions")})</span></TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Total Credits")}</TableHead>
<TableCell><span className='font-numeric'>{formatCurrency(flt(data.doc.total_credits, 2), data.currency)}</span> <span className='text-ink-gray-5 font-sans'>({data.doc.total_credit_transactions} {data.doc.total_credit_transactions === 1 ? _("transaction") : _("transactions")})</span></TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Closing Balance as of {}", [formatDate(data.doc.end_date, "Do MMMM YYYY")])}</TableHead>
<TableCell className='font-numeric'>{formatCurrency(flt(data.doc.closing_balance, 2), data.currency)}</TableCell>
</TableRow>
<TableRow>
<TableHead>
<div className='flex items-center gap-2'>
{_("Detected Amount Format")} <Tooltip>
<TooltipTrigger><InfoIcon size={16} /></TooltipTrigger>
<TooltipContent>
{_("The amount format detected in the statement file. This is used to parse the deposit and withdrawal values from each row.")}
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableCell>{data.doc.detected_amount_format}</TableCell>
</TableRow>
<TableRow>
<TableHead>
<div className='flex items-center gap-2'>
{_("Detected Date Format")}
<Tooltip>
<TooltipTrigger><InfoIcon size={16} /></TooltipTrigger>
<TooltipContent>
{_("The date format detected in the statement file. This is used to parse the date values.")}
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableCell>
{dateFormat || data.date_format} (e.g.{" "}
{formatDate(new Date(), dateFormat || "YYYY-MM-DD")})
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
{data.doc.status === "Not Started" ? <>
<ConflictingTransactions transactions={data.conflicting_transactions} />
<Separator />
<div className='flex flex-col gap-4'>
<div className='flex flex-col gap-1'>
<H3 className='text-base border-0 p-0'>{_("Preview Transactions")}</H3>
{data.final_transactions?.length === 1 ? (
<Paragraph className='text-p-sm'>{_("We've found 1 transaction in the statement file that will be imported into the system. Please review the details below and click the 'Import' button to proceed.")}</Paragraph>
) : (
<Paragraph className='text-p-sm'>{_("{0} transactions will be imported into the system. Please review the details below and click the 'Import' button to proceed.", [data.final_transactions?.length?.toString() || "0"])}</Paragraph>
)}
</div>
<div className='max-h-[400px] overflow-scroll pb-2'>
<Table>
<TableCaption>{_("Transactions to be imported into the system")}</TableCaption>
<TableHeader>
<TableRow>
<TableHead className='w-8'>#</TableHead>
<TableHead>{_("Date")}</TableHead>
<TableHead>{_("Description")}</TableHead>
<TableHead>{_("Ref.")}</TableHead>
<TableHead className='text-end'>{_("Withdrawal")}</TableHead>
<TableHead className='text-end'>{_("Deposit")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.final_transactions?.map((transaction, index) => (
<TableRow key={index}>
<TableCell className='w-8'>{index + 1}</TableCell>
<TableCell>{formatDate(transaction.date)}</TableCell>
<TableCell className='max-w-[200px] w-fit overflow-hidden text-ellipsis'>{transaction.description}</TableCell>
<TableCell className='max-w-[100px] w-fit overflow-hidden text-ellipsis'>{transaction.reference}</TableCell>
<TableCell className='text-end font-numeric'>{formatCurrency(transaction.withdrawal, data.currency)}</TableCell>
<TableCell className='text-end font-numeric'>{formatCurrency(transaction.deposit, data.currency)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</> : null}
</div>
)
}
const ConflictingTransactions = ({ transactions }: { transactions: GetStatementDetailsResponse["conflicting_transactions"] }) => {
if (transactions.length === 0) {
return null
}
return <>
<Alert theme="red">
<AlertCircleIcon />
<AlertTitle>{_("Conflicting Transactions")}</AlertTitle>
<AlertDescription>
{transactions.length === 1 ? _("We've found 1 existing transaction in the system that conflicts with the transactions in the statement file. Are you sure you want to proceed with the import?")
: _("We've found {0} existing transactions in the system that conflict with the transactions in the statement file. Are you sure you want to proceed with the import?", [transactions.length.toString()])}
<div className='py-2'>
<Dialog>
<DialogTrigger asChild>
<Button
size='sm'
type='button'
theme='red'
variant='solid'>
<span>{transactions.length > 1 ? _("View transactions") : _("View transaction")}</span>
</Button>
</DialogTrigger>
<DialogContent className='min-w-7xl'>
<DialogHeader>
<DialogTitle>{_("Conflicting Transactions")}</DialogTitle>
<DialogDescription>
{transactions.length === 1 ? _("We've found 1 existing transaction in the system that conflicts with the transactions in the statement file. Are you sure you want to proceed with the import?")
: _("We've found {0} existing transactions in the system that conflict with the transactions in the statement file. Are you sure you want to proceed with the import?", [transactions.length.toString()])}
</DialogDescription>
</DialogHeader>
<div className='max-h-[400px] overflow-scroll pb-2'>
<Table>
<TableCaption>{_("Existing transactions in the system belonging to the same bank account and date range")}</TableCaption>
<TableHeader>
<TableRow>
<TableHead>{_("Date")}</TableHead>
<TableHead>{_("Description")}</TableHead>
<TableHead>{_("Ref.")}</TableHead>
<TableHead className='text-end'>{_("Withdrawal")}</TableHead>
<TableHead className='text-end'>{_("Deposit")}</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transactions.map((transaction) => (
<TableRow key={transaction.name}>
<TableCell>{formatDate(transaction.date)}</TableCell>
<TableCell title={transaction.description} className='max-w-[200px] w-fit overflow-hidden text-ellipsis'>{transaction.description}</TableCell>
<TableCell title={transaction.reference_number} className='max-w-[100px] w-fit overflow-hidden text-ellipsis'>{transaction.reference_number ? transaction.reference_number : "-"}</TableCell>
<TableCell className='text-end font-numeric'>{formatCurrency(transaction.withdrawal, transaction.currency)}</TableCell>
<TableCell className='text-end font-numeric'>{formatCurrency(transaction.deposit, transaction.currency)}</TableCell>
<TableCell className='text-end'>
<Tooltip>
<TooltipTrigger asChild>
<Button variant='link' isIconButton asChild className='text-ink-gray-5 hover:text-black p-0 h-4'>
<a href={`/desk/bank-transaction/${transaction.name}`} target='_blank' rel='noopener noreferrer'>
<ExternalLinkIcon />
</a>
</Button>
</TooltipTrigger>
<TooltipContent>
{_("Open {0} in a new tab", [transaction.name])}
</TooltipContent>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant={'outline'} size='md' type='button'>{_("Close")}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</AlertDescription>
</Alert>
</>
}
export default StatementDetails

View File

@@ -1,42 +0,0 @@
import { BankStatementImportLog } from "@/types/Accounts/BankStatementImportLog"
import { useFrappeGetCall } from "frappe-react-sdk"
export interface GetStatementDetailsResponse {
doc: BankStatementImportLog,
conflicting_transactions: Array<{
name: string,
date: string,
withdrawal: number,
deposit: number,
description: string,
reference_number: string,
currency: string,
}>,
final_transactions: Array<{
date: string,
withdrawal: number,
deposit: number,
description: string,
reference: string,
transaction_type?: string,
debit_credit?: string,
included_fee?: number,
excluded_fee?: number,
party_name?: string,
party_account_number?: string,
party_iban?: string,
}>,
date_format: string,
raw_data: Array<Array<string>>,
currency: string,
}
export const useGetStatementDetails = (id: string) => {
return useFrappeGetCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.get_statement_details", {
statement_import_id: id,
}, undefined, {
revalidateOnFocus: false
})
}

View File

@@ -1,115 +0,0 @@
import { Badge } from '@/components/ui/badge'
import { Kbd, KbdGroup } from '@/components/ui/kbd'
import { KeyboardMetaKeyIcon } from '@/components/ui/keyboard-keys'
import { SettingsPanelDescription, SettingsPanelTitle, SettingsPanelHeader, SettingsPanelContent } from '@/components/ui/settings-dialog'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import _ from '@/lib/translate'
import { ArrowRightLeftIcon, HistoryIcon, LandmarkIcon, ReceiptIcon, SaveIcon, SettingsIcon, ZapIcon } from 'lucide-react'
const Shortcuts = [
{
shortcut: <KbdGroup><Kbd><KeyboardMetaKeyIcon /></Kbd><Kbd>B</Kbd></KbdGroup>,
action: {
icon: <LandmarkIcon />,
label: _("Bank Entry"),
description: _("Record a bank journal entry for expenses, income or split transactions")
}
},
{
shortcut: <KbdGroup><Kbd><KeyboardMetaKeyIcon /></Kbd><Kbd>P</Kbd></KbdGroup>,
action: {
icon: <ReceiptIcon />,
label: _("Record Payment"),
description: _("Record a payment against a customer or supplier")
}
},
{
shortcut: <KbdGroup><Kbd><KeyboardMetaKeyIcon /></Kbd><Kbd>I</Kbd></KbdGroup>,
action: {
icon: <ArrowRightLeftIcon />,
label: _("Transfer"),
description: _("Record a transfer between two bank accounts")
}
},
{
shortcut: <KbdGroup><Kbd><KeyboardMetaKeyIcon /></Kbd><Kbd>R</Kbd></KbdGroup>,
action: {
icon: <ZapIcon />,
label: _("Accept Matching Rule"),
description: _("Accept the rule for the selected transaction")
}
},
{
shortcut: <KbdGroup><Kbd><KeyboardMetaKeyIcon /></Kbd><Kbd>S</Kbd></KbdGroup>,
action: {
icon: <SaveIcon />,
label: _("Save"),
description: _("Save the currently opened form")
}
},
{
shortcut: <KbdGroup><Kbd><KeyboardMetaKeyIcon /></Kbd><Kbd>Z</Kbd></KbdGroup>,
action: {
icon: <HistoryIcon />,
label: _("Reconciliation History"),
description: _("View all reconciliation actions taken in this session")
}
},
{
shortcut: <KbdGroup><Kbd><KeyboardMetaKeyIcon /></Kbd><Kbd></Kbd><Kbd>G</Kbd></KbdGroup>,
action: {
icon: <SettingsIcon />,
label: _("Settings"),
description: _("Open the settings dialog")
}
}
]
const KeyboardShortcuts = () => {
return (
<>
<SettingsPanelHeader>
<SettingsPanelTitle>{_("Keyboard Shortcuts")}</SettingsPanelTitle>
<SettingsPanelDescription>{_("Get around the system quickly with keyboard shortcuts")}</SettingsPanelDescription>
</SettingsPanelHeader>
<SettingsPanelContent>
<div className='flex flex-col gap-3'>
<p className='text-p-sm text-ink-gray-6'>
{_("Transaction actions work when one or more unreconciled transactions are selected.")}
<br />
{_("To select more than one transaction at a time, press and hold the shift key.")}
</p>
<Table containerClassName='dark:border-outline-gray-2'>
<TableHeader>
<TableRow>
<TableHead>{_("Shortcut")}</TableHead>
<TableHead>{_("Action")}</TableHead>
<TableHead>{_("Description")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Shortcuts.map((shortcut) => (
<TableRow className='hover:bg-surface-gray-2'>
<TableCell>
{shortcut.shortcut}
</TableCell>
<TableCell>
<Badge size='lg' variant='outline'>
{shortcut.action.icon}
{shortcut.action.label}
</Badge>
</TableCell>
<TableCell>
<p className='text-p-sm text-ink-gray-6 text-wrap'>{shortcut.action.description}</p>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</SettingsPanelContent>
</>
)
}
export default KeyboardShortcuts

View File

@@ -1,46 +0,0 @@
import { Button } from '@/components/ui/button'
import { SettingsPanelTitle, SettingsPanelHeader, SettingsPanelDescription, SettingsPanelContent } from '@/components/ui/settings-dialog'
import _ from '@/lib/translate'
import { PlusIcon } from 'lucide-react'
import { useState } from 'react'
import RuleList, { RunRulesButton } from './Rules/RuleList'
import CreateNewRule from '../BankReconciliation/Rules/CreateNewRule'
import EditRule from '../BankReconciliation/Rules/EditRule'
const MatchingRules = () => {
const [selectedRule, setSelectedRule] = useState<string | null>(null)
const [isNewRule, setIsNewRule] = useState(false)
if (isNewRule) {
return <CreateNewRule onCreate={() => setIsNewRule(false)} />
}
if (selectedRule) {
return <EditRule onClose={() => setSelectedRule(null)} ruleID={selectedRule} />
}
return (
<>
<SettingsPanelHeader
actions={
<div className='flex gap-2 items-center'>
<RunRulesButton />
<Button type='button' onClick={() => setIsNewRule(true)}><PlusIcon /> {_("Add Rule")}</Button>
</div>
}
>
<SettingsPanelTitle>{_("Transaction Matching Rules")}</SettingsPanelTitle>
<SettingsPanelDescription>
{_("Set up rules to automatically classify transactions. Drag and drop rules to reorder their priority.")}
</SettingsPanelDescription>
</SettingsPanelHeader>
<SettingsPanelContent>
<RuleList setSelectedRule={setSelectedRule} />
</SettingsPanelContent>
</>
)
}
export default MatchingRules

View File

@@ -1,261 +0,0 @@
import ErrorBanner from "@/components/ui/error-banner"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import { SettingsPanelDescription, SettingsPanelHeader, SettingsPanelTitle, SettingsPanelContent } from "@/components/ui/settings-dialog"
import { Switch } from "@/components/ui/switch"
import { useTheme } from "@/components/ui/theme-provider"
import _ from "@/lib/translate"
import { AccountsSettings } from "@/types/Accounts/AccountsSettings"
import { useFrappeGetDoc, useFrappeUpdateDoc } from "frappe-react-sdk"
import { toast } from "sonner"
export const Preferences = () => {
const { data: accountsSettings, mutate, error: fetchError, isLoading } = useFrappeGetDoc<AccountsSettings>("Accounts Settings", "Accounts Settings", undefined, {
revalidateOnFocus: false
})
const { updateDoc, error } = useFrappeUpdateDoc<AccountsSettings>()
const onUpdate = (field: keyof AccountsSettings, value: any) => {
mutate(updateDoc("Accounts Settings", "Accounts Settings", {
[field]: value
}), {
optimisticData: {
...accountsSettings as AccountsSettings,
[field]: value
},
revalidate: false,
}).then(() => {
toast.success(_("Preferences updated"), {
dismissible: true,
duration: 500,
})
})
}
return <>
<SettingsPanelHeader>
<SettingsPanelTitle>{_("Preferences")}</SettingsPanelTitle>
<SettingsPanelDescription>{_("Configure settings for the banking module")}</SettingsPanelDescription>
</SettingsPanelHeader>
<SettingsPanelContent>
<div className='flex flex-col gap-4 w-full'>
{fetchError && <ErrorBanner error={fetchError} />}
{error && <ErrorBanner error={error} />}
<div className="flex flex-col flex-1">
<ThemeSwitcher />
<div className="flex justify-between items-center gap-8 py-3">
<div className="flex flex-col">
<Label htmlFor="transfer_match_days" className="text-p-base text-ink-gray-6">{_("Number of days to match transfers")}</Label>
<p className="text-p-sm text-ink-gray-5">
{_("For example, if set to 4, the system will try to find matching transfer transactions in other banks 4 days before and after the transaction date. This is because transactions can clear on different days on different bank accounts.")}
</p>
</div>
<div className="min-w-40 flex justify-end">
<Select disabled={isLoading} onValueChange={(value) => onUpdate("transfer_match_days", Number(value))} value={accountsSettings?.transfer_match_days?.toString()}>
<SelectTrigger id="transfer_match_days" className="min-w-32">
<SelectValue placeholder={_("Select number of days")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">{_("Same day")}</SelectItem>
<SelectItem value="1">{_("Within 1 day")}</SelectItem>
<SelectItem value="2">{_("Within 2 days")}</SelectItem>
<SelectItem value="3">{_("Within 3 days")}</SelectItem>
<SelectItem value="4">{_("Within 4 days")}</SelectItem>
<SelectItem value="5">{_("Within 5 days")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Separator />
<div className="flex justify-between items-center gap-8 py-3">
<div className="flex flex-col">
<Label htmlFor="automatically_run_rules_on_unreconciled_transactions" className="text-p-base text-ink-gray-6">{_("Automatically run rules on unreconciled transactions")}</Label>
<p className="text-p-sm text-ink-gray-5">
{_("This will automatically run transaction matching rules on unreconciled transactions every hour.")}
</p>
</div>
<div className="flex justify-end">
<Switch
id="automatically_run_rules_on_unreconciled_transactions"
className="dark:disabled:bg-surface-gray-2"
disabled={isLoading}
checked={accountsSettings?.automatically_run_rules_on_unreconciled_transactions === 1}
onCheckedChange={(checked) => onUpdate("automatically_run_rules_on_unreconciled_transactions", checked ? 1 : 0)}
/>
</div>
</div>
<Separator />
<div className="flex justify-between items-center gap-8 py-3">
<div className="flex flex-col">
<Label htmlFor="enable_party_matching" className="text-p-base text-ink-gray-6">{_("Enable automatic party matching")}</Label>
<p className="text-p-sm text-ink-gray-5">
{_("The system will attempt to automatically match a party to a bank transaction based on account number or IBAN.")}
</p>
</div>
<div className="flex justify-end">
<Switch
id="enable_party_matching"
className="dark:disabled:bg-surface-gray-2"
disabled={isLoading}
checked={accountsSettings?.enable_party_matching === 1}
onCheckedChange={(checked) => onUpdate("enable_party_matching", checked ? 1 : 0)}
/>
</div>
</div>
<Separator />
<div className="flex justify-between items-center gap-8 py-3">
<div className="flex flex-col">
<Label htmlFor="enable_fuzzy_matching" className="text-p-base text-ink-gray-6">{_("Enable party name/description fuzzy matching")}</Label>
<p className="text-p-sm text-ink-gray-5">
{_("If a party cannot be matched by account number or IBAN, the system will try fuzzy matching using the party name and transaction description.")}
</p>
</div>
<div className="flex justify-end">
<Switch
id="enable_fuzzy_matching"
className="dark:disabled:bg-surface-gray-2"
disabled={accountsSettings?.enable_party_matching !== 1 || isLoading}
checked={accountsSettings?.enable_fuzzy_matching === 1}
onCheckedChange={(checked) => onUpdate("enable_fuzzy_matching", checked ? 1 : 0)}
/>
</div>
</div>
</div>
{/* <DataField
name='transfer_match_days'
label={_("Number of days to match transfers")}
isRequired
inputProps={{
type: 'number',
inputMode: 'numeric',
}}
formDescription={_("For example, if set to 4, the system will try to find matching transactions in other banks 4 days before and after the transaction date. This is because transactions can clear on different days on different bank accounts.")}
/> */}
</div>
</SettingsPanelContent>
</>
}
const ThemeSwitcher = () => {
const { theme, setTheme } = useTheme()
const themeCards: Array<{ value: "Light" | "Dark" | "Automatic", label: string }> = [
{
value: "Light",
label: _("Light"),
},
{
value: "Dark",
label: _("Dark"),
},
{
value: "Automatic",
label: _("System"),
},
]
return <div className="flex flex-col gap-3 pb-3">
<div className="flex flex-col">
<Label className="text-p-base text-ink-gray-6">{_("Theme")}</Label>
<p className="text-p-sm text-ink-gray-5">
{_("Switch between light, dark, or system theme")}
</p>
</div>
<div className="flex gap-3">
{themeCards.map((option) => {
const selected = theme === option.value
return (
<button
key={option.value}
type="button"
onClick={() => setTheme(option.value)}
aria-pressed={selected}
className={`flex-1 basis-0 min-w-0 overflow-hidden rounded-lg border cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-outline-blue-4 ${selected ? "border-outline-gray-5" : "border-outline-gray-modals hover:border-outline-gray-4"}`}
>
{option.value === "Automatic" ? (
<div className="flex w-full min-w-0">
<ThemePreviewWindow theme="light" roundedClass="rounded-tl-[10.5px]" />
<ThemePreviewWindow theme="dark" roundedClass="rounded-tr-[10.5px]" />
</div>
) : (
<ThemePreviewWindow theme={option.value === "Light" ? "light" : "dark"} roundedClass="rounded-t-[10.5px]" />
)}
<div className="flex items-center justify-between px-3 py-2 border-t border-outline-gray-modals">
<div className="text-base text-ink-gray-7">{option.label}</div>
<span className={`rounded-full size-3.5 ${selected ? "border-4 border-outline-gray-5" : "border border-outline-gray-4"}`} />
</div>
</button>
)
})}
</div>
</div>
}
const ThemePreviewWindow = ({ theme, roundedClass }: { theme: "light" | "dark", roundedClass: string }) => {
const isLight = theme === "light"
const frameClass = isLight ? "bg-white border-gray-100" : "bg-gray-900 border-gray-800"
const subtleSurfaceClass = isLight ? "bg-gray-50" : "bg-gray-800"
const mutedLineClass = isLight ? "bg-gray-200" : "bg-gray-700"
const mutedLineStrongClass = isLight ? "bg-gray-300" : "bg-gray-600"
const dividerClass = isLight ? "border-gray-100" : "border-gray-800"
const cardClass = isLight ? "bg-white border-gray-200" : "bg-gray-900 border-gray-700"
return <div className={`flex flex-1 min-w-0 pl-5 pt-3.5 ${isLight ? "bg-surface-gray-2" : "bg-surface-gray-3"} ${roundedClass}`}>
<div className={`w-full rounded-tl-sm border ${frameClass}`}>
<div className={`flex gap-[3px] py-[3px] px-1 border-b ${dividerClass}`}>
<div className="size-1.5 bg-[#FF5F57] rounded-full" />
<div className="size-1.5 bg-[#FEBC2D] rounded-full" />
<div className="size-1.5 bg-[#28C840] rounded-full" />
</div>
<div className="p-1.5">
<div className={`flex items-center gap-1.5 p-1 rounded-sm border ${subtleSurfaceClass} ${dividerClass}`}>
<div className={`h-2 w-8 rounded-full ${mutedLineStrongClass}`} />
<div className={`h-2 w-6 rounded-full ${mutedLineClass}`} />
<div className={`h-2 w-7 rounded-full ml-auto ${mutedLineClass}`} />
</div>
<div className="grid grid-cols-2 gap-1 mt-1.5">
<div className={`rounded-sm border p-1 ${cardClass}`}>
<div className={`h-1.5 w-full rounded-full ${mutedLineStrongClass}`} />
<div className={`h-1.5 w-4/5 rounded-full mt-1 ${mutedLineClass}`} />
<div className={`h-1.5 w-3/5 rounded-full mt-1 ${mutedLineClass}`} />
</div>
<div className={`rounded-sm border p-1 ${cardClass}`}>
<div className="flex items-center justify-between gap-1">
<div className={`h-1.5 w-2/5 rounded-full ${mutedLineStrongClass}`} />
{/* <div className={`h-2.5 w-5 rounded-sm border ${chipClass}`} /> */}
</div>
<div className={`h-1.5 w-full rounded-full mt-1 ${mutedLineClass}`} />
<div className={`h-1.5 w-3/4 rounded-full mt-1 ${mutedLineClass}`} />
</div>
</div>
</div>
</div>
</div>
}

View File

@@ -1,314 +0,0 @@
import { Button } from "@/components/ui/button"
import ErrorBanner from "@/components/ui/error-banner"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import _ from "@/lib/translate"
import { BankTransactionRule } from "@/types/Accounts/BankTransactionRule"
import { FrappeConfig, FrappeContext, useFrappeGetCall, useFrappeGetDocList, useFrappePostCall } from "frappe-react-sdk"
import { ArrowDownRight, ArrowDownUp, ArrowUpRight, MoreVertical, Trash2, GripVertical, Play, RefreshCw, ZapIcon, CalendarSyncIcon } from "lucide-react"
import { useContext, useState } from "react"
import { toast } from "sonner"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, DropdownMenuCheckboxItem } from "@/components/ui/dropdown-menu"
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import {
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { cn } from "@/lib/utils"
const useGetRuleList = () => {
return useFrappeGetDocList<BankTransactionRule>("Bank Transaction Rule", {
fields: ["name", "rule_name", "rule_description", "transaction_type", "priority"],
orderBy: {
field: 'priority',
order: 'asc'
},
limit: 100
})
}
export const RunRulesButton = () => {
const { data } = useGetRuleList()
const { call: runRuleEvaluation, loading: isRunningRules } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction_rule.bank_transaction_rule.run_rule_evaluation')
const handleRunRules = async (forceEvaluate: boolean = false) => {
try {
await runRuleEvaluation({
force_evaluate: forceEvaluate
})
toast.success(forceEvaluate ? _("Rules evaluation started") : _("Rules evaluation completed"))
} catch (error) {
toast.error(_("Failed to run rules evaluation"))
console.error("Error running rules evaluation:", error)
}
}
if (!data || data.length === 0) {
return null
}
return <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={isRunningRules}>
{isRunningRules ? (
<RefreshCw className="animate-spin" />
) : (
<Play />
)}
{isRunningRules ? _("Running...") : _("Run Rules")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => handleRunRules(false)} disabled={isRunningRules} title={_("Run rules on unreconciled transactions that haven't been evaluated yet")}>
<Play />
{_("Run on new transactions")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleRunRules(true)} disabled={isRunningRules} title={_("Force re-evaluate all unreconciled transactions, even if they were previously evaluated")}>
<RefreshCw />
{_("Force evaluate all")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<AutoRunRuleItem />
</DropdownMenuContent>
</DropdownMenu>
}
const AutoRunRuleItem = () => {
const { db } = useContext(FrappeContext) as FrappeConfig
const { data: accountsSetting, mutate: setAutomaticallyRunRulesOnUnreconciledTransactions } = useFrappeGetCall("frappe.client.get_single_value", {
"doctype": "Accounts Settings",
"field": "automatically_run_rules_on_unreconciled_transactions"
})
const automaticallyRunRulesOnUnreconciledTransactions = accountsSetting?.message ? true : false
const onAutoClassifyTransactions = (checked: boolean) => {
toast.promise(db.setValue("Accounts Settings", "Accounts Settings", "automatically_run_rules_on_unreconciled_transactions", checked ? 1 : 0).then(() => {
setAutomaticallyRunRulesOnUnreconciledTransactions({
message: {
automatically_run_rules_on_unreconciled_transactions: checked ? 1 : 0,
}
}, {
revalidate: false
})
}), {
loading: _("Updating..."),
success: checked ? _("Scheduled job enabled. Transactions will be auto classified.") : _("Scheduled job disabled. Transactions will not be auto classified."),
error: _("Failed to update auto classify transactions settings")
})
}
return <DropdownMenuCheckboxItem
checked={automaticallyRunRulesOnUnreconciledTransactions}
onCheckedChange={onAutoClassifyTransactions}>
<CalendarSyncIcon />
{_("Run rules automatically")}
</DropdownMenuCheckboxItem>
}
const RuleList = ({ setSelectedRule }: { setSelectedRule: (rule: string) => void }) => {
const { data, error, isLoading, mutate } = useGetRuleList()
const { db } = useContext(FrappeContext) as FrappeConfig
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const onDeleteRule = (ruleID: string) => {
toast.promise(db.deleteDoc("Bank Transaction Rule", ruleID).then(() => {
mutate()
}), {
loading: _("Deleting rule..."),
success: _("Rule deleted."),
error: _("Failed to delete rule.")
})
}
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event
if (active.id !== over?.id && data) {
const oldIndex = data.findIndex((rule) => rule.name === active.id)
const newIndex = data.findIndex((rule) => rule.name === over?.id)
const newData = arrayMove(data, oldIndex, newIndex)
// Update priorities based on new order
const updatePromises = newData.map((rule, index) => {
const newPriority = index + 1
if (rule.priority !== newPriority) {
return db.setValue("Bank Transaction Rule", rule.name, "priority", newPriority)
}
return Promise.resolve()
})
try {
await Promise.all(updatePromises)
toast.success(_("Rule priorities updated"))
mutate() // Refresh the data
} catch (error) {
toast.error(_("Failed to update rule priorities"))
console.error("Error updating priorities:", error)
}
}
}
return (
<>
<div className="overflow-y-auto">
{isLoading && <div className="flex flex-col gap-2">
<Skeleton className="w-full h-10" />
<Skeleton className="w-full h-10" />
<Skeleton className="w-full h-10" />
<Skeleton className="w-full h-10" />
<Skeleton className="w-full h-10" />
</div>}
{error && <ErrorBanner error={error} />}
{data && data.length === 0 && <Empty className="h-96">
<EmptyMedia>
<ZapIcon />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>{_("No rules setup yet")}</EmptyTitle>
<EmptyDescription>{_("Configure rules to save time when reconciling transactions.")}</EmptyDescription>
</EmptyHeader>
</Empty>}
{data && data.length > 0 && (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={data.map(rule => rule.name)}
strategy={verticalListSortingStrategy}
>
<ul className="space-2 divide-y divide-outline-gray-modals">
{data?.map((rule) => (
<SortableRuleItem
key={rule.name}
rule={rule}
setSelectedRule={setSelectedRule}
onDeleteRule={onDeleteRule}
/>
))}
</ul>
</SortableContext>
</DndContext>
)}
</div>
</>
)
}
const SortableRuleItem = ({
rule,
setSelectedRule,
onDeleteRule
}: {
rule: BankTransactionRule
setSelectedRule: (rule: string) => void
onDeleteRule: (ruleID: string) => void
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: rule.name })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
return (
<li ref={setNodeRef} style={style}>
<div className={cn("flex justify-between items-center py-2 my-0.5 h-full hover:bg-surface-gray-1 pe-2 rounded", isDropdownOpen && "bg-surface-gray-1")}>
<div className="flex items-center gap-2">
<div
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 rounded"
title={_("Drag to reorder")}
>
<GripVertical className="w-4 h-4 text-ink-gray-5" />
</div>
<Badge theme="gray" className="font-numeric tabular-nums">
{rule.priority}
</Badge>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Button
variant='link'
size='sm'
className="p-0 h-fit text-start cursor-pointer no-underline hover:underline"
onClick={() => setSelectedRule(rule.name)}>
{rule.rule_name}
</Button>
<div title={rule.transaction_type === "Any" ? _("Applies to withdrawals and deposits") : rule.transaction_type === "Withdrawal" ? _("Applies to withdrawals") : _("Applies to deposits")}>
{rule.transaction_type === "Any" ? <ArrowDownUp className="text-ink-gray-5 w-4 h-4" /> : rule.transaction_type === "Withdrawal" ? <ArrowUpRight className="text-ink-red-3 w-5 h-5" /> : <ArrowDownRight className="text-ink-green-3 w-5 h-5" />}
</div>
</div>
<span className="text-sm text-ink-gray-5">
{rule.rule_description}
</span>
</div>
</div>
<div className="flex items-center gap-2 h-full justify-center">
<DropdownMenu open={isDropdownOpen} onOpenChange={setIsDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant='ghost' isIconButton className="hover:bg-transparent">
<MoreVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onDeleteRule(rule.name)}>
<Trash2 />
{_("Delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</li>
)
}
export default RuleList

View File

@@ -1,95 +0,0 @@
import { Button } from '@/components/ui/button'
import { Dialog, DialogTrigger } from '@/components/ui/dialog'
import {
SettingsDialog,
SettingsPanel,
SettingsPanels,
SettingsTabGroup,
SettingsTabItem,
SettingsTabs,
} from '@/components/ui/settings-dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import _ from '@/lib/translate'
import { KeyboardIcon, SettingsIcon, SlidersVerticalIcon, ZapIcon } from 'lucide-react'
import { useState } from 'react'
import { Preferences } from './Preferences'
import MatchingRules from './MatchingRules'
import KeyboardShortcuts from './KeyboardShortcuts'
import { useHotkeys } from 'react-hotkeys-hook'
const Settings = () => {
const [isOpen, setIsOpen] = useState(false)
useHotkeys('shift+meta+g', () => {
setIsOpen(x => !x)
}, {
enabled: true,
preventDefault: true,
enableOnFormTags: false
})
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button variant={'outline'} isIconButton size='md'>
<SettingsIcon />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>
{_("Settings")}
</TooltipContent>
</Tooltip>
<SettingsDialog defaultValue="preferences" onClose={() => setIsOpen(false)}>
<SettingsTabs>
<SettingsTabGroup header={_("Settings")}>
<SettingsTabItem
icon={<SlidersVerticalIcon />}
label={_("Preferences")}
value="preferences"
/>
<SettingsTabItem
icon={<ZapIcon />}
label={_("Matching Rules")}
value="rules"
/>
{/* <SettingsTabItem
icon={<LandmarkIcon />}
label={_("Bank Accounts")}
value="bank-accounts"
/>
<SettingsTabItem
icon={<ListIcon />}
label={_("Masters")}
value="masters"
/> */}
<SettingsTabItem
icon={<KeyboardIcon />}
label={_("Keyboard Shortcuts")}
value="keyboard-shortcuts"
/>
</SettingsTabGroup>
</SettingsTabs>
<SettingsPanels>
<SettingsPanel value="preferences">
<Preferences />
</SettingsPanel>
<SettingsPanel value="rules">
<MatchingRules />
</SettingsPanel>
<SettingsPanel value="bank-accounts" />
<SettingsPanel value="masters" />
<SettingsPanel value="keyboard-shortcuts">
<KeyboardShortcuts />
</SettingsPanel>
</SettingsPanels>
</SettingsDialog>
</Dialog >
)
}
export default Settings

View File

@@ -1,196 +0,0 @@
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black-200 dark:bg-black-700",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"bg-surface-modal shadow-xl rounded-xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 p-6 duration-200 data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-start sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-2xl leading-6 text-ink-gray-8 font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-ink-gray-7 text-p-base", className)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"bg-surface-gray-1 mb-2 inline-flex size-16 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "solid",
size = "md",
theme = "red",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size" | "theme">) {
return (
<Button variant={variant} size={size} theme={theme} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "md",
theme = "gray",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size" | "theme">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View File

@@ -1,104 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3.5 text-base grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-1 [&>svg]:text-current",
{
variants: {
variant: {
subtle: "bg-surface-white",
outline: "border border-outline-gray-3",
},
theme: {
gray: "text-ink-gray-8",
blue: "text-ink-blue-3",
green: "text-ink-green-3",
red: "text-ink-red-3",
amber: "text-ink-amber-3",
}
},
compoundVariants: [
// Subtle alerts
{
theme: "gray",
variant: "subtle",
className: "bg-surface-gray-2 border-outline-gray-1"
},
{
theme: "blue",
variant: "subtle",
className: "bg-surface-blue-2 border-surface-blue-2"
},
{
theme: "green",
variant: "subtle",
className: "bg-surface-green-2 border-surface-green-2"
},
{
theme: "red",
variant: "subtle",
className: "bg-surface-red-2 border-surface-red-2"
},
{
theme: "amber",
variant: "subtle",
className: "bg-surface-amber-2 border-surface-amber-2"
}
],
defaultVariants: {
variant: "subtle",
theme: "gray",
},
}
)
export type AlertProps = React.ComponentProps<"div"> & VariantProps<typeof alertVariants>
function Alert({
className,
variant,
theme,
...props
}: AlertProps) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant, theme }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 min-h-4 text-ink-gray-8 font-medium text-p-base",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-ink-gray-6 col-start-2 grid justify-items-start gap-1 text-p-base",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -1,188 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center select-none rounded-full whitespace-nowrap gap-1 w-fit shrink-0 [&>svg]:pointer-events-none transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
solid: "",
subtle: "",
outline: "bg-transparent border",
ghost: "bg-transparent",
},
size: {
sm: 'h-4 text-xs px-1.5 [&>svg]:size-2.5',
md: 'h-5 text-xs px-1.5 [&>svg]:size-3',
lg: 'h-6 text-sm px-2 [&>svg]:size-3',
},
theme: {
gray: "",
blue: "",
green: "",
red: "",
orange: "",
violet: "",
}
},
compoundVariants: [
// Solid badges
{
variant: "solid",
theme: "gray",
className: "text-ink-white bg-surface-gray-7 [a&]:hover:bg-surface-gray-8"
},
{
variant: "solid",
theme: "blue",
className: "text-ink-blue-1 bg-surface-blue-5 [a&]:hover:bg-surface-blue-6"
},
{
variant: "solid",
theme: "green",
className: "text-ink-green-1 bg-surface-green-5 [a&]:hover:bg-surface-green-6"
},
{
variant: "solid",
theme: "orange",
className: "text-ink-amber-1 bg-surface-amber-5 [a&]:hover:bg-surface-amber-6"
},
{
variant: "solid",
theme: "red",
className: "text-ink-red-1 bg-surface-red-5 [a&]:hover:bg-surface-red-6"
},
{
variant: "solid",
theme: "violet",
className: "text-ink-violet-1 bg-surface-violet-5 [a&]:hover:bg-surface-violet-6"
},
// Subtle badge
{
variant: "subtle",
theme: "gray",
className: "text-ink-gray-6 bg-surface-gray-2 [a&]:hover:bg-surface-gray-3"
},
{
variant: "subtle",
theme: "blue",
className: "text-ink-blue-4 bg-surface-blue-2 [a&]:hover:bg-surface-blue-3"
},
{
variant: "subtle",
theme: "green",
className: "text-ink-green-4 bg-surface-green-2 [a&]:hover:bg-surface-green-3"
},
{
variant: "subtle",
theme: "orange",
className: "text-ink-amber-4 bg-surface-amber-2 [a&]:hover:bg-surface-amber-3"
},
{
variant: "subtle",
theme: "red",
className: "text-ink-red-4 bg-surface-red-2 [a&]:hover:bg-surface-red-3"
},
{
variant: "subtle",
theme: "violet",
className: "text-ink-violet-4 bg-surface-violet-2 [a&]:hover:bg-surface-violet-3"
},
// Outline badge
{
variant: "outline",
theme: "gray",
className: "text-ink-gray-6 border-outline-gray-2 [a&]:hover:bg-surface-gray-2"
},
{
variant: "outline",
theme: "blue",
className: "text-ink-blue-4 border-outline-blue-2 [a&]:hover:bg-surface-blue-2"
},
{
variant: "outline",
theme: "green",
className: "text-ink-green-4 border-outline-green-2 [a&]:hover:bg-surface-green-2"
},
{
variant: "outline",
theme: "orange",
className: "text-ink-amber-4 border-outline-amber-2 [a&]:hover:bg-surface-amber-2"
},
{
variant: "outline",
theme: "red",
className: "text-ink-red-4 border-outline-red-2 [a&]:hover:bg-surface-red-2"
},
{
variant: "outline",
theme: "violet",
className: "text-ink-violet-4 border-outline-violet-2 [a&]:hover:bg-surface-violet-2"
},
// Ghost badge
{
variant: "ghost",
theme: "gray",
className: "text-ink-gray-6"
},
{
variant: "ghost",
theme: "blue",
className: "text-ink-blue-4"
},
{
variant: "ghost",
theme: "green",
className: "text-ink-green-4"
},
{
variant: "ghost",
theme: "orange",
className: "text-ink-amber-4"
},
{
variant: "ghost",
theme: "red",
className: "text-ink-red-4"
},
{
variant: "ghost",
theme: "violet",
className: "text-ink-violet-4"
}
],
defaultVariants: {
variant: "subtle",
size: "md",
theme: "gray",
},
}
)
function Badge({
className,
variant = "subtle",
size = "md",
theme = "gray",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
data-size={size}
data-theme={theme}
className={cn(badgeVariants({ variant, size, theme }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -1,109 +0,0 @@
import * as React from "react"
import { MoreHorizontal } from "lucide-react"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-ink-gray-5 flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("text-ink-gray-5 font-medium text-lg hover:text-ink-gray-7 active:text-ink-gray-7 transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-ink-gray-8 text-lg font-medium text-balance tracking-wide", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <span className="text-ink-gray-4 text-base">/</span>}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

Some files were not shown because too many files have changed in this diff Show More