Compare commits

..

623 Commits

Author SHA1 Message Date
Frappe PR Bot
e1782901e4 chore(release): Bumped to Version 16.7.2
## [16.7.2](https://github.com/frappe/erpnext/compare/v16.7.1...v16.7.2) (2026-02-26)

### Bug Fixes

* broekn link of docs in asset onboarding ([5d2822d](5d2822df97))
2026-02-26 06:48:40 +00:00
rohitwaghchaure
7c8e071d24 Merge pull request #52986 from frappe/mergify/bp/version-16/pr-52985
fix: broken link of docs in asset onboarding (backport #52983) (backport #52985)
2026-02-26 12:16:36 +05:30
Frappe PR Bot
5d86e9697a chore(release): Bumped to Version 16.7.1
## [16.7.1](https://github.com/frappe/erpnext/compare/v16.7.0...v16.7.1) (2026-02-26)

### Bug Fixes

* patch to complete onboarding stpes for existing records ([c3700f1](c3700f1b31))
2026-02-26 06:03:21 +00:00
rohitwaghchaure
19143bfe9b Merge branch 'version-16' into mergify/bp/version-16/pr-52985 2026-02-26 11:33:03 +05:30
rohitwaghchaure
2b664387d7 Merge pull request #52984 from frappe/mergify/bp/version-16/pr-52969
fix: patch to complete onboarding stpes for existing records (backport #52967) (backport #52969)
2026-02-26 11:31:45 +05:30
rohitwaghchaure
38c49dfed6 chore: fix conflicts
Updated the modified date in learn_asset.json.
2026-02-26 11:31:16 +05:30
rohitwaghchaure
6bebf51087 chore: fix conflicts 2026-02-26 11:30:57 +05:30
Rohit Waghchaure
5d2822df97 fix: broekn link of docs in asset onboarding
(cherry picked from commit 1cdf439e38)
(cherry picked from commit 5c48f74d4c)

# Conflicts:
#	erpnext/assets/module_onboarding/asset_onboarding/asset_onboarding.json
#	erpnext/assets/onboarding_step/learn_asset/learn_asset.json
2026-02-26 05:54:16 +00:00
rohitwaghchaure
76d27877c2 chore: fix conflicts
(cherry picked from commit 8f922382e4)
2026-02-26 05:21:53 +00:00
Rohit Waghchaure
c3700f1b31 fix: patch to complete onboarding stpes for existing records
(cherry picked from commit d90ec49241)

# Conflicts:
#	erpnext/patches.txt
(cherry picked from commit 941a78e1a8)
2026-02-26 05:21:53 +00:00
Frappe PR Bot
ca9f9de39f chore(release): Bumped to Version 16.7.0
# [16.7.0](https://github.com/frappe/erpnext/compare/v16.6.1...v16.7.0) (2026-02-25)

### Bug Fixes

* **`fiscal_year_company`:** made `company` field mandatory ([54bed64](54bed64356))
* **`fiscal_year`:** `Fiscal Year` auto-generation and notification ([92c2c7b](92c2c7bf82))
* Add handling for Sales Invoice Item quantity field ([f6a1ea8](f6a1ea804a))
* add missing property_type ([dd41f2c](dd41f2ceb7))
* add purchase invoice as well ([2d864bb](2d864bb599))
* addresses portal (backport [#52712](https://github.com/frappe/erpnext/issues/52712)) ([#52786](https://github.com/frappe/erpnext/issues/52786)) ([d7b61b9](d7b61b945f))
* **asset:** handle partial asset sales by splitting remaining quantity (backport [#51363](https://github.com/frappe/erpnext/issues/51363)) ([#52394](https://github.com/frappe/erpnext/issues/52394)) ([10be8f1](10be8f19e2))
* avoid duplicate taxes and charges rows in payment entry (backport [#52178](https://github.com/frappe/erpnext/issues/52178)) ([#52319](https://github.com/frappe/erpnext/issues/52319)) ([e6f7a7e](e6f7a7e979))
* better permissions on make payment request ([a5c83dd](a5c83dd11e))
* bug with comparison regarding `None` values and empty string ([66d1b7c](66d1b7c837))
* check gl account of an associated bank account in bank transaction ([746b5d9](746b5d96de))
* enfore permission on make_payment_request ([8184599](81845992a6))
* fiscal year notification subject ([e8c5d57](e8c5d5710d))
* get employee email with priority if preferred is not set ([116361c](116361c1dc))
* ignore permissions instead of saving parent ([715dbc0](715dbc0093))
* inconsistent label name between parent and child ([b56c444](b56c444f18))
* item code shows undefined ([0fd9fc4](0fd9fc48f3))
* link field displays incorrect value when empty ([b67d42e](b67d42ee58))
* **manufacturing:** remove delete query of job card & batch and serial no  ([#52840](https://github.com/frappe/erpnext/issues/52840)) ([9ff924e](9ff924e831))
* **manufacturing:** set pick list purpose while creating it from work order ([131e279](131e279a0c))
* **manufacturing:** update status for work order before calculating planned qty ([61d339c](61d339cfa7))
* material request on receive notification condition ([785773b](785773b0ac))
* permission issue for quotation item during update item ([bccca6f](bccca6f58e))
* populate doctypes to be ignored table in validate ([237e458](237e4583e2))
* prevent precision errors in  discount distribution with inclusive tax ([0e2e89c](0e2e89c355))
* **Purchase Receipt:** copy project from first row when adding items ([77da330](77da3306b5))
* remove form tour for sales and purchase order ([aac7fc3](aac7fc30d5))
* remove supplier invoice date/posting date validation ([cfad7e1](cfad7e17f1))
* reservation based on field should be read only in SRE ([a499b7e](a499b7e046))
* sales and purchase modules forms clean-up (backport [#52875](https://github.com/frappe/erpnext/issues/52875)) ([#52911](https://github.com/frappe/erpnext/issues/52911)) ([2177098](217709836b))
* **sales-order:** update quotation status while cancelling sales order ([#52822](https://github.com/frappe/erpnext/issues/52822)) ([3a0f90c](3a0f90c433))
* sensible insufficient stock message in pick list ([dbe2a87](dbe2a87a84))
* setup fails to set abbr to departments ([b15db05](b15db05ef8))
* skip empty dimension values in exchange gain loss ([780b626](780b626ae5))
* standalone sales invoice return should not fallback to item master for valuation rate ([6e1a808](6e1a8083a5))
* supplier_type appears multiple times ([9a2eb91](9a2eb91eec))
* test cases related to default letterhead change ([1e64dea](1e64dea6a0))
* typo ([d0c2cc8](d0c2cc848c))
* typo ([0c4b9ea](0c4b9ea9ca))
* unable to submit subcontracting order if created from material request ([c5bc92d](c5bc92d50e))
* unhide book_advance_payments_in_separate_party_account check field in Payment Entry doctype ([13d153c](13d153c196))
* update items fetches wrong item code ([3355c60](3355c60c79))
* update modified timestamp in json ([74a3965](74a3965a12))
* use stock qty instead of qty when updating transferred qty in WO ([90e61ab](90e61abff4))
* user permission on reports (backport [#52709](https://github.com/frappe/erpnext/issues/52709)) ([#52757](https://github.com/frappe/erpnext/issues/52757)) ([893683a](893683a512))
* **work_order:** update returned qty ([c4ba3c9](c4ba3c9c4b))

### Features

* default letterhead and print format ([4adcc1c](4adcc1c521))
* **Journal Entry Account:** add Bank Transaction as Reference Type (backport [#52760](https://github.com/frappe/erpnext/issues/52760)) ([#52816](https://github.com/frappe/erpnext/issues/52816)) ([2c5bdef](2c5bdefd13))
* module onboarding ([858119f](858119f907))
* **payment request:** create payment request as per payment schedules ([751a081](751a081253))
* **payment_request:** add option to calculate request amount using payment schedule ([298ea33](298ea33922))
* retrieve employee basic contact information ([f701407](f701407e23))
* retrieve employee contact details ([94f9f8b](94f9f8b30a))
* standard print format for Sales Order and Purchase Invoice ([222f51b](222f51b4d0))
2026-02-25 06:34:28 +00:00
ruthra kumar
44b935b845 Merge pull request #52925 from frappe/version-16-hotfix
chore: release v16
2026-02-25 12:01:35 +05:30
Mihir Kandoi
16e29d8571 Merge pull request #52944 from frappe/mergify/bp/version-16-hotfix/pr-52942 2026-02-25 11:04:43 +05:30
Mihir Kandoi
5db413d802 Merge pull request #52946 from frappe/mergify/bp/version-16-hotfix/pr-52945
fix: item code shows undefined (backport #52945)
2026-02-25 10:49:46 +05:30
Mihir Kandoi
0fd9fc48f3 fix: item code shows undefined
(cherry picked from commit 9ef7f05712)
2026-02-25 04:55:59 +00:00
Mihir Kandoi
265342d40a chore: clearer description for internal transfer at arms length
(cherry picked from commit bd9e5e97d7)
2026-02-25 04:43:22 +00:00
Khushi Rawat
cc604d4921 Merge pull request #52938 from frappe/mergify/bp/version-16-hotfix/pr-52889
feat: default letterhead and print format (backport #52889)
2026-02-25 00:39:54 +05:30
Khushi Rawat
1311664f52 Merge pull request #52939 from frappe/mergify/bp/version-16-hotfix/pr-52861
feat: standard print format for Sales Order and Purchase Invoice (backport #52861)
2026-02-25 00:37:37 +05:30
khushi8112
f4ec356dfb refactor: add translation and fix typo
(cherry picked from commit cbea4493c1)
2026-02-24 15:53:41 +00:00
khushi8112
222f51b4d0 feat: standard print format for Sales Order and Purchase Invoice
(cherry picked from commit 371efce88a)
2026-02-24 15:53:41 +00:00
khushi8112
1e64dea6a0 fix: test cases related to default letterhead change
(cherry picked from commit 8a2cb96c2a)
2026-02-24 15:52:54 +00:00
khushi8112
b21acec711 test: debugging the issue
(cherry picked from commit 570f574758)
2026-02-24 15:52:54 +00:00
khushi8112
dd41f2ceb7 fix: add missing property_type
(cherry picked from commit fbf5529ddd)
2026-02-24 15:52:54 +00:00
khushi8112
4adcc1c521 feat: default letterhead and print format
(cherry picked from commit 0ea22f9796)
2026-02-24 15:52:53 +00:00
Mihir Kandoi
6975fa185c Merge pull request #52937 from frappe/mergify/bp/version-16-hotfix/pr-52724 2026-02-24 20:57:08 +05:30
ljain112
6902fd6b33 refactor: use postprocess in mapped_doc to update items in subcontracting controller
(cherry picked from commit 1d3d09f48c)
2026-02-24 15:07:59 +00:00
ruthra kumar
9336f1f9a4 Merge pull request #52934 from frappe/mergify/bp/version-16-hotfix/pr-51777
feat: making payment requests based on payment schedule (backport #51777)
2026-02-24 18:55:13 +05:30
ruthra kumar
aaba93bd03 Merge pull request #52933 from frappe/mergify/bp/version-16-hotfix/pr-52824
refactor: separate construction of chart related data from `get_columns()` (backport #52824)
2026-02-24 18:46:27 +05:30
Jatin3128
751a081253 feat(payment request): create payment request as per payment schedules
(cherry picked from commit e476dff842)
2026-02-24 13:09:27 +00:00
Jatin3128
298ea33922 feat(payment_request): add option to calculate request amount using payment schedule
(cherry picked from commit 60108590b0)
2026-02-24 13:09:26 +00:00
ruthra kumar
807463e90c Merge pull request #52929 from frappe/mergify/bp/version-16-hotfix/pr-52029
fix: prevent precision errors in  discount distribution with inclusive tax (backport #52029)
2026-02-24 18:39:16 +05:30
Harsh Patadia
27c03fee1c refactor: separate construction of chart related data from get_columns() (#52824)
* fix: avoid hardcoded column slicing for Profit & Loss chart data

* refactor: improve parameter naming and reduce code repetion by using same function get_period_columns()

* refactor: improved parameter naming in get_data() and get_chart_data()

(cherry picked from commit bdcb2c1512)
2026-02-24 12:37:51 +00:00
rohitwaghchaure
ffe77ba1e7 Merge pull request #52931 from frappe/mergify/bp/version-16-hotfix/pr-52930
fix: remove form tour for sales and purchase order (backport #52930)
2026-02-24 17:45:33 +05:30
Rohit Waghchaure
aac7fc30d5 fix: remove form tour for sales and purchase order
(cherry picked from commit ed7315d78e)
2026-02-24 11:53:56 +00:00
ljain112
0e2e89c355 fix: prevent precision errors in discount distribution with inclusive tax
(cherry picked from commit 2068299766)
2026-02-24 11:29:05 +00:00
rohitwaghchaure
d8f6a007a0 Merge pull request #52922 from frappe/mergify/bp/version-16-hotfix/pr-52839
feat: module onboarding (backport #52839)
2026-02-24 16:09:40 +05:30
Rohit Waghchaure
858119f907 feat: module onboarding
(cherry picked from commit 792a1a7ab7)
2026-02-24 07:20:09 +00:00
ruthra kumar
033c9fc57d Merge pull request #52920 from frappe/mergify/bp/version-16-hotfix/pr-52363
fix: unhide book_advance_payments_in_separate_party_account check fie… (backport #52363)
2026-02-24 12:21:48 +05:30
ruthra kumar
d743ee9e1c Merge pull request #52919 from frappe/mergify/bp/version-16-hotfix/pr-52822
fix(sales-order): update quotation status while cancelling sales order (backport #52822)
2026-02-24 12:11:58 +05:30
Shllokkk
13d153c196 fix: unhide book_advance_payments_in_separate_party_account check field in Payment Entry doctype
(cherry picked from commit 5793322c30)
2026-02-24 06:26:55 +00:00
Sowmya
3a0f90c433 fix(sales-order): update quotation status while cancelling sales order (#52822)
* fix(sales-order): update quotation status while cancelling sales order

* test: validate quotation status

* chore: remove submit

(cherry picked from commit d638f3e033)
2026-02-24 06:18:37 +00:00
mergify[bot]
217709836b fix: sales and purchase modules forms clean-up (backport #52875) (#52911)
* fix: sales and purchase modules forms clean-up

(cherry picked from commit 0e356dc2e3)

# Conflicts:
#	erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json
#	erpnext/accounts/doctype/sales_invoice/sales_invoice.json
#	erpnext/buying/doctype/purchase_order/purchase_order.json
#	erpnext/selling/doctype/sales_order/sales_order.json
#	erpnext/stock/doctype/delivery_note/delivery_note.json

* chore: fix conflicts

* chore: fix conflicts

Removed unnecessary fields and updated the modified date.

* chore: fix conflicts

* chore: fix conflicts

* chore: fix conflicts

Removed UTM Analytics section and column break from delivery note JSON configuration. Updated the modified timestamp.

---------

Co-authored-by: Rohit Waghchaure <rohitw1991@gmail.com>
2026-02-24 11:28:34 +05:30
ruthra kumar
3663fc4f64 Merge pull request #52908 from frappe/mergify/bp/version-16-hotfix/pr-52896
fix: skip empty dimension values in exchange gain loss (backport #52896)
2026-02-23 21:24:31 +05:30
ravibharathi656
780b626ae5 fix: skip empty dimension values in exchange gain loss
(cherry picked from commit 7df9d951c6)
2026-02-23 15:36:47 +00:00
Mihir Kandoi
bbf1a94024 Merge pull request #52906 from frappe/mergify/bp/version-16-hotfix/pr-52544
fix(stock): improve error message when serial no is reserved via SRE (backport #52544)
2026-02-23 21:03:12 +05:30
Mihir Kandoi
8a44638939 Merge pull request #52904 from frappe/mergify/bp/version-16-hotfix/pr-52764
fix: populate doctypes to be ignored table in validate (backport #52764)
2026-02-23 20:45:09 +05:30
Imesha Sudasingha
fb8f085885 Merge pull request #52544 from one-highflyer/fix/improve-reserved-serial-no-error-message
fix(stock): improve error message when serial no is reserved via SRE

(cherry picked from commit 71248ff40b)
2026-02-23 15:13:17 +00:00
l0gesh29
237e4583e2 fix: populate doctypes to be ignored table in validate
(cherry picked from commit 60d2f2d304)
2026-02-23 14:51:15 +00:00
Diptanil Saha
d71a0d76e6 Merge pull request #52902 from frappe/mergify/bp/version-16-hotfix/pr-52891
fix: notifications (backport #52891)
2026-02-23 19:53:07 +05:30
diptanilsaha
e8c5d5710d fix: fiscal year notification subject
(cherry picked from commit 3e87059939)
2026-02-23 14:08:13 +00:00
diptanilsaha
785773b0ac fix: material request on receive notification condition
(cherry picked from commit 96aa37eff5)
2026-02-23 14:08:13 +00:00
Mihir Kandoi
c51bc69d58 Merge pull request #52901 from frappe/mergify/bp/version-16-hotfix/pr-52892
fix: link field displays incorrect value when empty (backport #52892)
2026-02-23 19:11:48 +05:30
Mihir Kandoi
b67d42ee58 fix: link field displays incorrect value when empty
(cherry picked from commit db00860662)
2026-02-23 13:37:44 +00:00
Mihir Kandoi
62f587318c Merge pull request #52898 from frappe/mergify/bp/version-16-hotfix/pr-52878
fix: standalone sales invoice return should not fallback to item mast… (backport #52878)
2026-02-23 19:07:01 +05:30
Mihir Kandoi
6e1a8083a5 fix: standalone sales invoice return should not fallback to item master for valuation rate
(cherry picked from commit a85a0aef52)
2026-02-23 13:19:55 +00:00
ruthra kumar
407bf7ec2e Merge pull request #52895 from frappe/mergify/bp/version-16-hotfix/pr-52812
fix: bank account mismatch error on reverse transaction reconciliation (backport #52812)
2026-02-23 18:28:38 +05:30
Diptanil Saha
4ac4292de4 Merge pull request #52893 from frappe/mergify/bp/version-16-hotfix/pr-50301
refactor:  fetch employee contact details in realtime in Payment Entry (backport #50301)
2026-02-23 18:11:21 +05:30
ervishnucs
746b5d96de fix: check gl account of an associated bank account in bank transaction
(cherry picked from commit 8fe0bf4ba3)
2026-02-23 12:36:45 +00:00
Abdeali Chharchhoda
5c9f9517d6 refactor: method to get employee contact without permission check
(cherry picked from commit 58cdb9503b)
2026-02-23 12:22:57 +00:00
Abdeali Chharchhoda
63975c8c00 refactor: use common method to get employee contacts
(cherry picked from commit ec1eb6d222)
2026-02-23 12:22:57 +00:00
Abdeali Chharchhoda
116361c1dc fix: get employee email with priority if preferred is not set
(cherry picked from commit 7b89c12470)
2026-02-23 12:22:57 +00:00
Abdeali Chharchhoda
0f8f8c2066 refactor: add validation for missing employee parameter
(cherry picked from commit b8e06b9636)
2026-02-23 12:22:56 +00:00
Abdeali Chharchhoda
123b7191fc refactor: fetch employee contact details in realtime
(cherry picked from commit 2ea6508fa5)
2026-02-23 12:22:56 +00:00
Abdeali Chharchhoda
94f9f8b30a feat: retrieve employee contact details
(cherry picked from commit a41297d841)
2026-02-23 12:22:56 +00:00
Abdeali Chharchhoda
f701407e23 feat: retrieve employee basic contact information
(cherry picked from commit 4ad1474e32)
2026-02-23 12:22:56 +00:00
Abdeali Chharchhoda
2b9af6a641 chore: Removing unused import
(cherry picked from commit 87c59f471c)
2026-02-23 12:22:55 +00:00
ruthra kumar
79cf614118 Merge pull request #52887 from frappe/mergify/bp/version-16-hotfix/pr-52840
fix(manufacturing): remove delete query of job card & batch and serial no  (backport #52840)
2026-02-23 17:39:28 +05:30
Sudharsanan Ashok
9ff924e831 fix(manufacturing): remove delete query of job card & batch and serial no (#52840)
* fix(manufacturing): remove delete query of batch and serial no

* fix(manufacturing): remove delete query of job card

* fix: remove delete function call for work order

(cherry picked from commit 8b2a971019)
2026-02-23 11:29:36 +00:00
mergify[bot]
8437355072 Merge pull request #52876 from frappe/mergify/bp/version-16-hotfix/pr-52399
refactor: Better organizing of the fields in various doctypes (backport #52399)
2026-02-23 16:54:54 +05:30
Khushi Rawat
1f82be4383 Merge pull request #52881 from frappe/mergify/bp/version-16-hotfix/pr-52879
fix: typo (backport #52879)
2026-02-23 16:04:05 +05:30
mergify[bot]
e6f7a7e979 fix: avoid duplicate taxes and charges rows in payment entry (backport #52178) (#52319)
Co-authored-by: Dharanidharan S <dharanidharans1328@gmail.com>
fix: avoid duplicate taxes and charges rows in payment entry (#52178)
2026-02-23 10:21:06 +00:00
Mihir Kandoi
c2666bbcac Merge pull request #52883 from frappe/mergify/bp/version-16-hotfix/pr-52880
fix(work_order): update returned qty on work order (backport #52880)
2026-02-23 15:49:48 +05:30
Khushi Rawat
74a3965a12 fix: update modified timestamp in json 2026-02-23 15:44:59 +05:30
Pandiyan37
c4ba3c9c4b fix(work_order): update returned qty
(cherry picked from commit b7f45e6963)
2026-02-23 10:04:09 +00:00
mahsem
d0c2cc848c fix: typo
(cherry picked from commit 2b72aab671)
2026-02-23 09:54:15 +00:00
Mihir Kandoi
af4c626238 Merge pull request #52874 from frappe/mergify/bp/version-16-hotfix/pr-52871
fix: use stock qty instead of qty when updating transferred qty in WO (backport #52871)
2026-02-23 14:55:47 +05:30
rohitwaghchaure
aab7759afc Merge pull request #52872 from frappe/mergify/bp/version-16-hotfix/pr-52504
refactor: form cleanup for sales order (backport #52504)
2026-02-23 14:01:39 +05:30
Mihir Kandoi
90e61abff4 fix: use stock qty instead of qty when updating transferred qty in WO
(cherry picked from commit 8e14249335)
2026-02-23 07:53:20 +00:00
ruthra kumar
d1c142cce8 refactor: form cleanup for sales order
(cherry picked from commit 93d1716eb5)
2026-02-23 07:49:58 +00:00
rohitwaghchaure
54e2fa0231 Merge pull request #52868 from frappe/mergify/bp/version-16-hotfix/pr-52534
refactor: supplier form cleanup (backport #52534)
2026-02-23 12:12:57 +05:30
rohitwaghchaure
ddfdc1a4e7 Merge pull request #52867 from frappe/mergify/bp/version-16-hotfix/pr-52508
refactor: form cleanup for stock and manufacturing doctypes (backport #52508)
2026-02-23 12:12:43 +05:30
rohitwaghchaure
4b0d2558d7 chore: fix conflicts
Refactor purchase order JSON structure by cleaning up field order and removing unnecessary fields.
2026-02-23 11:30:53 +05:30
rohitwaghchaure
7223acf266 Merge pull request #52866 from frappe/mergify/bp/version-16-hotfix/pr-52469
refactor: form cleanup for master doctypes related to stock module (backport #52469)
2026-02-23 11:27:27 +05:30
rohitwaghchaure
5b30e6e96b Merge pull request #52865 from frappe/mergify/bp/version-16-hotfix/pr-52392
refactor: Cleanup buying module related doctypes (backport #52392)
2026-02-23 11:26:51 +05:30
rohitwaghchaure
8cd12e37cd chore: fix conflicts 2026-02-23 11:26:31 +05:30
rohitwaghchaure
9a2eb91eec fix: supplier_type appears multiple times 2026-02-23 11:24:13 +05:30
rohitwaghchaure
7a680e6070 chore: fix conflicts
Refactored supplier form by reorganizing field order and removing unnecessary fields.
2026-02-23 11:05:07 +05:30
rohitwaghchaure
5e31eb3d77 chore: fix conflicts 2026-02-23 10:58:12 +05:30
Rohit Waghchaure
3eb838a6a2 refactor: supplier form cleanup
(cherry picked from commit bd521d9089)

# Conflicts:
#	erpnext/buying/doctype/supplier/supplier.json
2026-02-23 05:28:09 +00:00
Rohit Waghchaure
575fd4988b refactor: form cleanup for stock and manufacturing doctypes
(cherry picked from commit bf20ecca60)

# Conflicts:
#	erpnext/buying/doctype/purchase_order/purchase_order.json
2026-02-23 05:27:39 +00:00
Rohit Waghchaure
24fbd8add9 refactor: form cleanup for master doctypes related to stock module
(cherry picked from commit 6c49d5dc7d)
2026-02-23 05:27:10 +00:00
Rohit Waghchaure
91e9867fb1 refactor: Cleanup buying module forms
(cherry picked from commit f3ea1863ae)

# Conflicts:
#	erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json
2026-02-23 05:26:46 +00:00
Mihir Kandoi
069d6d5269 Merge pull request #52863 from frappe/mergify/bp/version-16-hotfix/pr-52803
fix(manufacturing): update closed status for current work order before calculating planned qty (backport #52803)
2026-02-23 07:47:08 +05:30
Sudharsanan11
d9d76fceeb test(manufacturing): add test to validate the planned qty
(cherry picked from commit cfbdfcf515)
2026-02-23 02:01:42 +00:00
Sudharsanan11
61d339cfa7 fix(manufacturing): update status for work order before calculating planned qty
(cherry picked from commit 4d40c84a31)
2026-02-23 02:01:41 +00:00
MochaMind
843c0d4acf chore: update POT file (#52858) 2026-02-22 13:19:38 +01:00
mergify[bot]
2c5bdefd13 feat(Journal Entry Account): add Bank Transaction as Reference Type (backport #52760) (#52816)
* feat: add Bank Transaction as Reference Type to Journal Entry Account (#52760)

* feat: add Bank Transaction as Reference Type to Journal Entry Account

* fix: take care of existing property setters

* fix: cancelling Bank Transactions should still be possible

* fix: handle blank options in patch

* fix: hide Reference Due Date for Bank Transaction

(cherry picked from commit 387fb1b202)

# Conflicts:
#	erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json

* chore: resolve conflicts

---------

Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-02-21 15:43:47 +01:00
Diptanil Saha
a4ee85e89c Merge pull request #52848 from frappe/mergify/bp/version-16-hotfix/pr-52842
refactor: `Fiscal Year` cleanup (backport #52842)
2026-02-21 13:00:22 +05:30
diptanilsaha
92c2c7bf82 fix(fiscal_year): Fiscal Year auto-generation and notification
(cherry picked from commit 4c76786ce4)
2026-02-21 07:13:05 +00:00
diptanilsaha
54bed64356 fix(fiscal_year_company): made company field mandatory
(cherry picked from commit 94fb7e11b4)
2026-02-21 07:13:05 +00:00
diptanilsaha
82170dfd07 refactor: Fiscal Year DocType cleanup
(cherry picked from commit 74ac28fc70)
2026-02-21 07:13:04 +00:00
Mihir Kandoi
595b5e7dae Merge pull request #52847 from frappe/mergify/bp/version-16-hotfix/pr-52845 2026-02-21 12:41:15 +05:30
Mihir Kandoi
cfad7e17f1 fix: remove supplier invoice date/posting date validation
(cherry picked from commit 7cff0ba626)
2026-02-21 06:55:30 +00:00
Mihir Kandoi
00ebeec94e Merge pull request #52837 from frappe/mergify/bp/version-16-hotfix/pr-52835
fix: inconsistent label name between parent and child (backport #52835)
2026-02-20 17:17:01 +05:30
Mihir Kandoi
b56c444f18 fix: inconsistent label name between parent and child
(cherry picked from commit d6e1ca0f10)
2026-02-20 11:28:53 +00:00
Mihir Kandoi
9d2ccd7246 Merge pull request #52827 from frappe/mergify/bp/version-16-hotfix/pr-52821
fix: sensible insufficient stock message in pick list (backport #52821)
2026-02-20 14:40:55 +05:30
Mihir Kandoi
d06e611c72 Merge pull request #52829 from frappe/mergify/bp/version-16-hotfix/pr-52825
fix: update items fetches wrong item code (backport #52825)
2026-02-20 14:27:47 +05:30
Mihir Kandoi
3355c60c79 fix: update items fetches wrong item code
(cherry picked from commit ba96d37c11)
2026-02-20 08:54:31 +00:00
Mihir Kandoi
dbe2a87a84 fix: sensible insufficient stock message in pick list
(cherry picked from commit 1352dc79bb)
2026-02-20 08:53:52 +00:00
Mihir Kandoi
f78317a79a Merge pull request #52819 from frappe/mergify/bp/version-16-hotfix/pr-52811
fix: permission issue for quotation item during update item (backport #52811)
2026-02-19 23:22:37 +05:30
Mihir Kandoi
0c4b9ea9ca fix: typo
(cherry picked from commit 732c98b72f)
2026-02-19 17:28:53 +00:00
Mihir Kandoi
715dbc0093 fix: ignore permissions instead of saving parent
(cherry picked from commit 6342e9a3e2)
2026-02-19 17:28:53 +00:00
Nishka Gosalia
bccca6f58e fix: permission issue for quotation item during update item
(cherry picked from commit 58b8af0fa8)
2026-02-19 17:28:53 +00:00
Mihir Kandoi
72a0d82147 Merge pull request #52806 from frappe/mergify/bp/version-16-hotfix/pr-52804
fix(Purchase Receipt): copy project from first row when adding items (backport #52804)
2026-02-19 14:03:27 +05:30
Marc Ramser
77da3306b5 fix(Purchase Receipt): copy project from first row when adding items
Adds `items_add` method to copy expense_account, cost_center and project from first row to newly added items, matching Purchase Invoice behavior.

(cherry picked from commit 21423676c9)
2026-02-19 08:32:20 +00:00
Mihir Kandoi
636414c0e4 Merge pull request #52798 from frappe/mergify/bp/version-16-hotfix/pr-52792
fix: unable to submit subcontracting order if created from material r… (backport #52792)
2026-02-19 13:59:24 +05:30
Mihir Kandoi
c5bc92d50e fix: unable to submit subcontracting order if created from material request
(cherry picked from commit 37323480dd)
2026-02-19 13:44:30 +05:30
Mihir Kandoi
c669387b82 Merge pull request #52796 from frappe/mergify/bp/version-16-hotfix/pr-52794
fix: reservation based on field should be read only in SRE (backport #52794)
2026-02-19 12:57:07 +05:30
Mihir Kandoi
a499b7e046 fix: reservation based on field should be read only in SRE
(cherry picked from commit 21452b4c6e)
2026-02-19 05:10:44 +00:00
Frappe PR Bot
9a0b54c649 chore(release): Bumped to Version 16.6.1
## [16.6.1](https://github.com/frappe/erpnext/compare/v16.6.0...v16.6.1) (2026-02-19)

### Bug Fixes

* better permissions on make payment request ([78fc942](78fc9424d9))
2026-02-19 05:04:10 +00:00
ruthra kumar
1f78a9fa6c Merge pull request #52790 from frappe/mergify/bp/version-16/pr-52763
fix: better permissions on make payment request (backport #52763)
2026-02-19 10:32:36 +05:30
Mihir Kandoi
17062e0154 Merge pull request #52788 from frappe/mergify/bp/version-16-hotfix/pr-52490
fix: Add handling for Sales Invoice Item quantity field (backport #52490)
2026-02-19 10:15:48 +05:30
ruthra kumar
78fc9424d9 fix: better permissions on make payment request
(cherry picked from commit f36962fc58)
2026-02-19 04:32:31 +00:00
Mihir Kandoi
2d864bb599 fix: add purchase invoice as well
(cherry picked from commit 1fc2eddf6f)
2026-02-19 04:13:06 +00:00
Thomas antony
f6a1ea804a fix: Add handling for Sales Invoice Item quantity field
Add handling for Sales Invoice Item quantity field

(cherry picked from commit edfcaee99b)
2026-02-19 04:13:05 +00:00
mergify[bot]
d7b61b945f fix: addresses portal (backport #52712) (#52786)
* fix: addresses portal

(cherry picked from commit e317ab1479)

# Conflicts:
#	erpnext/patches.txt

* chore: resolve conflicts

---------

Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-02-19 04:11:04 +00:00
Mihir Kandoi
b6a2284003 Merge pull request #52775 from frappe/mergify/bp/version-16-hotfix/pr-52628
fix(manufacturing): set pick list purpose while creating it from work order (backport #52628)
2026-02-18 15:32:12 +05:30
Sudharsanan11
131e279a0c fix(manufacturing): set pick list purpose while creating it from work order
(cherry picked from commit 23ccc2a8c5)
2026-02-18 09:46:02 +00:00
ruthra kumar
cc7de7e66e Merge pull request #52768 from frappe/mergify/bp/version-16-hotfix/pr-52763
fix: better permissions on make payment request (backport #52763)
2026-02-18 12:46:20 +05:30
ruthra kumar
a5c83dd11e fix: better permissions on make payment request
(cherry picked from commit f36962fc58)
2026-02-18 06:58:42 +00:00
ruthra kumar
f5fa1ba02b Merge pull request #52766 from frappe/mergify/bp/version-16-hotfix/pr-52419
fix: enfore permission on make_payment_request (backport #52419)
2026-02-18 12:26:45 +05:30
ruthra kumar
81845992a6 fix: enfore permission on make_payment_request
(cherry picked from commit b755ca12ca)
2026-02-18 06:32:44 +00:00
mergify[bot]
893683a512 fix: user permission on reports (backport #52709) (#52757)
* fix: user permission on reports (#52709)

(cherry picked from commit c6a292f6a9)

# Conflicts:
#	erpnext/accounts/report/purchase_register/purchase_register.py
#	erpnext/accounts/report/sales_register/sales_register.py

* chore: resolve conflict

---------

Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-02-17 23:07:41 +05:30
Mihir Kandoi
59e2dbb435 Merge pull request #52754 from frappe/mergify/bp/version-16-hotfix/pr-52750
fix: setup fails to set abbr to departments (backport #52750)
2026-02-17 21:50:51 +05:30
Mihir Kandoi
35e9ca64ce Merge pull request #52752 from frappe/mergify/bp/version-16-hotfix/pr-52743
bug: fix comparison regarding `None` values (backport #52743)
2026-02-17 21:39:33 +05:30
Mihir Kandoi
b15db05ef8 fix: setup fails to set abbr to departments
(cherry picked from commit debe868950)
2026-02-17 15:57:50 +00:00
Markus Lobedann
66d1b7c837 fix: bug with comparison regarding None values and empty string
In their default state, the fields can be `None`. When a user enters something and deletes it afterwards, the fields contain an empty string.

This fixes the comparison.

(cherry picked from commit 3fd5a0f100)
2026-02-17 15:52:09 +00:00
mergify[bot]
10be8f19e2 fix(asset): handle partial asset sales by splitting remaining quantity (backport #51363) (#52394)
* fix(asset): handle partial asset sales by splitting remaining quantity

(cherry picked from commit 9a2710b9d7)

* fix: refactor older testcases

(cherry picked from commit a88fe2ecab)

* test: validate asset partial sales

(cherry picked from commit 9eeccb765d)

# Conflicts:
#	erpnext/assets/doctype/asset/test_asset.py

* fix(asset): skip purchase document validation while splitting existing asset

(cherry picked from commit e7e6567792)

* fix(asset): handle same asset being sold in multiple line items in sales invoice

(cherry picked from commit 23b094f151)

* test: validate asset split for auto created asset from purchase voucher

(cherry picked from commit 4adeaedfde)

# Conflicts:
#	erpnext/assets/doctype/asset/test_asset.py

* fix: use new_asset instead of asset_doc when checking values after splitting

(cherry picked from commit ca97f34092)

* fix: remove the redundant purchase receipt submit

(cherry picked from commit eeb6d0e9bf)

* chore: fix conflict

---------

Co-authored-by: Navin-S-R <navin@aerele.in>
2026-02-17 14:32:08 +00:00
Frappe PR Bot
372b0119bb chore(release): Bumped to Version 16.6.0
# [16.6.0](https://github.com/frappe/erpnext/compare/v16.5.0...v16.6.0) (2026-02-17)

### Bug Fixes

* allow non-stock items while updating items ([e2183eb](e2183ebde9))
* allow sequence id edit in BOM if routing is not set ([6f812cc](6f812ccaf5))
* better validation for negative batch ([e46e874](e46e8741b4))
* cancel SABB if SLE cancelled from LCV ([ac90975](ac90975f43))
* consider sle for negative stock validation ([ca8f324](ca8f324b51))
* consider table multiselect in delete transaction ([38679d6](38679d6d14))
* correct typos in marketing campaign custom fields function ([531bdbc](531bdbc727))
* do not allow plant floor company and warehouse to be updated ([a039c17](a039c176c8))
* ensure layout has Bootstrap row and column (backport [#52649](https://github.com/frappe/erpnext/issues/52649)) ([#52719](https://github.com/frappe/erpnext/issues/52719)) ([53e400c](53e400cca1))
* log changes made to accounts settings ([2200b9a](2200b9aa67))
* **manufacturing:** add sales order fields in subassembly child table ([605c0db](605c0db976))
* **manufacturing:** set sales order references in subassembly child table ([f4b0e64](f4b0e646b4))
* **pos_invoice:** add correct depends on condition ([#52689](https://github.com/frappe/erpnext/issues/52689)) ([28592d0](28592d0180))
* prevent rows from being added to sub_assembly_items and mr_items ([80c98cd](80c98cdcf4))
* production plan status ([62ea18f](62ea18f1cc))
* removed lost reason detail ([3c33a19](3c33a19634))
* **selling-workspace-sidebar:** changed order of pos profile ([1820c35](1820c35880))
* standalone credit/debit notes should not fetch any serial or batch by default ([dd4e186](dd4e1867f5))
* **stock:** remove hardcoded letter_head from report ([1d444e5](1d444e53eb)), closes [#52569](https://github.com/frappe/erpnext/issues/52569)
* total weight does not update when updating items ([46b5884](46b5884420))
* wrong display_depends_on condition for item group and brand child tables ([6ae1b18](6ae1b18616))

### Features

* Negative Batch report ([8649543](8649543ae0))
* show formatted currency symbol on ledger preview ([b844afe](b844afe0ec))
2026-02-17 14:10:20 +00:00
ruthra kumar
7a46fad6e7 Merge pull request #52731 from frappe/version-16-hotfix
chore: release v16
2026-02-17 19:38:48 +05:30
rohitwaghchaure
2ccb8c839d Merge pull request #52740 from frappe/mergify/bp/version-16-hotfix/pr-52729
feat: Negative Batch report (backport #52729)
2026-02-17 17:23:50 +05:30
Rohit Waghchaure
8649543ae0 feat: Negative Batch report
(cherry picked from commit 34edbed00b)
2026-02-17 11:04:33 +00:00
Mihir Kandoi
bed2c83272 Merge pull request #52736 from frappe/mergify/bp/version-16-hotfix/pr-52733
fix: allow sequence ID edit in BOM if routing is not set (backport #52733)
2026-02-17 16:28:39 +05:30
Mihir Kandoi
5461945d00 Merge pull request #52738 from frappe/mergify/bp/version-16-hotfix/pr-52677
fix: standalone credit/debit notes should not fetch any serial or bat… (backport #52677)
2026-02-17 16:25:36 +05:30
Mihir Kandoi
dd4e1867f5 fix: standalone credit/debit notes should not fetch any serial or batch by default
(cherry picked from commit 2017edca88)
2026-02-17 10:40:32 +00:00
Mihir Kandoi
6f812ccaf5 fix: allow sequence id edit in BOM if routing is not set
(cherry picked from commit 08529964b4)
2026-02-17 10:36:17 +00:00
ruthra kumar
6c4bba3992 Merge pull request #52727 from frappe/mergify/bp/version-16-hotfix/pr-52679
fix: log changes made to accounts settings (backport #52679)
2026-02-17 14:15:25 +05:30
ruthra kumar
eda479a917 Merge pull request #52725 from frappe/mergify/bp/version-16-hotfix/pr-52648
fix: correct typos in marketing campaign custom fields function (backport #52648)
2026-02-17 13:59:19 +05:30
AarDG10
2200b9aa67 fix: log changes made to accounts settings
(cherry picked from commit 45febbabd7)
2026-02-17 08:25:47 +00:00
Mihir Kandoi
af15050acc Merge pull request #52723 from frappe/mergify/bp/version-16-hotfix/pr-52720
fix: wrong display_depends_on condition for item group and brand chil… (backport #52720)
2026-02-17 13:43:00 +05:30
Abdeali Chharchhoda
531bdbc727 fix: correct typos in marketing campaign custom fields function
(cherry picked from commit 6b7fed7f59)
2026-02-17 08:12:00 +00:00
ruthra kumar
53e400cca1 fix: ensure layout has Bootstrap row and column (backport #52649) (#52719)
Merge pull request #52649 from aerele/fix-sales-funnel-layout

fix: ensure layout has Bootstrap row and column
(cherry picked from commit ae0be7f6ce)

Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
2026-02-17 13:04:21 +05:30
Shllokkk
6ae1b18616 fix: wrong display_depends_on condition for item group and brand child tables
(cherry picked from commit de2843d9f1)
2026-02-17 07:30:21 +00:00
Mihir Kandoi
a8f21dbc07 Merge pull request #52718 from frappe/mergify/bp/version-16-hotfix/pr-52716
fix: do not allow plant floor company and warehouse to be updated (backport #52716)
2026-02-17 12:32:45 +05:30
Vishnu Priya Baskaran
1dd471fb18 Merge pull request #52649 from aerele/fix-sales-funnel-layout
fix: ensure layout has Bootstrap row and column
(cherry picked from commit ae0be7f6ce)
2026-02-17 06:55:51 +00:00
Mihir Kandoi
a039c176c8 fix: do not allow plant floor company and warehouse to be updated
(cherry picked from commit fd72132743)
2026-02-17 06:42:33 +00:00
Mihir Kandoi
cf141045ba Merge pull request #52715 from frappe/mergify/bp/version-16-hotfix/pr-52713
fix: production plan status (backport #52713)
2026-02-17 11:41:43 +05:30
Mihir Kandoi
62ea18f1cc fix: production plan status
(cherry picked from commit b3e6b304e4)
2026-02-17 05:54:10 +00:00
Mihir Kandoi
302ae382f1 Merge pull request #52697 from frappe/mergify/bp/version-16-hotfix/pr-52678
chore: do not show stock details if update stock is disabled (backport #52678)
2026-02-17 10:55:49 +05:30
Mihir Kandoi
98ff54a871 chore: resolve conflicts 2026-02-17 10:40:34 +05:30
rohitwaghchaure
41b089ab51 Merge pull request #52705 from frappe/mergify/bp/version-16-hotfix/pr-52620
fix: prevent rows from being added to sub_assembly_items and mr_items (backport #52620)
2026-02-17 10:16:09 +05:30
Diptanil Saha
46acd328a9 Merge pull request #52707 from frappe/mergify/bp/version-16-hotfix/pr-52706
fix(selling-workspace-sidebar): changed order of pos profile (backport #52706)
2026-02-17 00:55:44 +05:30
diptanilsaha
1820c35880 fix(selling-workspace-sidebar): changed order of pos profile
(cherry picked from commit 72f4fd08ee)
2026-02-16 19:11:27 +00:00
Shllokkk
80c98cdcf4 fix: prevent rows from being added to sub_assembly_items and mr_items
(cherry picked from commit 25f979a825)
2026-02-16 18:43:01 +00:00
Mihir Kandoi
4a8363e7da Merge pull request #52703 from frappe/mergify/bp/version-16-hotfix/pr-52626
fix(manufacturing): add sales order fields in subassembly child table (backport #52626)
2026-02-16 23:58:13 +05:30
rohitwaghchaure
dcbc1e1303 Merge pull request #52701 from frappe/mergify/bp/version-16-hotfix/pr-52699
fix: consider sle for negative stock validation (backport #52699)
2026-02-16 23:56:30 +05:30
Sudharsanan11
ecfd193002 test(manufacturing): add test to validate the sales order references for sub assembly items
(cherry picked from commit 341dc4be7a)
2026-02-16 18:11:53 +00:00
Sudharsanan11
f4b0e646b4 fix(manufacturing): set sales order references in subassembly child table
(cherry picked from commit 0f2ed28ab7)
2026-02-16 18:11:52 +00:00
Sudharsanan11
605c0db976 fix(manufacturing): add sales order fields in subassembly child table
(cherry picked from commit c2282eaf08)
2026-02-16 18:11:52 +00:00
Rohit Waghchaure
ca8f324b51 fix: consider sle for negative stock validation
(cherry picked from commit 38f35acffe)
2026-02-16 17:56:51 +00:00
Mihir Kandoi
7677b2f573 chore: do not show serial batch selector if not needed
(cherry picked from commit cdc62e7327)

# Conflicts:
#	erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json
2026-02-16 17:01:48 +00:00
Mihir Kandoi
f08964683a chore: do not show stock details if update stock is disabled
(cherry picked from commit 4499e974a0)

# Conflicts:
#	erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json
2026-02-16 17:01:48 +00:00
rohitwaghchaure
d2eabcbf74 Merge pull request #52696 from frappe/mergify/bp/version-16-hotfix/pr-52691
fix: cancel SABB if SLE cancelled from LCV (backport #52691)
2026-02-16 21:52:33 +05:30
Diptanil Saha
7d8bbac5fd Merge pull request #52694 from frappe/mergify/bp/version-16-hotfix/pr-52689
fix(pos_invoice): add correct depends on condition (backport #52689)
2026-02-16 21:23:24 +05:30
Rohit Waghchaure
ac90975f43 fix: cancel SABB if SLE cancelled from LCV
(cherry picked from commit f23a49a25e)
2026-02-16 15:42:23 +00:00
Soham Kulkarni
28592d0180 fix(pos_invoice): add correct depends on condition (#52689)
* fix(pos_invoice): add correct depends on condition

* fix: show field in sales order

* refactor: eval condition

(cherry picked from commit 219cf6bc57)
2026-02-16 15:37:25 +00:00
rohitwaghchaure
de5e8a6e6b Merge pull request #52684 from frappe/mergify/bp/version-16-hotfix/pr-52681
fix: better validation for negative batch (backport #52681)
2026-02-16 16:02:55 +05:30
Rohit Waghchaure
e46e8741b4 fix: better validation for negative batch
(cherry picked from commit a8636e4f59)
2026-02-16 09:47:05 +00:00
MochaMind
4a4e9956e2 chore: update POT file (#52674) 2026-02-15 14:00:35 +01:00
Mihir Kandoi
8ec83f2080 Merge pull request #52672 from frappe/mergify/bp/version-16-hotfix/pr-52670 2026-02-15 14:30:52 +05:30
Mihir Kandoi
46b5884420 fix: total weight does not update when updating items
(cherry picked from commit 63323a2611)
2026-02-15 08:45:19 +00:00
Mihir Kandoi
3548073a07 Merge pull request #52669 from frappe/mergify/bp/version-16-hotfix/pr-52658
fix: allow non-stock items while updating items (backport #52658)
2026-02-15 13:07:37 +05:30
ervishnucs
e2183ebde9 fix: allow non-stock items while updating items
(cherry picked from commit 07db5941aa)
2026-02-15 07:28:48 +00:00
Mihir Kandoi
37f740caa6 Merge pull request #52668 from frappe/revert-52578-mergify/bp/version-16-hotfix/pr-51594
Revert "fix: Workspace sidebar links for Debit/Credit Notes (backport #51594)"
2026-02-15 12:44:37 +05:30
Mihir Kandoi
485c1b025a Revert "fix: Workspace sidebar links for Debit/Credit Notes (backport #51594)" 2026-02-15 12:29:28 +05:30
ruthra kumar
880cc50ae4 Merge pull request #52659 from frappe/mergify/bp/version-16-hotfix/pr-52643
fix: consider table multiselect in delete transaction (backport #52643)
2026-02-13 15:12:54 +05:30
SowmyaArunachalam
3c33a19634 fix: removed lost reason detail
(cherry picked from commit 9bb60405e7)

# Conflicts:
#	erpnext/patches.txt
2026-02-13 14:11:58 +05:30
SowmyaArunachalam
38679d6d14 fix: consider table multiselect in delete transaction
(cherry picked from commit be3d2422a7)
2026-02-13 08:37:46 +00:00
ruthra kumar
3744c32950 Merge pull request #52646 from frappe/mergify/bp/version-16-hotfix/pr-52644
refactor: use query builder for profitability analysis (backport #52644)
2026-02-12 14:40:46 +05:30
ruthra kumar
543e0131b5 refactor: use query builder for profitability analysis
(cherry picked from commit 5e34325604)
2026-02-12 08:55:03 +00:00
ruthra kumar
eb5ffcdc88 Merge pull request #52642 from frappe/mergify/bp/version-16-hotfix/pr-52640
refactor: use query builder for sales person commission summary (backport #52640)
2026-02-12 12:53:24 +05:30
ruthra kumar
f98e53692e refactor: use query builder for sales person commission summary
(cherry picked from commit 7105e3fb69)
2026-02-12 07:07:42 +00:00
ruthra kumar
e778eabcb0 Merge pull request #52639 from frappe/mergify/bp/version-16-hotfix/pr-52571
fix(stock): remove hardcoded letter_head from report (backport #52571)
2026-02-12 12:04:57 +05:30
Roxxane
1d444e53eb fix(stock): remove hardcoded letter_head from report
The 'Incorrect Serial and Batch Bundle' report had a hardcoded
letter_head value of 'Test', preventing users from deleting a
Letter Head named 'Test' due to link check.

Standard reports should not reference specific Letter Head names.

Fixes #52569

(cherry picked from commit 99cd29d88f)
2026-02-12 06:19:37 +00:00
ruthra kumar
3bf9aff67e Merge pull request #52638 from frappe/mergify/bp/version-16-hotfix/pr-52619
feat: show formatted currency symbol on ledger preview (backport #52619)
2026-02-12 11:48:49 +05:30
Navin-S-R
b844afe0ec feat: show formatted currency symbol on ledger preview
(cherry picked from commit 5c8cb1e7ec)
2026-02-12 05:59:06 +00:00
Frappe PR Bot
6e7e219f71 chore(release): Bumped to Version 16.5.0
# [16.5.0](https://github.com/frappe/erpnext/compare/v16.4.1...v16.5.0) (2026-02-11)

### Bug Fixes

* Added a missing option to the currency field (backport [#52528](https://github.com/frappe/erpnext/issues/52528)) ([#52587](https://github.com/frappe/erpnext/issues/52587)) ([1c3fe00](1c3fe000ba))
* Added validation for quality inspection in job card ([a1ec68c](a1ec68cd1e))
* apply composite asset logic only in draft ([d2387a3](d2387a3af8))
* **balance sheet:** removed the extra labels from the chart ([6a9a28b](6a9a28b4ae))
* **buying:** add supplier group link filters in field level ([9886b46](9886b46cb4))
* correctly calculate running balances for financial report ([7df18af](7df18af799))
* do not show update stock flag unneccessarily ([c0c6cc5](c0c6cc58ed))
* email campaign timeout issue (backport [#51994](https://github.com/frappe/erpnext/issues/51994)) ([#52556](https://github.com/frappe/erpnext/issues/52556)) ([e753df8](e753df8ff0))
* enabling skip delivery option for order type maintenance ([7a78e97](7a78e9705c))
* **gross profit report:** translate column Sales Invoice ([6ff8820](6ff8820732))
* **gross-profit:** handle item group filters ([0d02bbb](0d02bbb01a))
* **gross-profit:** handle returns outside sale period ([9b32c84](9b32c84462))
* handle gross profit and percentage for return invoices ([d081a26](d081a26608))
* item code is tuple with operation id ([9d14c0b](9d14c0b60e))
* **manufacturing:** fix chart period keys ([b99ca48](b99ca486d7))
* **manufacturing:** handle None value for actual_end_date ([b1b75ec](b1b75eca3d))
* **map_current_doc:** prevent mutation of query args in get_query (backport [#52202](https://github.com/frappe/erpnext/issues/52202)) ([#52584](https://github.com/frappe/erpnext/issues/52584)) ([b8256e5](b8256e5f31))
* move company field to first position in sales invoice, purchase invoice, sales order, purchase order and journal entry ([e53ccd0](e53ccd0745))
* not able to complete job card ([c5ff534](c5ff534d58))
* operation status and bom validation ([1122265](11222653ce))
* Period Closing Voucher doesn't exist for GL Entry ([921584c](921584c769))
* process loss error incorrectly thrown even when semi FG BOM does not have any process loss ([4a7ffce](4a7ffce320))
* **quotation:** ignore zero ordered_qty ([cf7c127](cf7c127dc6))
* rate comparison in stock reco ([1f78f45](1f78f45aee))
* remove customer_pos_id reference ([#52396](https://github.com/frappe/erpnext/issues/52396)) ([ab59f73](ab59f73064))
* remove incorrect validation from email digest throwing spurious error (backport [#51827](https://github.com/frappe/erpnext/issues/51827)) ([#52579](https://github.com/frappe/erpnext/issues/52579)) ([dffb6ac](dffb6ac4cf))
* return None instead of 0 if valuation rate is falsy ([64a7956](64a7956a4a))
* stock balance report issue ([62616ad](62616ad9e1))
* stock reservation created against job card ([305483e](305483e074))
* **stock:** add is group filter for warehouse fields ([23a26b5](23a26b540b))
* **stock:** ignore pos reserved batches for stock levels ([e2c1204](e2c12043ae))
* **stock:** inward stock for pick list test record ([801a26a](801a26ae67))
* **stock:** set source warehouse for issue type ([53e512c](53e512ceaf))
* **stock:** update target field attribute ([96dfecf](96dfecf0d5))
* test cases ([ece8d00](ece8d00415))
* validate asset movement transaction date ([#52340](https://github.com/frappe/erpnext/issues/52340)) ([898d2e3](898d2e3c9a))
* Workspace sidebar links for Debit/Credit Notes ([29d33b3](29d33b3139))

### Features

* **accounts:** expand Journal Entry Template to support dimensions and party ([#51621](https://github.com/frappe/erpnext/issues/51621)) ([d06a46a](d06a46ae85))
* allow negative stock for the batch item ([b6afe7f](b6afe7f4da))

### Reverts

* "fix: allow sales invoice to be renamed" ([abef910](abef9109b0))
2026-02-11 06:03:37 +00:00
ruthra kumar
ce225d87f3 Merge pull request #52597 from frappe/version-16-hotfix
chore: release v16
2026-02-11 11:32:00 +05:30
ruthra kumar
0d831aad41 Merge pull request #52605 from frappe/mergify/bp/version-16-hotfix/pr-52593
refactor: update labels for tax withholding reports columns to improve clarity (backport #52593)
2026-02-10 18:30:52 +05:30
ruthra kumar
00caebe90c Merge pull request #52397 from frappe/mergify/bp/version-16-hotfix/pr-52396
fix: remove customer_pos_id reference (backport #52396)
2026-02-10 18:28:49 +05:30
ruthra kumar
0ee97ffbbf Merge pull request #52414 from frappe/mergify/bp/version-16-hotfix/pr-51745
fix(gross profit report): translate column Sales Invoice (backport #51745)
2026-02-10 18:27:54 +05:30
ruthra kumar
4a6c428848 Merge pull request #52604 from frappe/mergify/bp/version-16-hotfix/pr-52017
fix(gross-profit): handle returns outside the given sale period (backport #52017)
2026-02-10 18:27:38 +05:30
Kavin
3e3daa50b1 Merge pull request #52542 from frappe/mergify/bp/version-16-hotfix/pr-52516
fix(stock): ignore pos reserved batches for stock levels (backport #52516)
2026-02-10 18:21:37 +05:30
ljain112
ac7a25fff9 refactor: update labels for tax withholding reports columns to improve clarity
(cherry picked from commit 2cfdcc1af4)
2026-02-10 12:43:06 +00:00
Navin-S-R
0d02bbb01a fix(gross-profit): handle item group filters
(cherry picked from commit 047b278791)
2026-02-10 12:41:30 +00:00
Navin-S-R
d01ea27f2f test: fix test assertions to use index-based totals
(cherry picked from commit fdfa7bc963)
2026-02-10 12:41:29 +00:00
Navin-S-R
345d25bdf1 test: validate sales person wise gross profit
(cherry picked from commit 3ab978ab46)
2026-02-10 12:41:29 +00:00
Navin-S-R
e8eaae4120 test: validate return invoice profit and profit percentage
(cherry picked from commit 4da3d43013)
2026-02-10 12:41:29 +00:00
Navin-S-R
d081a26608 fix: handle gross profit and percentage for return invoices
(cherry picked from commit 51709f032f)
2026-02-10 12:41:29 +00:00
Navin-S-R
9b32c84462 fix(gross-profit): handle returns outside sale period
(cherry picked from commit 67d8223f73)
2026-02-10 12:41:28 +00:00
Diptanil Saha
22c9b26a24 Merge pull request #52450 from frappe/mergify/bp/version-16-hotfix/pr-52360
fix: correctly calculate running balances for financial report (backport #52360)
2026-02-10 02:37:22 +05:30
mergify[bot]
1c3fe000ba fix: Added a missing option to the currency field (backport #52528) (#52587)
fix: Added a missing option to the currency field (#52528)

(cherry picked from commit da07f84e44)

Co-authored-by: El-Shafei H. <el.shafei.developer@gmail.com>
2026-02-09 21:01:04 +00:00
mergify[bot]
ffd9b248f6 refactor: drop usages of db_query (backport #52559) (#52565)
refactor: drop usages of db_query


(cherry picked from commit 1e45195ef9)

Signed-off-by: Akhil Narang <me@akhilnarang.dev>
Co-authored-by: Akhil Narang <me@akhilnarang.dev>
2026-02-10 01:47:17 +05:30
mergify[bot]
b8256e5f31 fix(map_current_doc): prevent mutation of query args in get_query (backport #52202) (#52584)
fix(map_current_doc): prevent mutation of query args in get_query (#52202)

(cherry picked from commit 23a73c9cdb)

Co-authored-by: V Shankar <shankarv292002@gmail.com>
2026-02-10 01:26:00 +05:30
Trusted Computer
dffb6ac4cf fix: remove incorrect validation from email digest throwing spurious error (backport #51827) (#52579) 2026-02-09 18:26:57 +00:00
mergify[bot]
e753df8ff0 fix: email campaign timeout issue (backport #51994) (#52556)
fix: email campaign timeout issue (#51994)

* fix: email campaign timeout issue

* refactor: email campaign backend logic

* refactor: use sendmail instead of manually batching

(cherry picked from commit 22123dd955)

Co-authored-by: Pratik Badhe <badhepd@gmail.com>
2026-02-09 23:45:02 +05:30
Mihir Kandoi
560fd2e0d1 Merge pull request #52581 from frappe/mergify/bp/version-16-hotfix/pr-52527
fix(stock): correct warehouse mapping for material issue (backport #52527)
2026-02-09 21:22:27 +05:30
Mihir Kandoi
327719a0fd Merge pull request #52578 from frappe/mergify/bp/version-16-hotfix/pr-51594
fix: Workspace sidebar links for Debit/Credit Notes (backport #51594)
2026-02-09 21:19:00 +05:30
Pandiyan37
801a26ae67 fix(stock): inward stock for pick list test record
(cherry picked from commit f22b9e297b)
2026-02-09 15:36:44 +00:00
Pandiyan37
8c4a9040b7 test(stock): add test to check from warehouse for issue type
(cherry picked from commit da0322e994)
2026-02-09 15:36:44 +00:00
Pandiyan37
53e512ceaf fix(stock): set source warehouse for issue type
(cherry picked from commit a34e8c99cd)
2026-02-09 15:36:43 +00:00
Mihir Kandoi
e9212c6a32 chore: resolve conflicts 2026-02-09 21:03:37 +05:30
Nabin Hait
29d33b3139 fix: Workspace sidebar links for Debit/Credit Notes
(cherry picked from commit 8acf373e68)

# Conflicts:
#	erpnext/workspace_sidebar/invoicing.json
2026-02-09 15:32:25 +00:00
rohitwaghchaure
7e0dc2ff86 Merge pull request #52561 from frappe/mergify/bp/version-16-hotfix/pr-52340
fix: validate asset movement transaction date (backport #52340)
2026-02-09 19:52:15 +05:30
Poojashree T R
898d2e3c9a fix: validate asset movement transaction date (#52340)
* fix: validate asset transaction date

* fix: validate asset transaction date

* fix: add translation in validate_transaction_date

* test: test_movement_transaction_date

* fix: to ensure test reliability

(cherry picked from commit e98b68c38f)
2026-02-09 11:52:19 +00:00
rohitwaghchaure
4c562b2903 Merge pull request #52558 from frappe/mergify/bp/version-16-hotfix/pr-52550
feat: allow negative stock for the batch item (backport #52550)
2026-02-09 16:40:12 +05:30
Mihir Kandoi
04d6273713 Merge pull request #52554 from frappe/mergify/bp/version-16-hotfix/pr-52501
fix(quotation): ignore zero ordered_qty (backport #52501)
2026-02-09 16:25:45 +05:30
Rohit Waghchaure
b6afe7f4da feat: allow negative stock for the batch item
(cherry picked from commit 376ab0e346)
2026-02-09 10:50:46 +00:00
ravibharathi656
cf7c127dc6 fix(quotation): ignore zero ordered_qty
(cherry picked from commit 32ea37035e)
2026-02-09 10:39:04 +00:00
Sudharsanan11
59f6012c57 test(stock): add test to ignore pos reserved batches for stock levels
(cherry picked from commit 47ac67f7a2)
2026-02-09 06:28:58 +00:00
Sudharsanan11
e2c12043ae fix(stock): ignore pos reserved batches for stock levels
(cherry picked from commit 277ba9cb79)
2026-02-09 06:28:58 +00:00
Mihir Kandoi
3e0a7f2400 Merge pull request #52539 from frappe/mergify/bp/version-16-hotfix/pr-52538
revert: "fix: allow sales invoice to be renamed" (backport #52538)
2026-02-09 10:47:42 +05:30
Mihir Kandoi
abef9109b0 revert: "fix: allow sales invoice to be renamed"
This reverts commit 95fdbe55f9.

(cherry picked from commit 2660907ac8)
2026-02-09 05:00:36 +00:00
MochaMind
9766361c07 chore: update POT file (#52530) 2026-02-08 17:49:45 +01:00
Mihir Kandoi
6bcd311214 Merge pull request #52524 from frappe/mergify/bp/version-16-hotfix/pr-52497
fix: add is_group filter for supplier_group and warehouse fields (backport #52497)
2026-02-07 21:58:58 +05:30
Mihir Kandoi
dd45bb5664 chore: resolve conflicts 2026-02-07 21:44:42 +05:30
Sudharsanan11
23a26b540b fix(stock): add is group filter for warehouse fields
(cherry picked from commit a9829f5f7b)
2026-02-07 16:11:32 +00:00
Sudharsanan11
9886b46cb4 fix(buying): add supplier group link filters in field level
(cherry picked from commit cfdc554a19)

# Conflicts:
#	erpnext/buying/doctype/supplier/supplier.json
2026-02-07 16:11:32 +00:00
Mihir Kandoi
32d5cedafc Merge pull request #52486 from frappe/mergify/bp/version-16-hotfix/pr-52000
fix: Added validation for quality inspection in job card (backport #52000)
2026-02-06 14:49:10 +05:30
Nishka Gosalia
ece8d00415 fix: test cases 2026-02-06 14:34:08 +05:30
Khushi Rawat
c171b9a184 Merge pull request #52492 from frappe/mergify/bp/version-16-hotfix/pr-52491
fix: apply composite asset logic only in draft (backport #52491)
2026-02-06 13:10:24 +05:30
khushi8112
d2387a3af8 fix: apply composite asset logic only in draft
(cherry picked from commit ee501e884a)
2026-02-06 07:36:31 +00:00
rohitwaghchaure
79e0e07446 Merge pull request #52482 from frappe/mergify/bp/version-16-hotfix/pr-52476
fix: stock reservation created against job card (backport #52476)
2026-02-06 12:52:43 +05:30
Mihir Kandoi
4caca08b90 Merge pull request #52488 from frappe/mergify/bp/version-16-hotfix/pr-52219
fix: enabling skip delivery option for order type maintenance (backport #52219)
2026-02-06 12:44:47 +05:30
Mihir Kandoi
738cb6847e Merge pull request #52484 from frappe/mergify/bp/version-16-hotfix/pr-52475
fix: do not show update stock flag unneccessarily (backport #52475)
2026-02-06 12:28:36 +05:30
Nishka Gosalia
7a78e9705c fix: enabling skip delivery option for order type maintenance
(cherry picked from commit 1a22e3cb61)
2026-02-06 06:40:03 +00:00
Nishka Gosalia
a1ec68cd1e fix: Added validation for quality inspection in job card
(cherry picked from commit 46b4cf3add)
2026-02-06 06:38:32 +00:00
Mihir Kandoi
4847a76cb8 chore: resolve conflicts 2026-02-06 12:00:47 +05:30
Mihir Kandoi
f56c6f93a1 chore: resolve conflicts 2026-02-06 12:00:23 +05:30
Mihir Kandoi
c0c6cc58ed fix: do not show update stock flag unneccessarily
(cherry picked from commit 5fb5b7b30e)

# Conflicts:
#	erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json
#	erpnext/accounts/doctype/sales_invoice/sales_invoice.json
2026-02-06 06:27:46 +00:00
rohitwaghchaure
8c372faf50 chore: fix linter issue
Removed duplicate import of get_bom_items_as_dict from BOM module.
2026-02-06 11:29:45 +05:30
Diptanil Saha
ae4cf27f4f Merge pull request #52471 from frappe/mergify/bp/version-16-hotfix/pr-51621
feat(accounts): expand Journal Entry Template to support dimensions and party (backport #51621)
2026-02-06 11:07:27 +05:30
Rohit Waghchaure
305483e074 fix: stock reservation created against job card
(cherry picked from commit dca2cfd009)
2026-02-05 23:05:19 +00:00
rohitwaghchaure
86dd2e786c Merge pull request #52474 from frappe/mergify/bp/version-16-hotfix/pr-52467
fix: operation status and bom validation (backport #52467)
2026-02-05 21:06:23 +05:30
Rohit Waghchaure
11222653ce fix: operation status and bom validation
(cherry picked from commit 95baf953a8)
2026-02-05 14:16:38 +00:00
Nikhil Kothari
d06a46ae85 feat(accounts): expand Journal Entry Template to support dimensions and party (#51621)
* feat(accounts): expand Journal Entry Template to support dimensions and party

* fix: do not update standard row values

(cherry picked from commit ef44528ba5)
2026-02-05 12:55:13 +00:00
rohitwaghchaure
8c536df5f2 Merge pull request #52428 from frappe/mergify/bp/version-16-hotfix/pr-52422
fix: not able to complete job card (backport #52422)
2026-02-05 17:05:16 +05:30
Frappe PR Bot
d74a649016 chore(release): Bumped to Version 16.4.1
## [16.4.1](https://github.com/frappe/erpnext/compare/v16.4.0...v16.4.1) (2026-02-05)

### Bug Fixes

* stock balance report issue ([e009319](e0093199b1))
2026-02-05 10:52:48 +00:00
rohitwaghchaure
bc9928b32b Merge pull request #52464 from frappe/mergify/bp/version-16/pr-52461
fix: stock balance report issue (backport #52459) (backport #52461)
2026-02-05 16:20:53 +05:30
rohitwaghchaure
4305415ed9 chore: fix issue
(cherry picked from commit 1d24abf5dd)
2026-02-05 10:20:46 +00:00
Rohit Waghchaure
e0093199b1 fix: stock balance report issue
(cherry picked from commit 7e584dd84a)
(cherry picked from commit 62616ad9e1)
2026-02-05 10:20:45 +00:00
rohitwaghchaure
4dec567421 Merge pull request #52461 from frappe/mergify/bp/version-16-hotfix/pr-52459
fix: stock balance report issue (backport #52459)
2026-02-05 15:49:58 +05:30
rohitwaghchaure
1d24abf5dd chore: fix issue 2026-02-05 15:02:47 +05:30
Rohit Waghchaure
62616ad9e1 fix: stock balance report issue
(cherry picked from commit 7e584dd84a)
2026-02-05 09:20:01 +00:00
Jatin3128
6bffdbce56 Merge pull request #52455 from frappe/mergify/bp/version-16-hotfix/pr-52438
fix(balance sheet): removed the extra labels from the chart (backport #52438)
2026-02-05 13:39:41 +05:30
Jatin3128
6a9a28b4ae fix(balance sheet): removed the extra labels from the chart
(cherry picked from commit a64b5f2c5d)
2026-02-05 07:54:14 +00:00
Mihir Kandoi
66d0ab6380 Merge pull request #52454 from frappe/mergify/bp/version-16-hotfix/pr-52452
fix: process loss error incorrectly thrown even when semi FG BOM does… (backport #52452)
2026-02-05 13:15:53 +05:30
ruthra kumar
5913d5f14e Merge pull request #52351 from frappe/mergify/bp/version-16-hotfix/pr-52346
fix: move company field to first position in sales invoice, purchase … (backport #52346)
2026-02-05 13:11:28 +05:30
Mihir Kandoi
4a7ffce320 fix: process loss error incorrectly thrown even when semi FG BOM does not have any process loss
(cherry picked from commit 99ddc36c26)
2026-02-05 07:27:41 +00:00
Smit Vora
2994ba1b41 test: further tests for query builder
(cherry picked from commit 12f8bb2937)
2026-02-05 06:53:30 +00:00
Smit Vora
55eb631116 test: correct error message
(cherry picked from commit a29710dc07)
2026-02-05 06:53:30 +00:00
Smit Vora
77693b12a4 test: revert original pcv setting
(cherry picked from commit f45a5a63a7)
2026-02-05 06:53:29 +00:00
Smit Vora
8caf609f8d test: add tests for query builder
(cherry picked from commit 61d8308e81)
2026-02-05 06:53:29 +00:00
Smit Vora
921584c769 fix: Period Closing Voucher doesn't exist for GL Entry
(cherry picked from commit b41c1858a3)
2026-02-05 06:53:29 +00:00
Smit Vora
7df18af799 fix: correctly calculate running balances for financial report
(cherry picked from commit ee2f8d8ebc)
2026-02-05 06:53:29 +00:00
Mihir Kandoi
64d82a811f Merge pull request #52446 from frappe/mergify/bp/version-16-hotfix/pr-52445
fix: item code is tuple with operation id (backport #52445)
2026-02-05 12:12:35 +05:30
Mihir Kandoi
9d14c0b60e fix: item code is tuple with operation id
(cherry picked from commit 481deee4b2)
2026-02-05 06:29:11 +00:00
ruthra kumar
0a285523a8 Merge pull request #52426 from frappe/mergify/bp/version-16-hotfix/pr-51990
refactor: use https over http while saving website link (backport #51990)
2026-02-05 11:30:16 +05:30
ruthra kumar
ce695ebdd0 refactor: patch partner_website for old data
(cherry picked from commit 8db29b0a81)

# Conflicts:
#	erpnext/patches.txt
2026-02-05 11:00:58 +05:30
Mihir Kandoi
9f31910226 Merge pull request #52441 from frappe/mergify/bp/version-16-hotfix/pr-52416
fix(stock): update target field attribute (backport #52416)
2026-02-05 10:40:29 +05:30
Pandiyan37
f8405e4ca4 test(stock): testcase for different inventory dimension
(cherry picked from commit 21d0ee8db1)
2026-02-05 04:54:39 +00:00
Pandiyan37
96dfecf0d5 fix(stock): update target field attribute
(cherry picked from commit 7e08154217)
2026-02-05 04:54:38 +00:00
Mihir Kandoi
b5b8032ce0 Merge pull request #52430 from frappe/mergify/bp/version-16-hotfix/pr-52427 2026-02-04 20:17:43 +05:30
archielister
5298e26a11 fix for obtaining bom_no
(cherry picked from commit e4df0a393a)
2026-02-04 14:33:24 +00:00
Rohit Waghchaure
c5ff534d58 fix: not able to complete job card
(cherry picked from commit 175fe9279c)
2026-02-04 13:13:27 +00:00
Mihir Kandoi
1dc44691db Merge pull request #52421 from frappe/mergify/bp/version-16-hotfix/pr-51773
fix(manufacturing): refactor production analytics report (backport #51773)
2026-02-04 18:06:40 +05:30
ruthra kumar
91043de352 refactor: scrub http and use https in sales partner
(cherry picked from commit 8cf31548f2)
2026-02-04 12:32:42 +00:00
Sudharsanan11
b99ca486d7 fix(manufacturing): fix chart period keys
(cherry picked from commit 27091e5168)
2026-02-04 17:51:53 +05:30
Sudharsanan11
b1b75eca3d fix(manufacturing): handle None value for actual_end_date
(cherry picked from commit 16f09141da)
2026-02-04 17:51:53 +05:30
Mihir Kandoi
33305550b7 Merge pull request #52408 from frappe/mergify/bp/version-16-hotfix/pr-52383
fix: rate comparison in stock reco (backport #52383)
2026-02-04 17:42:06 +05:30
Mihir Kandoi
e86ab97b7e chore: add line in the end so linter check passes 2026-02-04 17:26:01 +05:30
elshafei-developer
6ff8820732 fix(gross profit report): translate column Sales Invoice
(cherry picked from commit 3e39d13172)
2026-02-04 09:18:31 +00:00
Mihir Kandoi
64a7956a4a fix: return None instead of 0 if valuation rate is falsy
(cherry picked from commit e8d1e9d946)
2026-02-04 06:48:33 +00:00
Mihir Kandoi
1f78f45aee fix: rate comparison in stock reco
(cherry picked from commit f1b4fe12a2)
2026-02-04 06:48:32 +00:00
Diptanil Saha
ab59f73064 fix: remove customer_pos_id reference (#52396)
(cherry picked from commit 036f64013d)
2026-02-04 05:18:56 +00:00
Frappe PR Bot
0312d58dca chore(release): Bumped to Version 16.4.0
# [16.4.0](https://github.com/frappe/erpnext/compare/v16.3.0...v16.4.0) (2026-02-04)

### Bug Fixes

* add precision to rejected batch no qty calculation ([b365444](b365444027))
* allow sales invoice to be renamed ([f7b915d](f7b915dfe6))
* **barcode:** failing request when item has both batch and serial ([e4cdd97](e4cdd971c8))
* batch selector not working if Use Legacy (Client side) Reactivity disabled ([2a3642b](2a3642b55a))
* better fix for aac39b2671 ([b8ab55f](b8ab55fee8))
* correct exchange gain loss in ppr ([e09406d](e09406d085))
* correct Sales Tax Template sidebar link to proper DocType ([4c14e74](4c14e74a12))
* correct spelling of Payment Reconciliation in Accounting ([c80b554](c80b554cd7))
* **credit-note:** set incoming rate as zero for expired batch ([0f9bf08](0f9bf08685))
* **demo:** removed toolbar eventlistener (backport [#52171](https://github.com/frappe/erpnext/issues/52171)) ([#52172](https://github.com/frappe/erpnext/issues/52172)) ([6608601](66086010fc))
* duplicate account number (Indonesia COA) (backport [#52080](https://github.com/frappe/erpnext/issues/52080)) ([#52317](https://github.com/frappe/erpnext/issues/52317)) ([81e6575](81e65757ee))
* failing test cases ([136b2cf](136b2cfba5))
* group item wise tax details by tax row ([45e4c04](45e4c04830))
* hide close button on WO if WO is completed ([0d1c30f](0d1c30f3f0))
* hide item_wise_tax_details table from print ([c619be9](c619be989b))
* include credit notes in project gross margin calculation ([e755a4a](e755a4ad98))
* item code not showing in report view ([af167f9](af167f91fe))
* journal auditing voucher print date to use posting_date ([7e5eab2](7e5eab261c))
* **journal-entry:** normalize exchange rate to float ([3f0032d](3f0032d793))
* js error if user does not have write permission for date field ([3944dfd](3944dfde31))
* lead time calculation for FG item ([5595602](5595602f24))
* make item name editable in RFQ ([dbe5846](dbe5846908))
* merge taxes in purchase receipt when get items from multiple purchase invoices ([#51422](https://github.com/frappe/erpnext/issues/51422)) ([d80c8d1](d80c8d14b0))
* missing depr_series causing error on jv creation (backport [#52085](https://github.com/frappe/erpnext/issues/52085)) ([#52206](https://github.com/frappe/erpnext/issues/52206)) ([78c4f01](78c4f01733))
* negative stock for purchase return ([220a528](220a528d7f))
* populate contact fields when creating quotation from customer ([55129e6](55129e697d))
* production plan not considering planning datetime when creating WO ([7e7b16b](7e7b16b23e))
* **profit and loss statement:** exclude non period columns ([28e8c40](28e8c40bfc))
* reset incoming rate in selling controller if there are changes in item ([024e7b0](024e7b01ac))
* **RFQ:** render email templates for preview and sending ([687a80d](687a80d74c))
* **stock:** add stock recon opening stock condition ([f9a8fc1](f9a8fc1f2d))
* **stock:** fetch batch wise valuation rate in get_items ([b132e3f](b132e3f22a))
* **stock:** ignore packing slip while cancelling the sales invoice ([f425f89](f425f89a26))
* **stock:** include subcontracting order qty while calculating the bin qty ([de244e0](de244e0af7))
* **stock:** remove is_return condition on pos batch qty calculation ([9dcaf38](9dcaf38142))
* **stock:** set incoming_rate with lcv rate for internal purchase ([f462639](f462639aa0))
* **subcontracting:** include item bom in supplied items grouping key ([95c4b8d](95c4b8de06))
* test cases ([e74389f](e74389f01c))
* validate over ordering of quotation ([e7ace8e](e7ace8e620))
* validation when more than one FG items in repack stock entry ([a2d302b](a2d302b3fa))
* zero valuation rate if returning from different warehouse ([8ce51b2](8ce51b2f80))

### Features

* clear demo data from desktop screen (backport [#52128](https://github.com/frappe/erpnext/issues/52128)) ([#52147](https://github.com/frappe/erpnext/issues/52147)) ([05e30dc](05e30dc011))
* **credit-note:** add checkbox to set valuation rate as zero for expired batch ([b84fd46](b84fd46841))
* **delivery-note:** add status indicator when document is partially billed ([6048add](6048add4c0))
* document naming rule will now use posting date of the document ([b03494b](b03494bb67))
* **Transaction Deletion Record:** Editable "DocTypes To Delete" List with CSV import/export ([#50592](https://github.com/frappe/erpnext/issues/50592)) ([4963261](4963261dc8))
2026-02-04 04:29:22 +00:00
ruthra kumar
e8e9fb25fe Merge pull request #52347 from frappe/version-16-hotfix
chore: release v16
2026-02-04 09:57:57 +05:30
rohitwaghchaure
4a7e2742ec Merge pull request #52388 from frappe/mergify/bp/version-16-hotfix/pr-51422
fix: merge taxes in purchase receipt when get items from multiple purchase invoices (backport #51422)
2026-02-03 22:53:40 +05:30
rohitwaghchaure
55d5d6535b Merge pull request #52391 from frappe/mergify/bp/version-16-hotfix/pr-51898
fix: group item wise tax details by tax row (backport #51898)
2026-02-03 22:52:37 +05:30
rohitwaghchaure
b8ac04fb54 Merge branch 'version-16' into version-16-hotfix 2026-02-03 22:50:07 +05:30
ravibharathi656
45e4c04830 fix: group item wise tax details by tax row
(cherry picked from commit 57bd1facf5)
2026-02-03 15:38:48 +00:00
NaviN
d80c8d14b0 fix: merge taxes in purchase receipt when get items from multiple purchase invoices (#51422)
* fix: merge taxes in purchase receipt when get items from multiple purchase invoices

* fix: make merge tax configurable

* chore: follow standard merge taxes method

* chore: follow standard merge taxes method

(cherry picked from commit 6fde0a6261)
2026-02-03 15:34:03 +00:00
mergify[bot]
66e47f5651 Merge pull request #52380 from frappe/mergify/bp/version-16-hotfix/pr-52278 2026-02-03 15:32:58 +00:00
ruthra kumar
a3e8af19a6 Merge pull request #52379 from frappe/mergify/bp/version-16-hotfix/pr-51651
fix: correct exchange gain loss in ppr (backport #51651)
2026-02-03 20:59:41 +05:30
Mihir Kandoi
9b498a8da8 Merge pull request #52385 from frappe/mergify/bp/version-16-hotfix/pr-52259
fix(stock): include subcontracting order qty while calculating the bin qty (backport #52259)
2026-02-03 20:48:37 +05:30
Mihir Kandoi
25389be340 Merge pull request #52382 from frappe/mergify/bp/version-16-hotfix/pr-52374
fix(stock): fetch batch wise valuation rate in get_items (backport #52374)
2026-02-03 20:34:37 +05:30
Sudharsanan11
de244e0af7 fix(stock): include subcontracting order qty while calculating the bin qty
(cherry picked from commit de8f8ef9f4)
2026-02-03 14:55:27 +00:00
kavin-114
b132e3f22a fix(stock): fetch batch wise valuation rate in get_items
(cherry picked from commit c5df570262)
2026-02-03 14:47:52 +00:00
ruthra kumar
670fd79e38 Merge pull request #52377 from frappe/mergify/bp/version-16-hotfix/pr-52314
fix(journal-entry): normalize exchange rate to float (backport #52314)
2026-02-03 20:10:03 +05:30
ravibharathi656
e09406d085 fix: correct exchange gain loss in ppr
(cherry picked from commit 02e96039ac)
2026-02-03 14:35:58 +00:00
rohitwaghchaure
3bc348d6f0 Merge pull request #52376 from frappe/mergify/bp/version-16-hotfix/pr-52375
fix: zero valuation rate if returning from different warehouse (backport #52369) (backport #52375)
2026-02-03 20:04:39 +05:30
Dharanidharan2813
3f0032d793 fix(journal-entry): normalize exchange rate to float
(cherry picked from commit be0040ddc7)
2026-02-03 14:17:24 +00:00
ruthra kumar
163b848455 Merge pull request #52368 from frappe/mergify/bp/version-16-hotfix/pr-52320
fix: correct Sales Tax Template sidebar link to proper DocType (backport #52320)
2026-02-03 19:44:26 +05:30
Rohit Waghchaure
8ce51b2f80 fix: zero valuation rate if returning from different warehouse
(cherry picked from commit 28929df0e8)
(cherry picked from commit eb2119e292)
2026-02-03 13:59:59 +00:00
ruthra kumar
422b37332e Merge pull request #52367 from frappe/mergify/bp/version-16-hotfix/pr-52279
fix(profit and loss statement): exclude non period columns (backport #52279)
2026-02-03 17:47:37 +05:30
Luis Mendoza
4c14e74a12 fix: correct Sales Tax Template sidebar link to proper DocType
(cherry picked from commit 06a7c85c93)
2026-02-03 12:05:37 +00:00
ravibharathi656
28e8c40bfc fix(profit and loss statement): exclude non period columns
(cherry picked from commit 6180e5eb53)
2026-02-03 12:00:00 +00:00
ruthra kumar
660fc8f76a Merge pull request #52365 from frappe/mergify/bp/version-16-hotfix/pr-52160
fix(stock): remove is_return condition on pos batch qty calculation (backport #52160)
2026-02-03 17:19:50 +05:30
ruthra kumar
22456a5857 Merge pull request #52362 from frappe/mergify/bp/version-16-hotfix/pr-51997
Add partially billed status indicator (backport #51997)
2026-02-03 17:19:31 +05:30
kavin-114
76e0eb00a5 test: add unit test case for pos reserved with return qty
(cherry picked from commit 12ec997027)
2026-02-03 11:11:50 +00:00
kavin-114
9dcaf38142 fix(stock): remove is_return condition on pos batch qty calculation
(cherry picked from commit 2c19c1fd06)
2026-02-03 11:11:50 +00:00
Dharanidharan2813
6048add4c0 feat(delivery-note): add status indicator when document is partially billed
(cherry picked from commit 7767000ccf)
2026-02-03 10:59:20 +00:00
rohitwaghchaure
0552b48328 Merge pull request #52356 from frappe/mergify/bp/version-16-hotfix/pr-52338
fix: negative stock for purchase return (backport #52338)
2026-02-03 16:16:28 +05:30
ruthra kumar
193b29d5fc Merge pull request #52350 from frappe/mergify/bp/version-16-hotfix/pr-52339
chore: rename icons (backport #52339)
2026-02-03 16:13:06 +05:30
Rohit Waghchaure
220a528d7f fix: negative stock for purchase return
(cherry picked from commit 77893933a2)
2026-02-03 10:25:10 +00:00
ruthra kumar
e087a8b179 Merge pull request #52353 from frappe/mergify/bp/version-16-hotfix/pr-52345
ci: skip svg (backport #52345)
2026-02-03 15:41:53 +05:30
ruthra kumar
d7067f6b7a ci: skip svg
(cherry picked from commit e565d2283e)
2026-02-03 10:11:12 +00:00
Shllokkk
e53ccd0745 fix: move company field to first position in sales invoice, purchase invoice, sales order, purchase order and journal entry
(cherry picked from commit 8e9365eb3b)
2026-02-03 10:04:56 +00:00
sokumon
e264d8e2d6 chore: rename icons
(cherry picked from commit 2d312bcfe8)
2026-02-03 10:02:54 +00:00
ruthra kumar
8b2559ab0c Merge pull request #52337 from frappe/mergify/bp/version-16-hotfix/pr-52280
fix(stock): ignore packing slip while cancelling the sales invoice (backport #52280)
2026-02-03 13:58:59 +05:30
Sudharsanan11
f425f89a26 fix(stock): ignore packing slip while cancelling the sales invoice
(cherry picked from commit c58887b44a)
2026-02-03 08:25:04 +00:00
ruthra kumar
e3bf84c572 Merge pull request #52321 from frappe/mergify/bp/version-16-hotfix/pr-50592
feat(Transaction Deletion Record): Editable "DocTypes To Delete" List with CSV import/export (backport #50592)
2026-02-03 13:50:00 +05:30
ruthra kumar
e2b88218ec Merge pull request #52330 from frappe/mergify/bp/version-16-hotfix/pr-51655
fix: include credit notes in project gross margin calculation (backport #51655)
2026-02-03 12:19:28 +05:30
ravibharathi656
e755a4ad98 fix: include credit notes in project gross margin calculation
(cherry picked from commit a378fee8e0)
2026-02-03 06:07:49 +00:00
ruthra kumar
d2ea428030 chore: resolve conflict 2026-02-03 10:38:39 +05:30
Mihir Kandoi
496956f08f Merge pull request #52324 from frappe/mergify/bp/version-16-hotfix/pr-52184
fix(subcontracting): include item bom in supplied items grouping key (backport #52184)
2026-02-03 09:24:00 +05:30
Sudharsanan11
a3190dd556 test(subcontracting): add test for consumed_qty calculation with similar finished goods
(cherry picked from commit 4d9412181c)
2026-02-03 03:40:18 +00:00
Sudharsanan11
95c4b8de06 fix(subcontracting): include item bom in supplied items grouping key
(cherry picked from commit 0d372a62a1)
2026-02-03 03:40:18 +00:00
Henning Wendtland
4963261dc8 feat(Transaction Deletion Record): Editable "DocTypes To Delete" List with CSV import/export (#50592)
* feat: add editable DocTypes To Delete list with import/export

Add user control over transaction deletion with reviewable and reusable deletion templates.

- New "DocTypes To Delete" table allows users to review and customize what will be deleted before submission
- Import/Export CSV templates for reusability across environments
- Company field rule: only filter by company if field is specifically named "company", otherwise delete all records
- Child tables (istable=1) automatically excluded from selection
- "Remove Zero Counts" helper button to clean up list
- Backward compatible with existing deletion records

* refactor: improve Transaction Deletion Record code quality

- Remove unnecessary chatty comments from AI-generated code
- Add concise docstrings to all new methods
- Remove redundant @frappe.whitelist() decorators from internal methods
- Improve CSV import validation (header check, child table filtering)
- Add better error feedback with consolidated skip messages
- Reorder form fields: To Delete list now appears before Excluded list
- Add conditional visibility for Summary table (legacy records only)
- Improve architectural clarity: single API entry point per feature

Technical improvements:
- export_to_delete_template_method and import_to_delete_template_method
  are now internal helpers without whitelist decorators
- CSV import now validates format and provides detailed skip reasons
- Summary table only shows for submitted records without To Delete list
- Maintains backward compatibility for existing deletion records

* fix: field order

* test: fix broken tests and add new ones

* fix: adapt create_transaction_deletion_request

* test: fix assertRaises trigger

* fix: conditionally execute Transaction Deletion pre-tasks based on selected DocTypes

* refactor: replace boolean task flags with status fields

* fix: remove UI comment

* fix: don't allow virtual doctype selection and improve protected Doctype List

* fix: replace outdated frappe.db.sql by frappe.qb

* feat: add support for multiple company fields

* fix: autofill comapny field, add docstrings, filter for company_field

* fix: add edge case handling for update_naming_series and add tests for prefix extraction

* fix: use redis for running deletion validation, check per doctype instead of company

(cherry picked from commit 0fb37ad792)

# Conflicts:
#	erpnext/patches.txt
2026-02-03 00:49:04 +00:00
mergify[bot]
81e65757ee fix: duplicate account number (Indonesia COA) (backport #52080) (#52317)
Co-authored-by: Apriliansyah Idris <apriliansyahidris@gmail.com>
fix: duplicate account number (Indonesia COA) (#52080)
2026-02-02 19:09:22 +00:00
rohitwaghchaure
78e581154b Merge pull request #52312 from frappe/mergify/bp/version-16-hotfix/pr-52303
fix: batch selector not working if Use Legacy (Client side) Reactivity disabled (backport #52303)
2026-02-02 23:32:40 +05:30
Rohit Waghchaure
2a3642b55a fix: batch selector not working if Use Legacy (Client side) Reactivity disabled
(cherry picked from commit d1cba1073f)
2026-02-02 16:32:18 +00:00
Mihir Kandoi
beaa76ca16 Merge pull request #52311 from frappe/mergify/bp/version-16-hotfix/pr-52246 2026-02-02 21:01:34 +05:30
Mihir Kandoi
9997185071 Merge pull request #52246 from mihir-kandoi/st58765
(cherry picked from commit 135a433018)
2026-02-02 15:15:38 +00:00
Mihir Kandoi
681c0b5917 Merge pull request #52308 from frappe/mergify/bp/version-16-hotfix/pr-52304
fix: populate contact fields when creating quotation from customer (backport #52304)
2026-02-02 20:36:19 +05:30
Mihir Kandoi
4a0e04ee20 Merge pull request #52306 from frappe/mergify/bp/version-16-hotfix/pr-52281
fix(stock): add stock recon opening stock condition (backport #52281)
2026-02-02 20:24:00 +05:30
Mihir Kandoi
55129e697d fix: populate contact fields when creating quotation from customer
(cherry picked from commit 75b2c2c83d)
2026-02-02 14:46:36 +00:00
kavin-114
f9a8fc1f2d fix(stock): add stock recon opening stock condition
(cherry picked from commit f3eb6c7078)
2026-02-02 14:37:36 +00:00
Mihir Kandoi
091ff81ae5 Merge pull request #52302 from frappe/mergify/bp/version-16-hotfix/pr-52286
fix: reset incoming rate in selling controller if there are changes i… (backport #52286)
2026-02-02 20:05:37 +05:30
Mihir Kandoi
024e7b01ac fix: reset incoming rate in selling controller if there are changes in item
(cherry picked from commit 2d6b43fd54)
2026-02-02 14:18:20 +00:00
ruthra kumar
a0156b61b8 Merge pull request #52284 from frappe/mergify/bp/version-16-hotfix/pr-52200
fix(accounts): correct date in Journal Auditing Voucher print format (backport #52200)
2026-02-02 12:55:41 +05:30
Tamal Majumdar
7e5eab261c fix: journal auditing voucher print date to use posting_date
(cherry picked from commit 43e2495df8)
2026-02-02 07:21:36 +00:00
ruthra kumar
63782e6355 Merge pull request #52282 from frappe/mergify/bp/version-16-hotfix/pr-51692
fix: correct spelling of Payment Reconciliation in Accounting (backport #51692)
2026-02-02 12:43:17 +05:30
nivithamerlin
c80b554cd7 fix: correct spelling of Payment Reconciliation in Accounting
(cherry picked from commit 35e53d28df)
2026-02-02 07:09:54 +00:00
Mihir Kandoi
7db88b210e Merge pull request #52276 from frappe/mergify/bp/version-16-hotfix/pr-52274 2026-02-02 10:24:07 +05:30
Mihir Kandoi
42d873f1d9 test: over ordering of quotation items
(cherry picked from commit 53e58f6678)
2026-02-02 04:38:13 +00:00
MochaMind
19c1dcc3dd chore: update POT file (#52264) 2026-02-01 15:07:28 +01:00
Mihir Kandoi
23e027b6be Merge pull request #52230 from frappe/mergify/bp/version-16-hotfix/pr-52222
fix: validate over ordering of quotation (backport #52222)
2026-01-31 20:26:44 +05:30
Mihir Kandoi
d91cfa76e6 Merge pull request #52257 from frappe/mergify/bp/version-16-hotfix/pr-52253 2026-01-31 20:19:18 +05:30
Mihir Kandoi
a29df7be67 chore: resolve conflicts 2026-01-31 20:13:26 +05:30
Mihir Kandoi
dbe5846908 fix: make item name editable in RFQ
(cherry picked from commit d9998a977c)
2026-01-31 14:34:24 +00:00
Mihir Kandoi
83fcb5d2d8 Merge pull request #52255 from frappe/mergify/bp/version-16-hotfix/pr-52252
fix: better fix for #51495 (backport #52252)
2026-01-31 19:52:45 +05:30
Mihir Kandoi
b8ab55fee8 fix: better fix for aac39b2671
(cherry picked from commit b24ae5e9a2)
2026-01-31 14:21:27 +00:00
rohitwaghchaure
4d03f4ebaa Merge pull request #52240 from frappe/mergify/bp/version-16-hotfix/pr-52232
fix: validation when more than one FG items in repack stock entry (backport #52232)
2026-01-31 17:54:08 +05:30
Rohit Waghchaure
a2d302b3fa fix: validation when more than one FG items in repack stock entry
(cherry picked from commit 6423ce2fa7)
2026-01-31 07:16:45 +00:00
Mihir Kandoi
b5321d42a3 Merge pull request #52234 from frappe/mergify/bp/version-16-hotfix/pr-52231
fix: item code not showing in report view (backport #52231)
2026-01-30 22:17:26 +05:30
UmakanthKaspa
af167f91fe fix: item code not showing in report view
(cherry picked from commit b20f57321f)
2026-01-30 16:46:26 +00:00
Mihir Kandoi
e74389f01c fix: test cases
(cherry picked from commit 36f1e3572c)
2026-01-30 14:00:52 +00:00
Mihir Kandoi
e7ace8e620 fix: validate over ordering of quotation
(cherry picked from commit 4cc306d2d8)

# Conflicts:
#	erpnext/patches.txt
2026-01-30 14:00:52 +00:00
Mihir Kandoi
e23e9b5d66 Merge pull request #52227 from frappe/mergify/bp/version-16-hotfix/pr-52226 2026-01-30 18:03:40 +05:30
Mihir Kandoi
136b2cfba5 fix: failing test cases
(cherry picked from commit d3f44a425c)
2026-01-30 12:18:10 +00:00
Mihir Kandoi
3b3738577d Merge pull request #52225 from frappe/mergify/bp/version-16-hotfix/pr-51433 2026-01-30 17:30:01 +05:30
Mihir Kandoi
1e646bd0ed Merge pull request #52224 from frappe/mergify/bp/version-16-hotfix/pr-52223
fix: allow sales invoice to be renamed (backport #52223)
2026-01-30 17:19:10 +05:30
Mihir Kandoi
02e6c49130 test: add test case
(cherry picked from commit e2c3d0fa94)
2026-01-30 11:41:46 +00:00
Mihir Kandoi
e9fa725030 chore: make feature opt in
(cherry picked from commit b8d4522ea1)
2026-01-30 11:41:46 +00:00
Mihir Kandoi
b03494bb67 feat: document naming rule will now use posting date of the document
(cherry picked from commit 22fd1a1cfd)
2026-01-30 11:41:45 +00:00
Mihir Kandoi
f7b915dfe6 fix: allow sales invoice to be renamed
(cherry picked from commit 95fdbe55f9)
2026-01-30 11:33:20 +00:00
Mihir Kandoi
0a5aac9ce7 Merge pull request #52218 from frappe/mergify/bp/version-16-hotfix/pr-52209
fix: add precision to rejected batch no qty calculation (backport #52209)
2026-01-30 12:21:50 +05:30
Mihir Kandoi
e3c62070d1 Merge pull request #52215 from frappe/mergify/bp/version-16-hotfix/pr-52213
fix: hide close button on WO if WO is completed (backport #52213)
2026-01-30 12:06:39 +05:30
Mihir Kandoi
b365444027 fix: add precision to rejected batch no qty calculation
(cherry picked from commit 838d245215)
2026-01-30 06:35:59 +00:00
Mihir Kandoi
0d1c30f3f0 fix: hide close button on WO if WO is completed
(cherry picked from commit 6e17ccf499)
2026-01-30 06:29:32 +00:00
Mihir Kandoi
d0b553dca3 Merge pull request #52212 from frappe/mergify/bp/version-16-hotfix/pr-52210
fix(barcode): failing request when item has both batch and serial (backport #52210)
2026-01-30 11:52:49 +05:30
Mihir Kandoi
e4cdd971c8 fix(barcode): failing request when item has both batch and serial
(cherry picked from commit 89f6f0f46f)
2026-01-30 06:17:41 +00:00
mergify[bot]
78c4f01733 fix: missing depr_series causing error on jv creation (backport #52085) (#52206)
fix: missing depr_series causing error on jv creation (#52085)

(cherry picked from commit b565dd3da8)

Co-authored-by: Dany Robert <rtdany10@gmail.com>
2026-01-29 23:30:29 +05:30
Mihir Kandoi
eac4978278 Merge pull request #52203 from frappe/mergify/bp/version-16-hotfix/pr-52201
fix: hide item_wise_tax_details table from print (backport #52201)
2026-01-29 21:58:37 +05:30
Mihir Kandoi
c619be989b fix: hide item_wise_tax_details table from print
(cherry picked from commit c38f884095)
2026-01-29 16:12:55 +00:00
rohitwaghchaure
64921fc1b5 Merge pull request #52197 from frappe/mergify/bp/version-16-hotfix/pr-52190
fix: lead time calculation for FG item (backport #52190)
2026-01-29 19:06:53 +05:30
rohitwaghchaure
6d8d502bbf Merge pull request #52192 from frappe/mergify/bp/version-16-hotfix/pr-52158
Add Landed Cost Voucher Amount in Internal Purchase Receipt (backport #52158)
2026-01-29 18:07:46 +05:30
Rohit Waghchaure
5595602f24 fix: lead time calculation for FG item
(cherry picked from commit 646688c291)
2026-01-29 12:29:39 +00:00
kavin-114
7042f2b8fb test: add unit test to check internal purchase with lcv
(cherry picked from commit dd4fd89ef8)
2026-01-29 12:02:50 +00:00
kavin-114
f462639aa0 fix(stock): set incoming_rate with lcv rate for internal purchase
(cherry picked from commit f0dccc3cd7)
2026-01-29 12:02:49 +00:00
Mihir Kandoi
14ba0f1cae Merge pull request #52183 from frappe/mergify/bp/version-16-hotfix/pr-52181
fix: js error if user does not have write permission for date field (backport #52181)
2026-01-29 15:42:29 +05:30
Mihir Kandoi
3944dfde31 fix: js error if user does not have write permission for date field
(cherry picked from commit 7f6f39f5e7)
2026-01-29 10:01:33 +00:00
ruthra kumar
16cc2b0a25 Merge pull request #52177 from frappe/mergify/bp/version-16-hotfix/pr-52173
New accounting icons (backport #52173)
2026-01-29 14:51:36 +05:30
Jacob Salvi
3394e1a126 refactor: new accounting icons (#52173)
* chore: new icons share-management

* chore: new accounting icons

* chore: trigger CI

---------

Co-authored-by: Soham Kulkarni <77533095+sokumon@users.noreply.github.com>
Co-authored-by: ruthra kumar <ruthra@erpnext.com>
(cherry picked from commit cdcf3fa593)
2026-01-29 09:13:39 +00:00
mergify[bot]
66086010fc fix(demo): removed toolbar eventlistener (backport #52171) (#52172)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
fix(demo): removed toolbar eventlistener (#52171)
2026-01-29 11:29:59 +05:30
Mihir Kandoi
195a2f3e74 Merge pull request #52168 from frappe/mergify/bp/version-16-hotfix/pr-52166
fix: production plan not considering planning datetime when creating WO (backport #52166)
2026-01-29 11:06:50 +05:30
Mihir Kandoi
7e7b16b23e fix: production plan not considering planning datetime when creating WO
(cherry picked from commit 4e19c7e8bd)
2026-01-29 05:20:59 +00:00
Aarol D'Souza
f4beb41df2 Merge pull request #52164 from frappe/mergify/bp/version-16-hotfix/pr-52092
fix(RFQ): render email templates for preview and sending (backport #52092)
2026-01-29 09:23:39 +05:30
AarDG10
4c4aa9bbdf ci: minor text correction
(cherry picked from commit 37cdae2f34)
2026-01-29 03:38:02 +00:00
AarDG10
687a80d74c fix(RFQ): render email templates for preview and sending
(cherry picked from commit 525b3960e1)
2026-01-29 03:38:01 +00:00
rohitwaghchaure
f01e0576b9 Merge pull request #52141 from frappe/mergify/bp/version-16-hotfix/pr-52007
Fix: Set Zero Rate for Standalone Credit Note with Expired Batch (backport #52007)
2026-01-28 19:46:10 +05:30
Frappe PR Bot
83a0d957ef chore(release): Bumped to Version 16.3.0
# [16.3.0](https://github.com/frappe/erpnext/compare/v16.2.0...v16.3.0) (2026-01-28)

### Features

* clear demo data from desktop screen (backport [#52128](https://github.com/frappe/erpnext/issues/52128))  ([#52150](https://github.com/frappe/erpnext/issues/52150)) ([554aeb9](554aeb94fd))
2026-01-28 12:51:32 +00:00
mergify[bot]
554aeb94fd feat: clear demo data from desktop screen (backport #52128) (#52150)
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Co-authored-by: Soham Kulkarni <77533095+sokumon@users.noreply.github.com>
2026-01-28 18:19:41 +05:30
mergify[bot]
05e30dc011 feat: clear demo data from desktop screen (backport #52128) (#52147)
Co-authored-by: Soham Kulkarni <77533095+sokumon@users.noreply.github.com>
2026-01-28 17:36:04 +05:30
kavin-114
40a3dabd30 test(credit-note): add unit test for zero valuation rate on expired batch
(cherry picked from commit 3460a7efb5)
2026-01-28 10:43:58 +00:00
kavin-114
0f9bf08685 fix(credit-note): set incoming rate as zero for expired batch
(cherry picked from commit e78c750b4e)
2026-01-28 10:43:58 +00:00
kavin-114
b84fd46841 feat(credit-note): add checkbox to set valuation rate as zero for expired batch
(cherry picked from commit 04cdf88715)
2026-01-28 10:43:58 +00:00
Frappe PR Bot
6209d633c2 chore(release): Bumped to Version 16.2.0
# [16.2.0](https://github.com/frappe/erpnext/compare/v16.1.0...v16.2.0) (2026-01-28)

### Bug Fixes

* **accounts:** correct base grand total and rounded total mismatch (backport [#51739](https://github.com/frappe/erpnext/issues/51739)) ([#52101](https://github.com/frappe/erpnext/issues/52101)) ([6115f8f](6115f8fb9a))
* **asset capitalization:** update total_asset_cost on asset capitalisation submission (backport [#52077](https://github.com/frappe/erpnext/issues/52077)) ([#52115](https://github.com/frappe/erpnext/issues/52115)) ([4f16956](4f1695616a))
* autofill warehouse for packed items ([881562f](881562fc37))
* Bin reserved qty for production for extra material transfer ([bf53133](bf53133f94))
* calculate weighted average rate for customer provided items in subcontracting inward order ([7120fbd](7120fbd14b))
* check the payment ledger entry has the dimension ([#51823](https://github.com/frappe/erpnext/issues/51823)) ([7342b25](7342b2551b))
* check the payment ledger entry has the dimension (backport [#51823](https://github.com/frappe/erpnext/issues/51823)) ([#52108](https://github.com/frappe/erpnext/issues/52108)) ([1927adb](1927adbd2e))
* create DN btn should not be shown if it cannot be created ([30e6b5d](30e6b5daac))
* **customer:** add customer group filters ([b1716bf](b1716bfeef))
* disable asset repair when status is fully depreciated ([13e4849](13e4849c43))
* Ensure paid_amount is always numeric before calling allocate_amount_to_references (backport [#50935](https://github.com/frappe/erpnext/issues/50935)) ([#52036](https://github.com/frappe/erpnext/issues/52036)) ([e9f3f0f](e9f3f0f445))
* force user to enter batch or serial for serial/batch items ([91199ea](91199ea9c9))
* handle parent level project change ([0b7684e](0b7684eccd))
* handle undefined bank_transaction_mapping in quick entry ([22a8d48](22a8d483e1))
* job cards should not be deleted on close of WO ([7c2bbe0](7c2bbe0d82))
* **journal-entry:** prevent submit failure due to double background queuing (backport [#52083](https://github.com/frappe/erpnext/issues/52083)) ([#52087](https://github.com/frappe/erpnext/issues/52087)) ([46e6096](46e6096fe3))
* negative stock for purchae return ([fb3fb8c](fb3fb8ca5e))
* not able to complete the job card ([f486071](f486071cf6))
* **payment entry:** update currency symbol (backport [#51956](https://github.com/frappe/erpnext/issues/51956)) ([#52094](https://github.com/frappe/erpnext/issues/52094)) ([b1b1f25](b1b1f25bb1))
* **project:** add missing counter to project update naming series ([37a237d](37a237dbb7))
* rejected qty in PR doesn't consider conversion factor ([c7c7a55](c7c7a55a58))
* **sales order:** set project at item level from parent ([27fe754](27fe754a7d))
* **shipment:** user contact validation to use full name ([0a56647](0a56647a61))
* show everything else besides other party specific item ([7575861](75758610dd))
* show message if image is removed from item description (backport [#52088](https://github.com/frappe/erpnext/issues/52088)) ([#52097](https://github.com/frappe/erpnext/issues/52097)) ([53b7375](53b73757ed))
* **stock:** use purchase UOM in Supplier Quotation items ([f97b850](f97b850077))
* strip whitespace in customer_name ([41e6687](41e6687b35))
* swedish_address_template ([cff09b7](cff09b71cc))
* tests ([6fa60d2](6fa60d2f1a))
* throw if item order field is not set in subcontracting controller ([264855e](264855e5e1))
* unable to split asset from capitalization (backport [#52020](https://github.com/frappe/erpnext/issues/52020)) ([#52114](https://github.com/frappe/erpnext/issues/52114)) ([c1cc1db](c1cc1dbd27)), closes [#52016](https://github.com/frappe/erpnext/issues/52016) [#52016](https://github.com/frappe/erpnext/issues/52016)
* UOM of item not fetching in BOM ([1b9a93f](1b9a93f90e))
* update country_wise_tax.json for Algerian Taxes (backport [#51878](https://github.com/frappe/erpnext/issues/51878)) ([#52038](https://github.com/frappe/erpnext/issues/52038)) ([8946f12](8946f12677))
* validation to check at-least one raw material for manufacture entry ([d067e37](d067e37ab6))
* warehouse permissions in MR incorrectly ignored ([504c84e](504c84e28a))

### Features

* **accounts:** retain filters when switching between financial statements (backport [#51668](https://github.com/frappe/erpnext/issues/51668)) ([#52117](https://github.com/frappe/erpnext/issues/52117)) ([9ed3801](9ed3801d06))
2026-01-28 04:15:59 +00:00
ruthra kumar
095fe65bef Merge pull request #52103 from frappe/version-16-hotfix
chore: release v16
2026-01-28 09:44:30 +05:30
Mihir Kandoi
b285548a46 Merge pull request #52124 from frappe/mergify/bp/version-16-hotfix/pr-51961
fix(sales order): set project at item level from parent (backport #51961)
2026-01-27 21:55:49 +05:30
SowmyaArunachalam
0b7684eccd fix: handle parent level project change
(cherry picked from commit 543b6e51c0)
2026-01-27 16:24:22 +00:00
SowmyaArunachalam
574460c009 chore: use frappe.model.set_value
(cherry picked from commit 3b27f49d79)
2026-01-27 16:24:22 +00:00
SowmyaArunachalam
27fe754a7d fix(sales order): set project at item level from parent
(cherry picked from commit 9e51701e2a)
2026-01-27 16:24:22 +00:00
Mihir Kandoi
531fe59a24 Merge pull request #52122 from frappe/mergify/bp/version-16-hotfix/pr-52084
fix(shipment): user contact validation to use full name (backport #52084)
2026-01-27 21:29:22 +05:30
harrishragavan
0a56647a61 fix(shipment): user contact validation to use full name
(cherry picked from commit 3c6eb9a531)
2026-01-27 15:57:22 +00:00
Soham Kulkarni
c2f666b7a3 Merge pull request #52120 from frappe/mergify/bp/version-16-hotfix/pr-52119 2026-01-27 21:11:42 +05:30
sokumon
c1bbe1104e chore: change color of icons in accounting folders
(cherry picked from commit 6f9cd8c261)
2026-01-27 15:15:16 +00:00
ruthra kumar
2c86327c7e Merge pull request #52112 from frappe/mergify/bp/version-16-hotfix/pr-52106
fix: show everything else besides other party specific item (backport #52106)
2026-01-27 20:08:54 +05:30
ruthra kumar
31385a1f91 Merge pull request #52118 from frappe/mergify/bp/version-16-hotfix/pr-51894
refactor: accounting workspace (backport #51894)
2026-01-27 20:08:29 +05:30
mergify[bot]
4f1695616a fix(asset capitalization): update total_asset_cost on asset capitalisation submission (backport #52077) (#52115)
fix(asset capitalization): update total_asset_cost on asset capitalisation submission (#52077)

fix(asset capitalization): update total_asset_cost on asset capitalization submission

(cherry picked from commit ec41f1b0f5)

Co-authored-by: NaviN <118178330+Navin-S-R@users.noreply.github.com>
2026-01-27 19:31:52 +05:30
mergify[bot]
9ed3801d06 feat(accounts): retain filters when switching between financial statements (backport #51668) (#52117) 2026-01-27 19:03:44 +05:30
ruthra kumar
61ad67ec29 refactor: reuse icon for invoicing
(cherry picked from commit f0332c4dc7)
2026-01-27 13:15:37 +00:00
ruthra kumar
735b9da6b1 refactor: rename Accounts to Accounting
(cherry picked from commit fb9656b975)
2026-01-27 13:15:36 +00:00
ruthra kumar
fe7a797156 refactor: link payments dashboard to sidebar
(cherry picked from commit f7abf9c1da)
2026-01-27 13:15:36 +00:00
ruthra kumar
f951dd180a refactor: payments dashboard
(cherry picked from commit 99406ccc15)
2026-01-27 13:15:36 +00:00
ruthra kumar
8e871796d4 refactor: shed duplicates from invoicing sidebar
(cherry picked from commit 1295d7aa30)
2026-01-27 13:15:36 +00:00
ruthra kumar
c88ee50c34 refactor: reorder accounts setup sidebar
(cherry picked from commit 5a680d5037)
2026-01-27 13:15:35 +00:00
ruthra kumar
e49f6f4f09 refactor: introduce setup icon and reorder
(cherry picked from commit 7528d42187)
2026-01-27 13:15:35 +00:00
ruthra kumar
72942e6b8c refactor: payments sidebar and icon
(cherry picked from commit cbdc945287)
2026-01-27 13:15:35 +00:00
ruthra kumar
fdcf037f1b chore: rename accounting to invoicing
(cherry picked from commit faf0dcb102)
2026-01-27 13:15:35 +00:00
ruthra kumar
9e0c606b95 chore: remove accounting icon
(cherry picked from commit 5e02b4009e)
2026-01-27 13:15:34 +00:00
ruthra kumar
6b9f2ddf83 refactor: invoicing icon
(cherry picked from commit 8125f9035c)
2026-01-27 13:15:34 +00:00
mergify[bot]
c1cc1dbd27 fix: unable to split asset from capitalization (backport #52020) (#52114)
fix: unable to split asset from capitalization (#52020)

* fix: Allow split asset from capitalized composite asset (fixes #52016)

* test: Add test case for splitting asset created via capitalization (fixes #52016)

* docs: Add docstring to before_submit method

* fix: Remove unused variable and fix UTF-8 encoding in asset files

* fix: Remove UTF-8 BOM from asset.py to fix linting

* fix: Fix test_split_asset_created_via_capitalization test parameters

* fix: Remove unused import create_item

* chore: remove unnecessary comments

Removed validation comments for composite asset capitalization in before_submit method.

---------


(cherry picked from commit 7e9647f3f0)

Co-authored-by: madelyngamble2 <madelyngamble2@gmail.com>
Co-authored-by: Khushi Rawat <142375893+khushi8112@users.noreply.github.com>
2026-01-27 18:04:19 +05:30
Mihir Kandoi
de584e2e8d test: fix tests
(cherry picked from commit 5eeebbde7f)
2026-01-27 10:57:40 +00:00
Mihir Kandoi
75758610dd fix: show everything else besides other party specific item
(cherry picked from commit 71371b0ba5)
2026-01-27 10:57:40 +00:00
ruthra kumar
1927adbd2e fix: check the payment ledger entry has the dimension (backport #51823) (#52108)
fix: check the payment ledger entry has the dimension (#51823)

* fix: check the payment ledger entry has the dimension

* fix: add project in payment ledger entry

(cherry picked from commit efa3973b77)

Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
2026-01-27 16:25:33 +05:30
Vishnu Priya Baskaran
7342b2551b fix: check the payment ledger entry has the dimension (#51823)
* fix: check the payment ledger entry has the dimension

* fix: add project in payment ledger entry

(cherry picked from commit efa3973b77)
2026-01-27 10:27:23 +00:00
mergify[bot]
6115f8fb9a fix(accounts): correct base grand total and rounded total mismatch (backport #51739) (#52101)
Co-authored-by: Dharanidharan S <dharanidharans1328@gmail.com>
fix(accounts): correct base grand total and rounded total mismatch (#51739)
2026-01-27 14:23:10 +05:30
mergify[bot]
53b73757ed fix: show message if image is removed from item description (backport #52088) (#52097)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-01-27 14:09:02 +05:30
rohitwaghchaure
dee3357da2 Merge pull request #52075 from frappe/mergify/bp/version-16-hotfix/pr-52062
fix: not able to complete the job card (backport #52062)
2026-01-27 13:03:43 +05:30
mergify[bot]
b1b1f25bb1 fix(payment entry): update currency symbol (backport #51956) (#52094)
Co-authored-by: NaviN <118178330+Navin-S-R@users.noreply.github.com>
fix(payment entry): update currency symbol (#51956)
2026-01-27 06:34:19 +00:00
mergify[bot]
46e6096fe3 fix(journal-entry): prevent submit failure due to double background queuing (backport #52083) (#52087)
Co-authored-by: V Shankar <shankarv292002@gmail.com>
fix(journal-entry): prevent submit failure due to double background queuing (#52083)
2026-01-27 05:53:05 +00:00
Rohit Waghchaure
f486071cf6 fix: not able to complete the job card
(cherry picked from commit 696ea68f86)
2026-01-26 17:45:21 +00:00
Mihir Kandoi
b541fbbd60 Merge pull request #52066 from frappe/mergify/bp/version-16-hotfix/pr-52064
fix: strip whitespace in customer_name (backport #52064)
2026-01-26 15:32:27 +05:30
Shankarv19bcr
41e6687b35 fix: strip whitespace in customer_name
(cherry picked from commit e5ba0e6401)
2026-01-26 09:47:06 +00:00
MochaMind
55ce40de37 chore: update POT file (#52058) 2026-01-25 20:37:09 +01:00
ruthra kumar
6f21ab5d9a Merge pull request #52040 from frappe/mergify/bp/version-16-hotfix/pr-51670
fix: handle undefined bank_transaction_mapping in quick entry (backport #51670)
2026-01-25 13:11:28 +05:30
ruthra kumar
7c81672b36 Merge pull request #52041 from frappe/mergify/bp/version-16-hotfix/pr-51691
refactor: not warn when filter field is missing in FS reports (backport #51691)
2026-01-25 13:10:02 +05:30
ruthra kumar
d0faff09cb Merge pull request #52055 from frappe/mergify/bp/version-16-hotfix/pr-52050
fix: swedish_address_template (backport #52050)
2026-01-25 13:08:42 +05:30
mahsem
cff09b71cc fix: swedish_address_template
(cherry picked from commit 334e8ada30)
2026-01-25 05:22:41 +00:00
rohitwaghchaure
b180e3b78c Merge pull request #52053 from frappe/mergify/bp/version-16-hotfix/pr-52043
fix: UOM of item not fetching in BOM (backport #52043)
2026-01-25 10:50:38 +05:30
rohitwaghchaure
6ba2795043 Merge pull request #51905 from frappe/mergify/bp/version-16-hotfix/pr-51900
fix: validation to check at-least one raw material for manufacture entry (backport #51900)
2026-01-25 10:45:11 +05:30
Rohit Waghchaure
1b9a93f90e fix: UOM of item not fetching in BOM
(cherry picked from commit ba8eadda52)
2026-01-25 05:15:05 +00:00
rohitwaghchaure
1f1428f00a Merge branch 'version-16-hotfix' into mergify/bp/version-16-hotfix/pr-51900 2026-01-24 13:49:24 +05:30
Abdeali Chharchhoda
606ac2a91a refactor: not warn when filter field is missing in FS reports
(cherry picked from commit d905f78984)
2026-01-24 07:08:08 +00:00
Abdeali Chharchhoda
9a175757ac refactor: use console.error for error logging in Plaid integration
(cherry picked from commit 9322095786)
2026-01-24 07:07:48 +00:00
Abdeali Chharchhoda
22a8d483e1 fix: handle undefined bank_transaction_mapping in quick entry
(cherry picked from commit 8a1b8259bd)
2026-01-24 07:07:47 +00:00
Abdeali Chharchhoda
7abaaed957 refactor: remove redundant onload function for bank mapping table
(cherry picked from commit 7c7ba0154a)
2026-01-24 07:07:47 +00:00
mergify[bot]
8946f12677 fix: update country_wise_tax.json for Algerian Taxes (backport #51878) (#52038)
fix: update country_wise_tax.json for Algerian Taxes (#51878)

* Algeria chart of accounts

Algeria chart of accounts

* Update Algeria Chart Of Account

* Algeria chart of account

* Algeria Chart of Account

Algeria Chart of Account

* Modify Algeria tax entries in country_wise_tax.json

Updated tax rates and account names for Algeria.

* Rename account for Algeria tax from VAT to TVA

Rename account for Algeria tax from VAT to TVA

(cherry picked from commit e810cd8440)

Co-authored-by: HALFWARE <contact@half-ware.com>
2026-01-24 06:48:16 +00:00
mergify[bot]
e9f3f0f445 fix: Ensure paid_amount is always numeric before calling allocate_amount_to_references (backport #50935) (#52036)
fix: Ensure paid_amount is always numeric before calling allocate_amount_to_references (#50935)

fix: ensure paid_amount is not null in allocate_party_amount_against_ref_docs
(cherry picked from commit 50b3396064)

Co-authored-by: El-Shafei H. <el.shafei.developer@gmail.com>
2026-01-24 12:03:34 +05:30
ruthra kumar
ba38bc3eaf Merge pull request #52026 from frappe/mergify/bp/version-16-hotfix/pr-51756
fix: disable asset repair when status is fully depreciated (backport #51756)
2026-01-24 09:46:53 +05:30
rohitwaghchaure
b4572978f9 Merge pull request #52031 from frappe/mergify/bp/version-16-hotfix/pr-52024
fix: Bin reserved qty for production for extra material transfer (backport #52024)
2026-01-24 08:47:44 +05:30
Rohit Waghchaure
bf53133f94 fix: Bin reserved qty for production for extra material transfer
(cherry picked from commit f5378b6573)
2026-01-23 15:45:46 +00:00
Mihir Kandoi
23c902c317 Merge pull request #52022 from frappe/mergify/bp/version-16-hotfix/pr-51999
fix(stock): use purchase UOM in Supplier Quotation items (backport #51999)
2026-01-23 19:21:12 +05:30
SowmyaArunachalam
13e4849c43 fix: disable asset repair when status is fully depreciated
(cherry picked from commit 66fe1aa85d)
2026-01-23 11:38:39 +00:00
Bharathidhasan06
f97b850077 fix(stock): use purchase UOM in Supplier Quotation items
(cherry picked from commit 2606ca6fa9)
2026-01-23 08:34:36 +00:00
rohitwaghchaure
b3e12f9acb Merge pull request #52015 from frappe/mergify/bp/version-16-hotfix/pr-52006
fix: negative stock for purchase return (backport #52006)
2026-01-23 13:22:36 +05:30
Rohit Waghchaure
fb3fb8ca5e fix: negative stock for purchae return
(cherry picked from commit d68a04ad16)
2026-01-23 06:04:02 +00:00
rohitwaghchaure
e2232340dc Merge pull request #52005 from frappe/mergify/bp/version-16-hotfix/pr-51989
fix: autofill warehouse for packed items (backport #51989)
2026-01-22 23:56:30 +05:30
Sudharsanan11
881562fc37 fix: autofill warehouse for packed items
(cherry picked from commit 3f8a0a4833)
2026-01-22 17:28:22 +00:00
Mihir Kandoi
dcd6279d47 Merge pull request #51983 from frappe/mergify/bp/version-16-hotfix/pr-51908
fix: throw if item order field is not set in subcontracting controller (backport #51908)
2026-01-22 10:53:05 +05:30
Mihir Kandoi
041a7c5a57 Merge pull request #51982 from frappe/mergify/bp/version-16-hotfix/pr-51929
fix: calculate weighted average rate for customer provided items in subcontracting inward order (backport #51929)
2026-01-22 10:51:54 +05:30
Mihir Kandoi
27ffef41a7 Merge pull request #51980 from frappe/mergify/bp/version-16-hotfix/pr-51966
fix(customer): add customer group filters (backport #51966)
2026-01-22 10:42:45 +05:30
Mihir Kandoi
9038f19fb6 Merge pull request #51978 from frappe/mergify/bp/version-16-hotfix/pr-51967
fix(project): add missing counter to project update naming series (backport #51967)
2026-01-22 10:38:33 +05:30
ljain112
264855e5e1 fix: throw if item order field is not set in subcontracting controller
(cherry picked from commit d256365f4a)
2026-01-22 05:05:22 +00:00
ljain112
7120fbd14b fix: calculate weighted average rate for customer provided items in subcontracting inward order
(cherry picked from commit 37ee560eae)
2026-01-22 05:02:16 +00:00
SowmyaArunachalam
b1716bfeef fix(customer): add customer group filters
(cherry picked from commit 1e3db9f916)
2026-01-22 04:57:04 +00:00
ravibharathi656
37a237dbb7 fix(project): add missing counter to project update naming series
(cherry picked from commit 49e64f4e1c)
2026-01-22 04:53:10 +00:00
Mihir Kandoi
be9112b6fc Merge pull request #51972 from frappe/mergify/bp/version-16-hotfix/pr-51968 2026-01-22 09:04:25 +05:30
Mihir Kandoi
2f240f3553 Merge pull request #51970 from frappe/mergify/bp/version-16-hotfix/pr-51964
fix: create DN btn should not be shown if it cannot be created (backport #51964)
2026-01-21 22:54:50 +05:30
Mihir Kandoi
c7c7a55a58 fix: rejected qty in PR doesn't consider conversion factor
(cherry picked from commit 343ee9695b)
2026-01-21 17:21:00 +00:00
Mihir Kandoi
30e6b5daac fix: create DN btn should not be shown if it cannot be created
(cherry picked from commit 70ec977cb2)
2026-01-21 17:09:48 +00:00
Mihir Kandoi
0b5cc039b6 Merge pull request #51962 from frappe/mergify/bp/version-16-hotfix/pr-51958
fix!: force user to enter batch or serial for serial/batch items (backport #51958)
2026-01-21 16:38:31 +05:30
Mihir Kandoi
6fa60d2f1a fix: tests
(cherry picked from commit 035b3cb61e)
2026-01-21 10:53:46 +00:00
Mihir Kandoi
91199ea9c9 fix: force user to enter batch or serial for serial/batch items
(cherry picked from commit 7170a1bd78)
2026-01-21 10:53:46 +00:00
Mihir Kandoi
e23ba0e852 Merge pull request #51960 from frappe/mergify/bp/version-16-hotfix/pr-51947
fix: job cards should not be deleted on close of WO (backport #51947)
2026-01-21 16:03:09 +05:30
Mihir Kandoi
7c2bbe0d82 fix: job cards should not be deleted on close of WO
(cherry picked from commit c919b1de38)
2026-01-21 10:17:16 +00:00
Mihir Kandoi
0a2234a814 Merge pull request #51951 from frappe/mergify/bp/version-16-hotfix/pr-51948
fix: warehouse permissions in MR incorrectly ignored (backport #51948)
2026-01-21 14:05:14 +05:30
Mihir Kandoi
504c84e28a fix: warehouse permissions in MR incorrectly ignored
(cherry picked from commit 5bacb67d36)
2026-01-21 07:36:22 +00:00
ruthra kumar
bb2bada1fd Merge pull request #51945 from ruthra-kumar/reenable_auto_close
chore: reenable auto close
2026-01-21 10:16:58 +05:30
ruthra kumar
ca85ee33f5 chore: reenable auto close 2026-01-21 10:13:32 +05:30
ruthra kumar
21c1189e24 Merge pull request #51944 from ruthra-kumar/remove_junk_comment
chore: remove stray comment and disable auto close
2026-01-21 10:10:35 +05:30
ruthra kumar
7e7885b304 chore: remove stray comment and disable auto close 2026-01-21 10:09:03 +05:30
Frappe PR Bot
30238e3063 chore(release): Bumped to Version 16.1.0
# [16.1.0](https://github.com/frappe/erpnext/compare/v16.0.1...v16.1.0) (2026-01-20)

### Bug Fixes

* **accounts_controller:** make return message translatable ([621243c](621243c1d3))
* **accounts:** add missing accounting dimensions in advance taxes and charges ([673635e](673635e2c3))
* add below-0 column in ar/ap report (backport [#51673](https://github.com/frappe/erpnext/issues/51673)) ([#51780](https://github.com/frappe/erpnext/issues/51780)) ([5c93bf5](5c93bf5798))
* add company filters for warehouse ([ccab91b](ccab91b9ed))
* add other charges in total ([68c8dfb](68c8dfb24c))
* add uom js error ([a660ed0](a660ed061b))
* add validation for amount and hours ([ce421bb](ce421bb1d4))
* add validation for direct return ([bfd6375](bfd6375508))
* add validation for duplication ([84a749e](84a749e3d0))
* add validation for return against ([6dade11](6dade11d8f))
* allow creation of DN in SI for items not having DN reference ([fef6df7](fef6df709d))
* allow disassemble stock entry without work order (backport [#51761](https://github.com/frappe/erpnext/issues/51761)) ([#51836](https://github.com/frappe/erpnext/issues/51836)) ([c830bf6](c830bf6fc7))
* **bank_account:** validation for is_company_account ([5d5d208](5d5d208a49))
* **bom:** pass company warehouse filter ([3c533d0](3c533d04f5))
* **budget variance report:** check budget dimensions ([a3d860e](a3d860eabf))
* bugs ([accce1f](accce1fe59))
* calculate net profit amount from root node accounts ([89b44c4](89b44c41a2))
* change docfield type to render html format (backport [#51795](https://github.com/frappe/erpnext/issues/51795)) ([#51804](https://github.com/frappe/erpnext/issues/51804)) ([fcea760](fcea7603a8))
* common_party_path ([#51826](https://github.com/frappe/erpnext/issues/51826)) ([aeb2b60](aeb2b60450))
* continuous raw material consumption with bom validation (backport [#51914](https://github.com/frappe/erpnext/issues/51914)) ([#51919](https://github.com/frappe/erpnext/issues/51919)) ([c9d7c6c](c9d7c6cd42))
* docs_path ([86d5939](86d5939d91))
* dont show certain fields based on permissions ([d3dfed9](d3dfed909e))
* handle return cancellation ([65a1c70](65a1c7086b))
* include total hours validation in depends on ([cbfc137](cbfc13728b))
* **manufacturing:** consider process loss qty while validating the work order ([7b3f746](7b3f74609a))
* no attribute error on LCV ([fe59ace](fe59ace285))
* no attribute error on subcontracting receipt ([2131c7a](2131c7aadb))
* overproduction % not considered when making WO from SO ([fb669eb](fb669eb6f4))
* **pos:** reapply set warehouse during cart update ([6869115](686911546f))
* **postgres:** compute current month sales without DATE_FORMAT ([49760e4](49760e4542))
* prevent UOM from updating incorrectly while scanning barcode ([9d5a0e5](9d5a0e56a0))
* qty with serial no count ([ae6b3af](ae6b3af013))
* remove already transferred batch ([f1e41f4](f1e41f4a4f))
* setting process loss qty causes fg item qty to be incorrect ([cb2d455](cb2d4550af))
* Show non-SLE vouchers with GL entries in Stock vs Account Value Comparison report ([e64ae9a](e64ae9a8a9))
* **stock:** resolve quantity issue when adding items via barcode scan ([ab482ca](ab482caac9))
* **transaction.js:** use flt instead of cint for plc_conversion_rate ([8ba4701](8ba470160d))
* validation message in stock reco row idx ([176096b](176096bc5b))
* valuation rate for non batchwise valuation ([768c131](768c131073))

### Features

* add list_view status for partial billing ([9b88275](9b88275312))
* add new 2025 Charts of Accounts for France ([9cc9fa5](9cc9fa59be))
* Adding Item name in update item dialog box ([1da8ed2](1da8ed202b))
* modify field properties ([e49add2](e49add20b7))
* remove old French chart of accounts with code as nex 2025 is provided ([3bdaab1](3bdaab149b))
* support for serial item ([c4c2d35](c4c2d35565))
* **timesheet:** handle partial billing in sales invoice ([332673f](332673f260))

### Performance Improvements

* prevent duplicate reposting for the same item ([3ac431b](3ac431bd50))
2026-01-20 16:47:59 +00:00
ruthra kumar
35b3045b72 Merge pull request #51911 from frappe/version-16-hotfix
chore: release v16
2026-01-20 22:16:27 +05:30
Mihir Kandoi
cf130ff865 Merge pull request #51936 from frappe/mergify/bp/version-16-hotfix/pr-51934
fix: validation message in stock reco row idx (backport #51934)
2026-01-20 21:05:40 +05:30
Mihir Kandoi
176096bc5b fix: validation message in stock reco row idx
(cherry picked from commit 3960c01798)
2026-01-20 15:17:53 +00:00
rohitwaghchaure
e854eafc0b Merge pull request #51933 from frappe/mergify/bp/version-16-hotfix/pr-51930
Revert "perf: prevent duplicate reposting for the same item" (backport #51930)
2026-01-20 20:05:41 +05:30
rohitwaghchaure
72cdddbeda Revert "perf: prevent duplicate reposting for the same item"
(cherry picked from commit 6e4b90055f)
2026-01-20 14:19:47 +00:00
mergify[bot]
c9d7c6cd42 fix: continuous raw material consumption with bom validation (backport #51914) (#51919)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-01-20 12:56:27 +00:00
Diptanil Saha
8393c3c32d Merge pull request #51922 from frappe/mergify/bp/version-16-hotfix/pr-51887
fix(bank_account): `is_company_account` related validations (backport #51887)
2026-01-20 18:09:01 +05:30
Mihir Kandoi
5752d2e0a1 Merge pull request #51926 from frappe/mergify/bp/version-16-hotfix/pr-51909
fix: allow creation of DN in SI for items not having DN reference (backport #51909)
2026-01-20 18:03:35 +05:30
rohitwaghchaure
f25558b4d7 Merge pull request #51924 from frappe/mergify/bp/version-16-hotfix/pr-51920
perf: prevent duplicate reposting for the same item (backport #51920)
2026-01-20 18:01:21 +05:30
Mihir Kandoi
fef6df709d fix: allow creation of DN in SI for items not having DN reference
(cherry picked from commit b691de0147)
2026-01-20 12:14:59 +00:00
Rohit Waghchaure
3ac431bd50 perf: prevent duplicate reposting for the same item
(cherry picked from commit 7535931571)
2026-01-20 12:09:03 +00:00
diptanilsaha
5d5d208a49 fix(bank_account): validation for is_company_account
(cherry picked from commit 7532ab01d6)
2026-01-20 11:53:16 +00:00
ruthra kumar
9535f3d583 Merge pull request #51916 from frappe/mergify/bp/version-16-hotfix/pr-51671
fix(accounts): add missing accounting dimensions in advance taxes and charges (backport #51671)
2026-01-20 17:19:35 +05:30
Nikhil Kothari
673635e2c3 fix(accounts): add missing accounting dimensions in advance taxes and charges
(cherry picked from commit 22e9cb4cf4)

# Conflicts:
#	erpnext/patches.txt
2026-01-20 17:04:51 +05:30
Rohit Waghchaure
d067e37ab6 fix: validation to check at-least one raw material for manufacture entry
(cherry picked from commit f003b3c378)
2026-01-20 08:25:59 +00:00
Mihir Kandoi
37e241ba15 Merge pull request #51897 from frappe/mergify/bp/version-16-hotfix/pr-51895
fix: overproduction % not considered when making WO from SO (backport #51895)
2026-01-20 13:25:01 +05:30
Mihir Kandoi
fb669eb6f4 fix: overproduction % not considered when making WO from SO
(cherry picked from commit edba9efb5e)
2026-01-20 07:34:24 +00:00
ruthra kumar
232225d753 Merge pull request #51891 from frappe/mergify/bp/version-16-hotfix/pr-51561
fix: delete advance ledger entries  while reconciling payment entry (backport #51561)
2026-01-20 08:17:44 +05:30
ruthra kumar
80cbd851d1 Merge pull request #51893 from frappe/mergify/bp/version-16-hotfix/pr-51886
fix(accounts_controller): make return message translatable (backport #51886)
2026-01-20 08:13:12 +05:30
ruthra kumar
5474ac298d Merge pull request #51884 from frappe/mergify/bp/version-16-hotfix/pr-51830
fix(manufacturing): consider process loss qty while validating the work order (backport #51830)
2026-01-20 08:08:29 +05:30
ruthra kumar
7a9b10a05e Merge pull request #51861 from frappe/mergify/bp/version-16-hotfix/pr-51822
fix(budget variance report): check budget dimensions (backport #51822)
2026-01-20 07:56:49 +05:30
barredterra
621243c1d3 fix(accounts_controller): make return message translatable
(cherry picked from commit 0209f0fe29)
2026-01-20 02:26:48 +00:00
Lakshit Jain
efa5173964 Merge pull request #51561 from ljain112/fic-adv-ple-po
fix: delete advance ledger entries  while reconciling payment entry
(cherry picked from commit aea70c5ec1)
2026-01-20 02:21:34 +00:00
Sudharsanan11
7b3f74609a fix(manufacturing): consider process loss qty while validating the work order
(cherry picked from commit e6366e830c)
2026-01-19 16:18:36 +00:00
Mihir Kandoi
775f6d07b1 Merge pull request #51882 from frappe/mergify/bp/version-16-hotfix/pr-51880
fix: no attribute error on LCV (backport #51880)
2026-01-19 20:30:03 +05:30
Mihir Kandoi
e80ed14456 Merge pull request #51881 from frappe/mergify/bp/version-16-hotfix/pr-51879
fix: no attribute error on subcontracting receipt (backport #51879)
2026-01-19 20:15:55 +05:30
Mihir Kandoi
fe59ace285 fix: no attribute error on LCV
(cherry picked from commit ad11914fca)
2026-01-19 14:35:22 +00:00
Mihir Kandoi
2131c7aadb fix: no attribute error on subcontracting receipt
(cherry picked from commit fbac8b032e)
2026-01-19 14:30:08 +00:00
Diptanil Saha
b7284c7717 Merge pull request #51877 from frappe/mergify/bp/version-16-hotfix/pr-51595 2026-01-19 18:12:07 +05:30
Florian HENRY
6b9107c05c chore: re add older template
(cherry picked from commit b3efb3084f)
2026-01-19 12:23:31 +00:00
Florian HENRY
1ed8857d31 chore: fix bank account type
(cherry picked from commit 4fe1b214c1)
2026-01-19 12:23:31 +00:00
Florian HENRY
a195690bc8 chore: fix CASH acount type
(cherry picked from commit 6a876de838)
2026-01-19 12:23:31 +00:00
Florian HENRY
0c546c9e5a chore: fix bank acount type
(cherry picked from commit 765487a087)
2026-01-19 12:23:30 +00:00
Florian HENRY
11d9fd3dee chore: add Expenses Included In Valuation account
(cherry picked from commit c519cd0268)
2026-01-19 12:23:30 +00:00
Florian HENRY
3bdaab149b feat: remove old French chart of accounts with code as nex 2025 is provided
(cherry picked from commit bf430fce09)
2026-01-19 12:23:30 +00:00
Florian HENRY
ad4ac4e53c chore: Review PR #51595
(cherry picked from commit 6bdaeb983d)
2026-01-19 12:23:30 +00:00
Florian HENRY
9cc9fa59be feat: add new 2025 Charts of Accounts for France
(cherry picked from commit c81dee137f)
2026-01-19 12:23:30 +00:00
rohitwaghchaure
ab2aedd9a2 Merge pull request #51866 from frappe/mergify/bp/version-16-hotfix/pr-51769
fix(pos): reapply set warehouse during cart update (backport #51769)
2026-01-19 15:44:38 +05:30
ravibharathi656
686911546f fix(pos): reapply set warehouse during cart update
(cherry picked from commit 5a53c45321)
2026-01-19 10:07:50 +00:00
rohitwaghchaure
4f3078ab1a Merge pull request #51863 from frappe/mergify/bp/version-16-hotfix/pr-51690
feat: Adding Item name in update item dialog box (backport #51690)
2026-01-19 15:34:47 +05:30
rohitwaghchaure
a950adab79 Merge pull request #51864 from frappe/mergify/bp/version-16-hotfix/pr-51856
fix: qty with serial no count (backport #51856)
2026-01-19 15:34:23 +05:30
Rohit Waghchaure
ae6b3af013 fix: qty with serial no count
(cherry picked from commit 56e58ef301)
2026-01-19 10:00:35 +00:00
Nishka Gosalia
1da8ed202b feat: Adding Item name in update item dialog box
(cherry picked from commit e6133ad6d4)
2026-01-19 10:00:34 +00:00
ervishnucs
a3d860eabf fix(budget variance report): check budget dimensions
(cherry picked from commit cb696a8880)
2026-01-19 09:55:17 +00:00
rohitwaghchaure
adc9dc82ca Merge pull request #51848 from frappe/mergify/bp/version-16-hotfix/pr-51644
Refactor batch bundle get snos sle (backport #51644)
2026-01-19 15:15:20 +05:30
Rohit Waghchaure
79e04ea1fe chore: fix semantic commit message
(cherry picked from commit dfcbee9cc0)
2026-01-19 09:22:45 +00:00
krupalvora
0981b894dd refactor: Batch & Bundle get sle for snos - Added docstring
(cherry picked from commit 22dee50348)
2026-01-19 09:22:45 +00:00
krupalvora
380564a677 refactor: Batch & Bundle get Stock ledger for snos - added posting date in select
(cherry picked from commit 1ccc7365a7)
2026-01-19 09:22:45 +00:00
krupalvora
75deb180fb refactor: Batch & Bundle get Stock ledger for snos v2
(cherry picked from commit a074d81754)
2026-01-19 09:22:45 +00:00
krupalvora
839315752b refactor: Batch & Bundle get Stock ledger for snos
(cherry picked from commit c0149925ad)
2026-01-19 09:22:44 +00:00
Mihir Kandoi
2b4a547e23 Merge pull request #51847 from frappe/mergify/bp/version-16-hotfix/pr-51845
fix(bom): pass company warehouse filter (backport #51845)
2026-01-19 14:18:39 +05:30
22-poojashree
3c533d04f5 fix(bom): pass company warehouse filter
(cherry picked from commit 73bcfc4710)
2026-01-19 08:34:16 +00:00
ruthra kumar
1c214eec98 Merge pull request #51844 from frappe/mergify/bp/version-16-hotfix/pr-51826
fix: common_party_path (backport #51826)
2026-01-19 13:21:14 +05:30
mahsem
aeb2b60450 fix: common_party_path (#51826)
* fix: common_pary_path

* chore: remove non-existent anchor

---------

Co-authored-by: ruthra kumar <ruthra@erpnext.com>
(cherry picked from commit 0c0f43f7f7)
2026-01-19 07:50:30 +00:00
ruthra kumar
1b3f5e1c96 Merge pull request #51841 from frappe/mergify/bp/version-16-hotfix/pr-51513
fix: calculate net profit amount from root node accounts (backport #51513)
2026-01-19 13:05:56 +05:30
mergify[bot]
c830bf6fc7 fix: allow disassemble stock entry without work order (backport #51761) (#51836)
fix: allow disassemble stock entry without work order (#51761)

* fix: allow disassemble stock entry without work order

* fix: use existing functionality to load fg item

* chore: better dict update

(cherry picked from commit 83919119f8)

Co-authored-by: Smit Vora <smitvora203@gmail.com>
2026-01-19 12:54:06 +05:30
ruthra kumar
e2b95da24d Merge pull request #51839 from frappe/mergify/bp/version-16-hotfix/pr-51787
fix: recalculate taxes when item tax template changes after discount (backport #51787)
2026-01-19 12:50:34 +05:30
Navin-S-R
89b44c41a2 fix: calculate net profit amount from root node accounts
(cherry picked from commit c84986d00e)
2026-01-19 07:15:50 +00:00
Lakshit Jain
181141b56a Merge pull request #51787 from ljain112/fix-taxes-disc
fix: recalculate taxes when item tax template changes after discount
(cherry picked from commit f00aeec9b4)
2026-01-19 07:01:52 +00:00
ruthra kumar
5742a5d86a Merge pull request #51834 from frappe/mergify/bp/version-16-hotfix/pr-51742
fix: add other charges in total (backport #51742)
2026-01-19 11:34:22 +05:30
SowmyaArunachalam
68c8dfb24c fix: add other charges in total
(cherry picked from commit 9406c07c42)
2026-01-19 05:45:20 +00:00
Mihir Kandoi
7f54de7926 Merge pull request #51829 from frappe/mergify/bp/version-16-hotfix/pr-51824
fix: setting process loss qty causes fg item qty to be incorrect (backport #51824)
2026-01-18 22:51:58 +05:30
Mihir Kandoi
cb2d4550af fix: setting process loss qty causes fg item qty to be incorrect
(cherry picked from commit 56f5df6847)
2026-01-18 17:21:03 +00:00
Mihir Kandoi
c22d7e16d1 Merge pull request #51821 from frappe/mergify/bp/version-16-hotfix/pr-51817
fix: prevent UOM from updating incorrectly while scanning barcode (backport #51817)
2026-01-18 15:10:56 +05:30
Pandiyan5273
9d5a0e56a0 fix: prevent UOM from updating incorrectly while scanning barcode
(cherry picked from commit 30263b26a5)
2026-01-18 09:36:44 +00:00
mergify[bot]
fcea7603a8 fix: change docfield type to render html format (backport #51795) (#51804)
fix: change docfield type to render html format (#51795)

(cherry picked from commit 3fe5b5c80d)

Co-authored-by: Sowmya <106989392+SowmyaArunachalam@users.noreply.github.com>
2026-01-17 15:12:57 +05:30
ruthra kumar
42ebb7446a Merge pull request #51797 from frappe/mergify/bp/version-16-hotfix/pr-51555
fix(postgres): compute current month sales without DATE_FORMAT (backport #51555)
2026-01-16 17:15:48 +05:30
Matt Howard
49760e4542 fix(postgres): compute current month sales without DATE_FORMAT
(cherry picked from commit 64f391adf7)
2026-01-16 11:29:08 +00:00
Mihir Kandoi
6e1f4d84b6 Merge pull request #51794 from frappe/mergify/bp/version-16-hotfix/pr-51790
fix(stock): resolve quantity issue when adding items via barcode scan (backport #51790)
2026-01-16 16:20:37 +05:30
Pandiyan5273
ab482caac9 fix(stock): resolve quantity issue when adding items via barcode scan
(cherry picked from commit f959b2c59a)
2026-01-16 10:49:36 +00:00
Mihir Kandoi
d8506fb2c0 Merge pull request #51792 from frappe/mergify/bp/version-16-hotfix/pr-51791
fix: dont show certain fields based on permissions (backport #51791)
2026-01-16 16:02:37 +05:30
Mihir Kandoi
d3dfed909e fix: dont show certain fields based on permissions
(cherry picked from commit b3db2981de)
2026-01-16 10:31:39 +00:00
Mihir Kandoi
ac31c5ca19 Merge pull request #51789 from frappe/mergify/bp/version-16-hotfix/pr-51784
fix: add company filters for warehouse (backport #51784)
2026-01-16 15:16:05 +05:30
SowmyaArunachalam
ccab91b9ed fix: add company filters for warehouse
(cherry picked from commit f952b92d71)
2026-01-16 09:44:42 +00:00
Mihir Kandoi
541a8b135a Merge pull request #51785 from frappe/mergify/bp/version-16-hotfix/pr-51693 2026-01-16 14:09:49 +05:30
Mihir Kandoi
c0a30a5302 chore: typo
(cherry picked from commit 8fd1d6aec8)
2026-01-16 08:25:03 +00:00
Mihir Kandoi
accce1fe59 fix: bugs
(cherry picked from commit 19ae405742)
2026-01-16 08:25:03 +00:00
Mihir Kandoi
f04221417e test: add test case
(cherry picked from commit b567184dd7)
2026-01-16 08:25:03 +00:00
Mihir Kandoi
c4c2d35565 feat: support for serial item
(cherry picked from commit 3d0f649411)
2026-01-16 08:25:02 +00:00
Mihir Kandoi
f1e41f4a4f fix: remove already transferred batch
(cherry picked from commit b54067e04d)
2026-01-16 08:25:02 +00:00
Mihir Kandoi
d9326d80de refactor: sample retention stock entry
(cherry picked from commit 8d188cd32b)
2026-01-16 08:25:02 +00:00
ruthra kumar
5c93bf5798 fix: add below-0 column in ar/ap report (backport #51673) (#51780)
Merge pull request #51673 from Jatin3128/ar/ap-future-range-fix

fix: add below-0 column in ar/ap report
(cherry picked from commit c5b0787de6)

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
2026-01-16 12:46:26 +05:30
Jatin3128
f62ad83d6f Merge pull request #51673 from Jatin3128/ar/ap-future-range-fix
fix: add below-0 column in ar/ap report
(cherry picked from commit c5b0787de6)
2026-01-16 06:37:34 +00:00
Ankush Menat
876e2d4e6e build: Update Frappe dependency (#51779) 2026-01-16 11:24:43 +05:30
rohitwaghchaure
4977e06c50 Merge pull request #51772 from frappe/mergify/bp/version-16-hotfix/pr-51768
fix: Show non-SLE vouchers with GL entries in Stock vs Account Value … (backport #51768)
2026-01-15 19:26:45 +05:30
Rohit Waghchaure
e64ae9a8a9 fix: Show non-SLE vouchers with GL entries in Stock vs Account Value Comparison report
(cherry picked from commit 1db9ce205f)
2026-01-15 12:20:36 +00:00
rohitwaghchaure
6ddf4eee15 Merge pull request #51752 from frappe/mergify/bp/version-16-hotfix/pr-51729
fix: valuation rate for non batchwise valuation (backport #51729)
2026-01-15 17:00:32 +05:30
Mihir Kandoi
d27a09cb9f Merge pull request #51755 from frappe/mergify/bp/version-16-hotfix/pr-51753
fix: docs_path (backport #51753)
2026-01-14 21:31:31 +05:30
mahsem
86d5939d91 fix: docs_path
(cherry picked from commit 7ef8c81caf)
2026-01-14 16:00:03 +00:00
Rohit Waghchaure
768c131073 fix: valuation rate for non batchwise valuation
(cherry picked from commit b6312bca9c)
2026-01-14 14:06:50 +00:00
Diptanil Saha
8f77223057 Merge pull request #51748 from frappe/mergify/bp/version-16-hotfix/pr-51730
fix(transaction.js): use flt instead of cint for plc_conversion_rate (backport #51730)
2026-01-14 15:55:13 +05:30
diptanilsaha
8ba470160d fix(transaction.js): use flt instead of cint for plc_conversion_rate
(cherry picked from commit 8b445e04e5)
2026-01-14 10:22:21 +00:00
Mihir Kandoi
1a6264d831 Merge pull request #51737 from frappe/mergify/bp/version-16-hotfix/pr-51684 2026-01-14 11:37:00 +05:30
Mihir Kandoi
19a90c0980 Merge pull request #51736 from frappe/mergify/bp/version-16-hotfix/pr-51295 2026-01-14 11:18:57 +05:30
Pandiyan5273
d57fc49896 test(stock-entry): manufacture entry without work order
(cherry picked from commit 784e338be4)
2026-01-14 05:34:40 +00:00
l0gesh29
fe0431a6d0 chore: modify error msg
(cherry picked from commit f7004aa8c3)
2026-01-14 05:33:39 +00:00
l0gesh29
bfd6375508 fix: add validation for direct return
(cherry picked from commit 8379b39aaf)
2026-01-14 05:33:38 +00:00
l0gesh29
6dade11d8f fix: add validation for return against
(cherry picked from commit ff9b936634)
2026-01-14 05:33:38 +00:00
Logesh Periyasamy
ce421bb1d4 fix: add validation for amount and hours
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
(cherry picked from commit 43d1d685c6)
2026-01-14 05:33:38 +00:00
l0gesh29
84a749e3d0 fix: add validation for duplication
(cherry picked from commit cda8a97f4a)
2026-01-14 05:33:37 +00:00
l0gesh29
65a1c7086b fix: handle return cancellation
(cherry picked from commit 50f73a5072)
2026-01-14 05:33:37 +00:00
l0gesh29
a04da71182 test: add test for partial billing and return
(cherry picked from commit ae594e81f9)
2026-01-14 05:33:37 +00:00
l0gesh29
cbfc13728b fix: include total hours validation in depends on
(cherry picked from commit 57d34ab146)
2026-01-14 05:33:37 +00:00
l0gesh29
9b88275312 feat: add list_view status for partial billing
(cherry picked from commit ff0b37055b)
2026-01-14 05:33:36 +00:00
l0gesh29
332673f260 feat(timesheet): handle partial billing in sales invoice
(cherry picked from commit c87b5d3132)
2026-01-14 05:33:36 +00:00
l0gesh29
e49add20b7 feat: modify field properties
(cherry picked from commit 38a4642479)
2026-01-14 05:33:36 +00:00
Mihir Kandoi
c3b0633eda Merge pull request #51734 from frappe/mergify/bp/version-16-hotfix/pr-51733
fix: add uom js error (backport #51733)
2026-01-14 10:28:24 +05:30
Mihir Kandoi
a660ed061b fix: add uom js error
(cherry picked from commit 6d3f6d73d0)
2026-01-14 04:55:22 +00:00
Frappe PR Bot
0b1c0c36b5 chore(release): Bumped to Version 16.0.1
## [16.0.1](https://github.com/frappe/erpnext/compare/v16.0.0...v16.0.1) (2026-01-13)

### Bug Fixes

* **asset value adjustment:** skip cancelling revaluation journal entry if already cancelled (backport [#51666](https://github.com/frappe/erpnext/issues/51666)) ([#51716](https://github.com/frappe/erpnext/issues/51716)) ([4b85d51](4b85d51257))
* Redirect to Desktop after signup ([#51696](https://github.com/frappe/erpnext/issues/51696)) ([0363b01](0363b01ab7))
* Redirect to Desktop after signup ([#51696](https://github.com/frappe/erpnext/issues/51696)) ([#51697](https://github.com/frappe/erpnext/issues/51697)) ([294fb27](294fb27dc8))
* Redirect to Desktop after signup (backport [#51696](https://github.com/frappe/erpnext/issues/51696)) ([#51714](https://github.com/frappe/erpnext/issues/51714)) ([2118321](211832104c))
* stock module not opened when no warehouses ([3420e21](3420e21d45))
* **tds:** correct tax logic for customer ([50ce61a](50ce61ae02))
2026-01-13 16:20:34 +00:00
Mihir Kandoi
d316ef2306 Merge pull request #51728 from frappe/trigger-release-v16 2026-01-13 21:44:48 +05:30
Mihir Kandoi
af3a7903b3 chore: trigger release 2026-01-13 21:43:30 +05:30
Mihir Kandoi
a66e114a71 Merge pull request #51727 from frappe/change-release-branch 2026-01-13 21:34:26 +05:30
Mihir Kandoi
631b9d3bb0 chore: update release branch from version-13 to version-16 2026-01-13 20:32:48 +05:30
Mihir Kandoi
eb03781718 Merge pull request #51713 from frappe/version-16-hotfix 2026-01-13 20:17:06 +05:30
rohitwaghchaure
eb7cebac91 Merge pull request #51720 from frappe/mergify/bp/version-16-hotfix/pr-51719
fix: stock module not opened when no warehouses (backport #51719)
2026-01-13 17:38:25 +05:30
Rohit Waghchaure
3420e21d45 fix: stock module not opened when no warehouses
(cherry picked from commit 9de3b07223)
2026-01-13 11:49:56 +00:00
Mihir Kandoi
211832104c fix: Redirect to Desktop after signup (backport #51696) (#51714)
Co-authored-by: Nabin Hait <nabinhait@gmail.com>
fix: Redirect to Desktop after signup (#51696)
2026-01-13 16:07:20 +05:30
mergify[bot]
4b85d51257 fix(asset value adjustment): skip cancelling revaluation journal entry if already cancelled (backport #51666) (#51716)
Co-authored-by: Navin-S-R <navin@aerele.in>
2026-01-13 16:05:14 +05:30
Nabin Hait
0363b01ab7 fix: Redirect to Desktop after signup (#51696)
(cherry picked from commit 3bc58fb46f)
2026-01-13 09:46:21 +00:00
Mihir Kandoi
fc517f7fa2 Merge pull request #51707 from mihir-kandoi/ci-patch-test-2 2026-01-13 15:11:37 +05:30
ruthra kumar
18451b69e6 Merge pull request #51703 from frappe/mergify/bp/version-16-hotfix/pr-51412
fix(tds): correct tax logic for customer (backport #51412)
2026-01-13 11:41:28 +05:30
ljain112
50ce61ae02 fix(tds): correct tax logic for customer
(cherry picked from commit 86b0f67dbc)
2026-01-13 05:37:32 +00:00
Nabin Hait
294fb27dc8 fix: Redirect to Desktop after signup (#51696) (#51697) 2026-01-12 19:22:24 +05:30
Rohit Waghchaure
c3fdb191b9 Merge branch 'develop' into version-16 2026-01-12 16:20:48 +05:30
Rohit Waghchaure
93db2ebd6f Merge branch 'develop' into version-16 2026-01-12 15:07:27 +05:30
rohitwaghchaure
2925f9a04e chore: weekly release for v16 2026-01-12 14:29:14 +05:30
rohitwaghchaure
c7bf103c0c chore: fix version 2026-01-12 14:04:45 +05:30
rohitwaghchaure
6dead8fd85 chore: fix version 2026-01-12 12:19:58 +05:30
1364 changed files with 206055 additions and 399962 deletions

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.frappe.io](https://discuss.frappe.io/c/erpnext/6).
### 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 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,6 +1,6 @@
---
name: Feature request
about: Suggest an idea or enhancement for ERPNext
about: Suggest an idea to improve ERPNext
title: ''
labels: feature-request
assignees: ''
@@ -17,21 +17,17 @@ Welcome to ERPNext issue tracker! Before creating an issue, please heed the foll
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.
Please keep in mind that we get many 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:
If you're in urgent need to 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].
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
@@ -39,17 +35,5 @@ 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

@@ -15,7 +15,7 @@ jobs:
strategy:
fail-fast: false
matrix:
branch: ["develop", "version-16-hotfix"]
branch: ["develop"]
permissions:
contents: write
@@ -30,11 +30,6 @@ jobs:
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

View File

@@ -19,7 +19,7 @@ jobs:
strategy:
fail-fast: false
matrix:
version: ["15", "16"]
version: ["14", "15", "16"]
steps:
- uses: octokit/request-action@v2.x

View File

@@ -43,6 +43,3 @@ jobs:
- 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

@@ -143,7 +143,6 @@ jobs:
}
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##*/}}"

View File

@@ -2,7 +2,7 @@ name: Generate Semantic Release
on:
push:
branches:
- version-13
- version-16
permissions:
contents: read

View File

@@ -4,8 +4,8 @@ on:
workflow_dispatch:
concurrency:
group: server-individual-tests-lightmode-develop
cancel-in-progress: true
group: server-individual-tests-develop-${{ github.event_name }}-${{ github.event.number || github.event_name == 'workflow_dispatch' && github.run_id || '' }}
cancel-in-progress: false
permissions:
contents: read
@@ -21,7 +21,7 @@ jobs:
- 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 '{
matrix=$(find . -path '*/doctype/*/test_*.py' | xargs grep -l 'def test_' | awk '{
# Remove ./ prefix, file extension, and replace / with .
gsub(/^\.\//, "", $0)
gsub(/\.py$/, "", $0)
@@ -58,7 +58,6 @@ jobs:
strategy:
fail-fast: false
matrix: ${{fromJson(needs.discover.outputs.matrix)}}
max-parallel: 14
name: Test
@@ -131,13 +130,4 @@ jobs:
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
run: 'cd ~/frappe-bench/ && bench --site test_site run-tests --app erpnext --module ${{ matrix.test }}'

View File

@@ -41,7 +41,6 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
env:
TZ: 'Asia/Kolkata'
NODE_ENV: "production"
WITH_COVERAGE: ${{ github.event_name != 'pull_request' }}
@@ -57,7 +56,6 @@ jobs:
mysql:
image: mariadb:10.6
env:
TZ: 'Asia/Kolkata'
MARIADB_ROOT_PASSWORD: 'root'
ports:
- 3306:3306
@@ -131,7 +129,7 @@ jobs:
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'
run: 'cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app erpnext --total-builds ${{ strategy.job-total }} --build-number ${{ matrix.container }} --with-coverage'
env:
TYPE: server

4
.gitignore vendored
View File

@@ -19,7 +19,3 @@ node_modules/
.backportrc.json
# Aider AI Chat
.aider*
# Banking SPA
erpnext/public/banking
erpnext/www/banking.html

View File

@@ -50,13 +50,13 @@ pull_request_rules:
- version-15-hotfix
assignees:
- "{{ author }}"
- name: backport to version-16-hotfix
- name: backport to version-16-beta
conditions:
- label="backport version-16-hotfix"
- label="backport version-16-beta"
actions:
backport:
branches:
- version-16-hotfix
- version-16-beta
assignees:
- "{{ author }}"
- name: Automatic merge on CI success and review

View File

@@ -1,5 +1,5 @@
{
"branches": ["version-13"],
"branches": ["version-16"],
"plugins": [
"@semantic-release/commit-analyzer", {
"preset": "angular",
@@ -21,4 +21,4 @@
],
"@semantic-release/github"
]
}
}

View File

@@ -7,17 +7,17 @@ erpnext/accounts/ @ruthra-kumar
erpnext/assets/ @khushi8112
erpnext/regional @ruthra-kumar
erpnext/selling @ruthra-kumar
erpnext/support/ @ruthra-kumar
erpnext/buying/ @rohitwaghchaure @mihir-kandoi
erpnext/maintenance/ @rohitwaghchaure @mihir-kandoi
erpnext/maintenance/ @rohitwaghchaure
erpnext/manufacturing/ @rohitwaghchaure @mihir-kandoi
erpnext/quality_management/ @rohitwaghchaure @mihir-kandoi
erpnext/quality_management/ @rohitwaghchaure
erpnext/stock/ @rohitwaghchaure @mihir-kandoi
erpnext/subcontracting/ @mihir-kandoi
erpnext/projects/ @nishkagosalia
erpnext/subcontracting @mihir-kandoi
erpnext/controllers/ @ruthra-kumar @rohitwaghchaure @mihir-kandoi
erpnext/patches/ @ruthra-kumar @rohitwaghchaure @mihir-kandoi
erpnext/patches/ @ruthra-kumar
.github/ @ruthra-kumar @mihir-kandoi
.github/ @ruthra-kumar
pyproject.toml @ruthra-kumar

View File

@@ -1,21 +1,21 @@
<div align="center">
<a href="https://frappe.io/erpnext">
<img src="./erpnext/public/images/v16/erpnext.svg" alt="ERPNext Logo" height="80px" width="80px"/>
<img src="./erpnext/public/images/v16/erpnext.svg" alt="ERPNext Logo" height="80px" width="80xp"/>
</a>
<h2>ERPNext</h2>
<div align="center">
<p align="center">
<p>Powerful, Intuitive and Open-Source ERP</p>
</div>
</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)
[![docker pulls](https://img.shields.io/docker/pulls/frappe/erpnext-worker.svg)](https://hub.docker.com/r/frappe/erpnext-worker)
</div>
<div align="center">
<img src="./erpnext/public/images/v16/hero_image.png" alt="ERPNext Hero Image"/>
<img src="./erpnext/public/images/v16/hero_image.png"/>
</div>
<div align="center">
@@ -28,19 +28,19 @@
## ERPNext
100% Open-Source ERP System to help you run your business.
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.
Running a business is a complex task - handling invoices, tracking stock, managing personnel and even more ad-hoc activities. 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.
- **Asset Management**: From purchase to perishment, IT infrastructure to equipment. Cover every branch of your organization, all in one centralized system.
- **Projects**: Delivery both internal and external Projects on time, budget and Profitability. Track tasks, timesheets, and issues by project.
<details open>
@@ -53,7 +53,7 @@ Running a business is a complex task - handling invoices, tracking stock, managi
### 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 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.
@@ -61,12 +61,12 @@ Running a business is a complex task - handling invoices, tracking stock, managi
### 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.
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 with peace of mind.
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.
It takes care of 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">
<a href="https://erpnext-demo.frappe.cloud/app/home" target="_blank">
<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" />
@@ -75,40 +75,25 @@ It handles installation, setup, upgrades, monitoring, maintenance, and support o
</div>
### Self-Hosted
#### Docker
See [Frappe Docker Documentation](https://github.com/frappe/frappe_docker) for full documentation & FAQ on Docker setup
Prerequisites: docker, docker-compose, git. Refer [Docker Documentation](https://docs.docker.com) for more details on Docker setup.
#### Prerequisites
Run following commands:
- [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
After a couple of minutes, site should be accessible on your localhost port: 8080. Use below default login credentials to access the site.
- Username: Administrator
- Password: admin
See [Frappe Docker](https://github.com/frappe/frappe_docker?tab=readme-ov-file#to-run-on-arm64-architecture-follow-this-instructions) for ARM based docker setup.
## Development Setup
@@ -116,7 +101,7 @@ See [Frappe Docker](https://github.com/frappe/frappe_docker/blob/main/docs/01-ge
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).
### Local
@@ -145,20 +130,20 @@ To setup the repository locally follow the steps mentioned below:
4. Open the URL `http://erpnext.localhost:8000/app` in your browser, you should see the app running
## Learning and Community
## 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.
3. [Discussion Forum](https://discuss.frappe.io/c/erpnext/6) - Engage with community of ERPNext users and service providers.
4. [Telegram Group](https://erpnext_public.t.me) - Get instant help from huge community of users.
## 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/security)
1. [Pull Request Requirements](https://github.com/frappe/erpnext/wiki/Contribution-Guidelines)
2. [Translations](https://crowdin.com/project/frappe)
## Logo and Trademark Policy

View File

@@ -1,7 +1,7 @@
# 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).
The ERPNext team and community take security issues seriously. To report a security issue, fill out the form at [https://erpnext.com/security/report](https://erpnext.com/security/report).
You can help us make ERPNext and all its users more secure by following the [Reporting guidelines](https://frappe.io/security).
You can help us make ERPNext and all it's users more secure by following the [Reporting guidelines](https://erpnext.com/security).
We appreciate your efforts to responsibly disclose your findings. We'll endeavor to respond quickly, and will keep you updated throughout the process.
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

@@ -18,9 +18,8 @@ We will grant permission to use the ERPNext name and logo for projects that meet
- 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.
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:

View File

@@ -1,5 +1,3 @@
**/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
**.tsx frappe.gettext.extractors.html_template.extract
**.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,
}

View File

@@ -1,263 +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 buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap transition-all disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none aria-invalid:shadow-focus-red aria-invalid:border-outline-red-3",
{
variants: {
variant: {
solid: "text-ink-white",
subtle: "",
ghost: "bg-transparent",
outline: "bg-surface-white border",
link: "bg-transparent underline-offset-4 underline",
},
size: {
sm: "h-7 text-base px-2 rounded [&_svg:not([class*='size-'])]:size-4",
md: "h-8 text-base font-medium px-2.5 rounded [&_svg:not([class*='size-'])]:size-4.5",
lg: "h-10 text-lg font-medium px-3 rounded-md [&_svg:not([class*='size-'])]:size-5",
xl: "h-11.5 text-xl font-medium px-3.5 rounded-lg [&_svg:not([class*='size-'])]:size-6",
"2xl": "h-13 text-2xl font-medium px-3.5 rounded-xl [&_svg:not([class*='size-'])]:size-6",
},
theme: {
gray: "focus-visible:shadow-focus-gray",
blue: "focus-visible:shadow-focus-blue",
green: "focus-visible:shadow-focus-green",
red: "focus-visible:shadow-focus-red",
amber: "focus-visible:shadow-focus-amber",
violet: "focus-visible:shadow-focus-violet",
},
isIconButton: {
true: "px-0",
false: ""
}
},
compoundVariants: [
// Icon only buttons - Sizes
{
isIconButton: true,
size: "sm",
className: "size-7"
},
{
isIconButton: true,
size: "md",
className: "size-8"
},
{
isIconButton: true,
size: "lg",
className: "size-10"
},
{
isIconButton: true,
size: "xl",
className: "size-11.5"
},
{
isIconButton: true,
size: "2xl",
className: "size-13"
},
// Solid buttons
{
variant: "solid",
theme: "gray",
className: "bg-surface-gray-7 hover:bg-surface-gray-6 active:bg-surface-gray-5 disabled:bg-surface-gray-2 disabled:text-ink-gray-4"
},
{
variant: "solid",
theme: "blue",
className: "bg-surface-blue-5 text-ink-blue-1 hover:bg-surface-blue-6 active:bg-surface-blue-7 disabled:bg-surface-blue-2 disabled:text-ink-blue-2"
},
{
variant: "solid",
theme: "green",
className: "bg-surface-green-5 text-ink-green-1 hover:bg-surface-green-6 active:bg-surface-green-7 disabled:bg-surface-green-2 disabled:text-ink-green-2"
},
{
variant: "solid",
theme: "red",
className: "bg-surface-red-5 text-ink-red-1 hover:bg-surface-red-6 active:bg-surface-red-7 disabled:bg-surface-red-2 disabled:text-ink-red-2"
},
{
variant: "solid",
theme: "violet",
className: "bg-surface-violet-5 text-ink-violet-1 hover:bg-surface-violet-6 active:bg-surface-violet-7 disabled:bg-surface-violet-2 disabled:text-ink-violet-2"
},
{
variant: "solid",
theme: "amber",
className: "bg-surface-amber-5 text-ink-amber-1 hover:bg-surface-amber-6 active:bg-surface-amber-7 disabled:bg-surface-amber-2 disabled:text-ink-amber-2"
},
// Subtle Buttons
{
variant: "subtle",
theme: "gray",
className: "text-ink-gray-7 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 disabled:bg-surface-gray-2 disabled:text-ink-gray-4"
},
{
variant: "subtle",
theme: "blue",
className: "text-ink-blue-4 bg-surface-blue-2 hover:bg-surface-blue-3 active:bg-surface-blue-4 disabled:bg-surface-blue-2 disabled:text-ink-blue-2"
},
{
variant: "subtle",
theme: "green",
className: "text-ink-green-4 bg-surface-green-2 hover:bg-surface-green-3 active:bg-surface-green-4 disabled:bg-surface-green-2 disabled:text-ink-green-2"
},
{
variant: "subtle",
theme: "red",
className: "text-ink-red-4 bg-surface-red-2 hover:bg-surface-red-3 active:bg-surface-red-4 disabled:bg-surface-red-2 disabled:text-ink-red-2"
},
{
variant: "subtle",
theme: "violet",
className: "text-ink-violet-4 bg-surface-violet-2 hover:bg-surface-violet-3 active:bg-surface-violet-4 disabled:bg-surface-violet-2 disabled:text-ink-violet-2"
},
{
variant: "subtle",
theme: "amber",
className: "text-ink-amber-4 bg-surface-amber-2 hover:bg-surface-amber-3 active:bg-surface-amber-4 disabled:bg-surface-amber-2 disabled:text-ink-amber-2"
},
// Outline buttons
{
variant: "outline",
theme: "gray",
className:
"text-ink-gray-7 border-outline-gray-2 hover:border-outline-gray-3 active:border-outline-gray-4 active:bg-surface-gray-4 disabled:bg-surface-gray-2 disabled:text-ink-gray-4 disabled:border-outline-gray-2"
},
{
variant: "outline",
theme: "blue",
className:
"text-ink-blue-4 border-outline-blue-2 hover:border-outline-blue-3 active:border-outline-blue-4 active:bg-surface-blue-4 disabled:bg-surface-blue-2 disabled:text-ink-blue-2 disabled:border-outline-blue-2"
},
{
variant: "outline",
theme: "green",
className:
"text-ink-green-4 border-outline-green-2 hover:border-outline-green-3 active:border-outline-green-4 active:bg-surface-green-4 disabled:bg-surface-green-2 disabled:text-ink-green-2 disabled:border-outline-green-2"
},
{
variant: "outline",
theme: "red",
className:
"text-ink-red-4 border-outline-red-2 hover:border-outline-red-3 active:border-outline-red-4 active:bg-surface-red-4 disabled:bg-surface-red-2 disabled:text-ink-red-2 disabled:border-outline-red-2"
},
{
variant: "outline",
theme: "violet",
className: "text-ink-violet-4 border-outline-violet-2 hover:border-outline-violet-3 active:border-outline-violet-4 active:bg-surface-violet-4 disabled:bg-surface-violet-2 disabled:text-ink-violet-2 disabled:border-outline-violet-2"
},
{
variant: "outline",
theme: "amber",
className: "text-ink-amber-4 border-outline-amber-2 hover:border-outline-amber-3 active:border-outline-amber-4 active:bg-surface-amber-4 disabled:bg-surface-amber-2 disabled:text-ink-amber-2 disabled:border-outline-amber-2"
},
// Ghost buttons
{
variant: "ghost",
theme: "gray",
className:
"text-ink-gray-7 hover:bg-surface-gray-3 active:bg-surface-gray-4 disabled:text-ink-gray-4"
},
{
variant: "ghost",
theme: "blue",
className:
"text-ink-blue-4 hover:bg-surface-blue-3 active:bg-surface-blue-4 disabled:text-ink-blue-2"
},
{
variant: "ghost",
theme: "green",
className:
"text-ink-green-4 hover:bg-surface-green-3 active:bg-surface-green-4 disabled:text-ink-green-2"
},
{
variant: "ghost",
theme: "red",
className:
"text-ink-red-4 hover:bg-surface-red-3 active:bg-surface-red-4 disabled:text-ink-red-2"
},
{
variant: "ghost",
theme: "violet",
className: "text-ink-violet-4 hover:bg-surface-violet-3 active:bg-surface-violet-4 disabled:text-ink-violet-2"
},
{
variant: "ghost",
theme: "amber",
className: "text-ink-amber-4 hover:bg-surface-amber-3 active:bg-surface-amber-4 disabled:text-ink-amber-2"
},
//Link buttons
{
variant: "link",
theme: "gray",
className: "text-ink-gray-8 hover:text-ink-gray-8 active:text-ink-gray-8 disabled:text-ink-gray-4"
},
{
variant: "link",
theme: "blue",
className: "text-ink-blue-3 hover:text-ink-blue-4 active:text-ink-blue-4 disabled:text-ink-blue-link"
},
{
variant: "link",
theme: "green",
className: "text-ink-green-3 hover:text-ink-green-4 active:text-ink-green-4 disabled:text-ink-green-2"
},
{
variant: "link",
theme: "red",
className: "text-ink-red-3 hover:text-ink-red-4 active:text-red-4 disabled:text-ink-red-2"
},
{
variant: "link",
theme: "violet",
className: "text-ink-violet-3 hover:text-ink-violet-4 active:text-ink-violet-4 disabled:text-ink-violet-2"
},
{
variant: "link",
theme: "amber",
className: "text-ink-amber-3 hover:text-ink-amber-4 active:text-ink-amber-4 disabled:text-ink-amber-2"
}
],
defaultVariants: {
variant: "solid",
size: "sm",
theme: "gray",
},
}
)
function Button({
className,
variant = "solid",
size = "sm",
theme = "gray",
isIconButton = false,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
data-theme={theme}
className={cn(buttonVariants({ variant, size, theme, className, isIconButton }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -1,218 +0,0 @@
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-surface-modal group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-outline-gray-1 border border-outline-gray-2 shadow-xs has-focus:ring-outline-gray-1/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-surface-modal inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md ps-2 pe-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-ink-gray-5 [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-ink-gray-5 rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-ink-gray-5",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-e-md group/day aspect-square select-none",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-s-md"
: "[&:first-child[data-selected=true]_button]:rounded-s-md",
defaultClassNames.day
),
range_start: cn(
"rounded-s-md bg-surface-gray-1",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-e-md bg-surface-gray-1", defaultClassNames.range_end),
today: cn(
"bg-surface-gray-1 text-ink-gray-8 rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-ink-gray-5 aria-selected:text-ink-gray-5",
defaultClassNames.outside
),
disabled: cn(
"text-ink-gray-5 opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
isIconButton
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-surface-gray-7 data-[selected-single=true]:text-ink-white data-[range-middle=true]:bg-surface-gray-1 data-[range-middle=true]:text-ink-gray-8 data-[range-start=true]:bg-surface-gray-7 data-[range-start=true]:text-ink-white data-[range-end=true]:bg-surface-gray-7 data-[range-end=true]:text-ink-white group-data-[focused=true]/day:border-outline-gray-1 group-data-[focused=true]/day:ring-outline-gray-1/50 dark:hover:text-ink-gray-8 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-e-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-s-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -1,92 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-surface-cards text-ink-gray-8 flex flex-col gap-6 rounded-xl border py-6 shadow-xs",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-ink-gray-5 text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -1,44 +0,0 @@
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Checkbox({
className,
size = "md",
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root> & { size?: "sm" | "md" }) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border data-[state=checked]:text-ink-white shrink-0 transition-shadow outline-none align-middle",
"rounded-[4px]",
"border-ink-gray-4 data-[state=checked]:bg-ink-gray-8 data-[state=checked]:border-ink-gray-8",
// Hover state
"hover:border-ink-gray-5 hover:shadow-checkbox-hover hover:data-[state=checked]:bg-ink-gray-7 hover:data-[state=checked]:border-ink-gray-7",
// Active state
"active:border-ink-gray-6 active:data-[state=checked]:bg-ink-gray-6 active:data-[state=checked]:border-ink-gray-6",
// Focus state
"focus-visible:border-ink-gray-8 focus-visible:shadow-focus-gray focus-visible:data-[state=checked]:bg-ink-gray-8 focus-visible:data-[state=checked]:border-ink-gray-8",
// Disabled state
"disabled:border-ink-gray-3 disabled:bg-surface-gray-1 disabled:cursor-not-allowed disabled:data-[state=checked]:bg-surface-gray-3 disabled:data-[state=checked]:border-surface-gray-3 disabled:text-ink-gray-4",
// Invalid state
"aria-invalid:border-red-500",
size === "sm" ? "size-3.5" : "size-4",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className={size === 'sm' ? "size-2.5" : "size-3"} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -1,183 +0,0 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-surface-modal flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-ink-gray-4 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex items-center gap-2 m-1.5 h-8 rounded px-2.5 py-2 border border-transparent transition-all bg-surface-gray-2 not-focus-within:hover:bg-surface-gray-3 text-ink-gray-7 focus-within:bg-surface-white focus-within:border-outline-gray-4 focus-within:shadow-focus-gray"
>
<SearchIcon className="size-4 shrink-0 text-ink-gray-4" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"flex w-full bg-transparent outline-hidden text-base placeholder:text-ink-gray-4",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-ink-gray-6 [&_[cmdk-group-heading]]:text-ink-gray-4 overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-sm [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-outline-gray-modals mx-0.5 h-px my-1", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"py-1.5 px-2 flex cursor-default text-ink-gray-6 items-center gap-2 rounded text-base relative outline-hidden select-none",
"data-[selected=true]:bg-surface-gray-2 [&_svg:not([class*='text-'])]:text-ink-gray-6 data-[disabled=true]:pointer-events-none data-[disabled=true]:text-ink-gray-3 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-ink-gray-5 ms-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -1,156 +0,0 @@
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="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 DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
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 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 outline-none sm:max-w-lg max-h-[90vh] overflow-y-auto",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="data-[state=open]:bg-surface-gray-1 data-[state=open]:text-ink-gray-8 absolute top-4 ltr:right-4 rtl:left-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon className="w-4 h-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 sm:text-start", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-2xl leading-6 text-ink-gray-8 font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-ink-gray-7 text-p-base", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -1,20 +0,0 @@
import * as React from "react"
import { Direction } from "radix-ui"
function DirectionProvider({
dir,
direction,
children,
}: React.ComponentProps<typeof Direction.DirectionProvider> & {
direction?: React.ComponentProps<typeof Direction.DirectionProvider>["dir"]
}) {
return (
<Direction.DirectionProvider dir={direction ?? dir}>
{children}
</Direction.DirectionProvider>
)
}
const useDirection = Direction.useDirection
export { DirectionProvider, useDirection }

View File

@@ -1,262 +0,0 @@
import * as React from "react"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-surface-modal min-w-32 rounded-lg p-1 shadow-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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
const BASE_ITEM_STYLES = `outline-hidden select-none relative flex cursor-default items-center
gap-2 rounded px-2 py-1.5 text-base text-ink-gray-6 data-[variant=destructive]:text-ink-red-3
data-[variant=destructive]:*:[svg]:text-ink-red-3! [&_svg:not([class*='text-'])]:text-ink-gray-6 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0
data-disabled:pointer-events-none data-disabled:text-ink-gray-3 data-disabled:*:[svg]:text-ink-gray-3! focus:bg-surface-gray-2 data-inset:ps-8`
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
BASE_ITEM_STYLES,
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
BASE_ITEM_STYLES,
className
)}
checked={checked}
{...props}
>
{children}
<span className="pointer-events-none flex size-4 ms-2 px-2 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
BASE_ITEM_STYLES,
className
)}
{...props}
>
{children}
<span className="pointer-events-none flex size-4 ps-2 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium text-ink-gray-4 data-inset:ps-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-outline-gray-modals my-1 h-px mx-0.5", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-ink-gray-5 ms-auto text-xs tabular-nums",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
BASE_ITEM_STYLES,
"data-[state=open]:bg-surface-gray-3",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ms-auto cn-rtl-flip size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-surface-modal rounded-lg p-1 shadow-xl min-w-32 text-ink-gray-6 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -1,85 +0,0 @@
import { cn } from "@/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex min-w-0 min-h-64 flex-1 flex-col items-center justify-center gap-3 rounded-lg p-6 text-center text-balance",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn(
"flex flex-col items-center gap-1 text-center",
className
)}
{...props}
/>
)
}
function EmptyMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-icon"
className={cn("flex justify-center items-center shrink-0 [&_svg]:pointer-events-none [&_svg]:shrink-0 bg-transparent size-7.5 [&_svg:not([class*='size-'])]:size-7.5 [&_svg:not([class*='text-'])]:text-ink-gray-5", className)}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-lg font-medium text-ink-gray-7", className)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-center text-p-base text-ink-gray-6 [&>a:hover]:text-ink-gray-7 [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}

View File

@@ -1,51 +0,0 @@
import { getErrorMessages } from '@/lib/frappe'
import { FrappeError } from 'frappe-react-sdk'
import { Alert, AlertDescription, AlertProps, AlertTitle } from '@/components/ui/alert'
import { AlertCircle } from 'lucide-react'
import MarkdownRenderer from '@/components/ui/markdown'
import _ from '@/lib/translate'
import { useMemo } from 'react'
type ErrorBannerProps = AlertProps & {
error?: FrappeError | null,
overrideHeading?: string,
}
interface ParsedErrorMessage {
message: string,
title?: string,
indicator?: string,
}
const parseHeading = (message?: ParsedErrorMessage) => {
if (message?.title === 'Message' || message?.title === 'Error') return "There was an error."
return message?.title
}
const ErrorBanner = ({ error, overrideHeading, ...props }: ErrorBannerProps) => {
//exc_type: "ValidationError" or "PermissionError" etc
// exc: With entire traceback - useful for reporting maybe
// httpStatus and httpStatusText - not needed
// _server_messages: Array of messages - useful for showing to user
// console.log(JSON.parse(error?._server_messages!))
const messages = useMemo(() => {
return getErrorMessages(error)
}, [error])
return (
<Alert theme={messages[0]?.indicator === 'yellow' ? 'amber' : "red"} {...props}>
<AlertCircle />
<AlertTitle>{overrideHeading ?? parseHeading(messages[0])}</AlertTitle>
<AlertDescription>
{messages.map((m, i) => {
return <MarkdownRenderer content={m.message} key={i} />
})}
</AlertDescription>
</Alert>
)
}
export default ErrorBanner

View File

@@ -1,289 +0,0 @@
import _ from '@/lib/translate'
import { Dispatch, SetStateAction, useCallback } from 'react'
import { Accept, useDropzone } from 'react-dropzone'
import { cn } from '@/lib/utils'
import { formatBytes, getFileExtension } from '@/lib/file'
import { Button } from './button'
import { Trash2Icon } from 'lucide-react'
type Props = {
files: File[],
setFiles?: Dispatch<SetStateAction<File[]>>
accept?: Accept,
multiple?: boolean
onDrop?: (acceptedFiles: File[]) => void,
onUpdate?: VoidFunction
className?: string
}
export const FileDropzone = ({ files, setFiles, accept, multiple = true, onDrop, className, onUpdate }: Props) => {
const onFileDrop = useCallback((acceptedFiles: File[]) => {
// Do something with the files
if (multiple) {
setFiles?.((prev) => [...prev, ...acceptedFiles])
} else {
setFiles?.(acceptedFiles)
}
onDrop?.(acceptedFiles)
onUpdate?.()
}, [setFiles, onDrop, multiple, onUpdate])
const { getRootProps, getInputProps } = useDropzone({ onDrop: onFileDrop, accept, multiple })
return (
<div {...getRootProps()} className={cn('border border-outline-gray-2 border-dashed p-4 rounded bg-surface-gray-1 focus-within:bg-surface-gray-2 hover:bg-surface-gray-2 hover:border-outline-gray-3 focus-within:border-outline-gray-3 focus-within:outline-none', className)}>
<input {...getInputProps()} />
{files.length === 0 ? <p className='text-sm text-ink-gray-5 text-center h-8 flex items-center justify-center'>{multiple ? _("Drop some files here, or click to select files") : _("Drop a file here, or click to select a file")}</p> : null}
<div className='flex flex-col gap-4'>
{files.map(f => <div key={f.name} className='flex justify-between items-center'>
<div className='flex items-center gap-2'>
<FileTypeIcon fileType={getFileExtension(f.name)} size='sm' />
<div className='flex flex-col gap-0.5'>
<span className='text-ink-gray-7 text-sm'>{f.name}</span>
<span className='text-ink-gray-5 text-xs'>{formatBytes(f.size)}</span>
</div>
</div>
<Button type='button' variant='ghost' isIconButton
className='text-ink-gray-5 hover:text-ink-gray-8 hover:bg-transparent'
onClick={(e) => {
e.stopPropagation()
setFiles?.(files.filter(file => file.name !== f.name))
onUpdate?.()
}}>
<Trash2Icon className='w-4 h-4' />
</Button>
</div>)}
</div>
</div>
)
}
interface FileTypeIconProps {
fileType: string
size?: 'sm' | 'md' | 'lg' | 'xl'
className?: string
showBackground?: boolean
}
const sizeClasses = {
sm: 'h-8 w-8',
md: 'h-10 w-10',
lg: 'h-12 w-12',
xl: 'h-16 w-16'
}
const iconSizeClasses = {
sm: 'h-5 w-5',
md: 'h-6 w-6',
lg: 'h-8 w-8',
xl: 'h-10 w-10'
}
// Special sizing for PowerPoint icon due to different viewBox
const pptIconSizeClasses = {
sm: 'h-3.5 w-3.5',
md: 'h-4 w-4',
lg: 'h-5 w-5',
xl: 'h-6 w-6'
}
export const FileTypeIcon = ({
fileType,
size = 'md',
className,
showBackground = true
}: FileTypeIconProps) => {
const containerClass = cn(sizeClasses[size], className)
const RenderIcon = ({ className }: { className?: string }) => {
switch (fileType.toLowerCase()) {
case 'pdf':
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" className={cn("text-white", iconSizeClasses[size], className)}>
<path d="M7 22.9c.1-.6.5-1 .9-1.4.5-.5 1.1-.8 1.8-1.2.7-.4 1.4-.7 2.1-1 .1 0 .2-.1.2-.2.6-1.2 1.2-2.4 1.7-3.6.3-.7.5-1.4.8-2.1v-.1c-.3-.7-.6-1.5-.7-2.3-.2-.8-.2-1.6-.1-2.4.1-.5.4-.9.8-1.3.1-.1.3-.1.5-.1h.8c.2 0 .4.1.5.3.3.2.5.5.7.8.2.4.2.8.3 1.2 0 1.2-.2 2.3-.4 3.4-.1.4-.2.7-.3 1.1v.1c.6 1.1 1.4 2.1 2.2 3 .1.1.1.1.3.1 1.1-.2 2.2-.2 3.2-.2.6 0 1.3.1 1.9.4.3.2.6.4.8.7.1.2.2.4.2.6v.7c0 .2-.1.4-.3.5-.2.2-.4.5-.8.5-.2 0-.5.1-.7.1-1.6.1-2.9-.4-4.2-1.3-.2-.2-.5-.4-.7-.6-.1 0-.1-.1-.2-.1-.6.1-1.2.2-1.8.4-.8.2-1.6.5-2.4.7-.1 0-.1.1-.2.1-.5.9-1.1 1.8-1.7 2.6-.5.6-1.1 1.2-1.7 1.7-.3.2-.7.4-1.1.5h-.8c-.2 0-.3 0-.5-.1-.5-.2-.9-.6-1-1.1-.1 0-.1-.2-.1-.4zm8.8-7c-.3.8-.7 1.6-1 2.4l2.4-.6c-.5-.6-1-1.3-1.4-1.8zm4.3 2.6c.6.4 1.3.7 2 .9.3.1.5 0 .7-.1.2-.1.3-.4.1-.5 0-.1-.1-.1-.2-.1-.2-.1-.5-.1-.8-.2-.6-.1-1.2-.1-1.8 0zm-9.4 2.8s-.1 0 0 0c-.6.3-1.2.7-1.7 1.1-.3.2-.5.5-.7.8v.2c.1.1.1.1.2.1.3-.2.5-.4.7-.5.6-.5 1-1.1 1.5-1.7zM15 11.2c.1 0 .1 0 0 0 .2-.6.3-1.2.3-1.7 0-.3 0-.6-.1-.9 0-.1-.1-.1-.2-.1s-.1.1-.2.1c-.2.3-.2.6-.2 1 0 .3 0 .5.1.8.2.2.2.5.3.8z" fill="currentColor" />
</svg>
)
case 'doc':
case 'docx':
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" className={cn("text-white", iconSizeClasses[size], className)}>
<path d="M26 11.4V8.8c0-.4-.3-.8-.8-.8h-7.3V6.2h-1.4c-.2 0-.3.1-.5.1-.7.1-1.4.3-2.1.4-.7.1-1.4.2-2 .4-.7.1-1.4.2-2.2.4-.8.1-1.5.2-2.2.3-.5.1-1 .2-1.4.2H6v15.9c.8.1 1.6.3 2.4.4.8.1 1.7.3 2.5.4.8.1 1.6.3 2.4.4.8.1 1.7.3 2.5.5.3.1.7.1 1 .1h.9V24c0-.1 0-.1.1-.1h7.3c.1 0 .3 0 .4-.1.2 0 .3-.1.3-.3 0-.2.1-.3.1-.5V11.4c.1.1.1.1.1 0zm-11 1.5l-.9 3.9c-.2.7-.3 1.4-.5 2.2 0 .1-.1.1-.1.1-.2.1-.4 0-.6 0h-.6c-.1 0-.1 0-.1-.1-.1-.6-.3-1.3-.4-1.9-.2-.8-.3-1.6-.5-2.4 0 .2-.1.4-.1.6l-.6 3c0 .2-.1.5-.1.7 0 .1 0 .1-.1.1-.4 0-.8-.1-1.2-.1-.1 0-.1 0-.1-.1-.3-1.6-.6-3.2-1-4.9-.1-.3-.1-.7-.2-1v-.1h1.2c.2 1.4.5 2.8.7 4.3 0-.2.1-.4.1-.6.3-1.2.5-2.5.8-3.7 0-.1 0-.1.1-.1h1c.2 0 .2 0 .3.2.3 1.4.6 2.8.9 4.3v.1c.1-.8.3-1.6.4-2.4.1-.7.3-1.5.4-2.2 0 0 0-.1.1-.1.4 0 .8 0 1.3-.1h.1c-.2 0-.3.2-.3.3zm10.3-4.1s0 .1 0 0v14.5h-7.5v-1.8h5.9v-.9H18c-.1 0-.1 0-.1-.1v-.9c0-.1 0-.1.1-.1h5.8v-.9h-5.9v-1.1h5.8v-.9h-5.9v-1h5.8c.1 0 .1 0 .1-.1v-.7c0-.1 0-.1-.1-.1H18c-.1 0-.1 0-.1-.1v-1h5.9v-.9h-5.7c-.1 0-.1 0-.1-.1v-.9c0-.1 0-.1.1-.1h5.7v-.9h-5.9v-1.2h5.8c.1 0 .1 0 .1-.1v-.7c0-.1 0-.1-.1-.1h-5.9V9c0-.1 0-.1.1-.1h7.3c.1-.2.1-.2.1-.1z" fill="currentColor" />
</svg>
)
case 'xls':
case 'xlsx':
case 'csv':
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" className={cn("text-white", iconSizeClasses[size], className)}>
<path d="M26 9.3v13.6c0 .1-.1.2-.1.3-.2.3-.5.5-.8.5h-7.7v2c-.3-.1-.7-.1-1-.2-.7-.1-1.5-.3-2.2-.4-.8-.1-1.6-.3-2.4-.4-.8-.1-1.6-.3-2.4-.4-.7-.1-1.5-.3-2.2-.4-.4-.1-.7-.1-1.1-.2V9c.1 0 .3-.1.4-.1.7-.5 1.5-.7 2.3-.8.7-.1 1.4-.3 2-.4.6-.1 1.3-.2 1.9-.4.7-.1 1.5-.3 2.2-.4.8-.1 1.5-.3 2.3-.4h.1v1.9h7.8c.4 0 .8.3.9.7v.2zm-.8-.1h-7.9v1.2H20v1.7h-2.7v.6H20v1.7h-2.7v.6H20v1.7h-2.7v.7h2.8v1.7h-2.8v.6H20v1.7h-2.7v1.2h7.9V9.2zM14.7 20.7s0-.1-.1-.1c-.7-1.4-1.5-2.8-2.2-4.2v-.2c.7-1.4 1.4-2.7 2.2-4.1V12h-.1c-.2 0-.5 0-.7.1-.3 0-.6 0-1 .1-.1 0-.1 0-.1.1-.3.6-.5 1.1-.8 1.7-.2.5-.4.9-.6 1.4-.1-.2-.1-.5-.2-.7-.3-.7-.6-1.5-.9-2.2-.1-.2-.1-.2-.3-.2-.4 0-.8.1-1.2.1h-.4v.1c.1.2.2.5.3.7l1.5 3v.1c-.6 1.2-1.3 2.4-1.9 3.6 0 .1-.1.1-.1.2h.6c.4 0 .7.1 1.1.1.1 0 .1 0 .1-.1.3-.6.6-1.2.9-1.9.1-.3.3-.6.4-.9 0-.1 0-.2.1-.3v.1c.1.2.1.4.2.5.4.8.7 1.6 1.1 2.5.1.1.1.2.3.2.5 0 1 .1 1.5.1.1.3.2.3.3.3z" fill="currentColor" />
<path d="M23.9 10.4v1.7h-3.1v-1.7h3.1zm-3.1 11.2v-1.7h3.1v1.7h-3.1zm0-4.7v-1.7h3.1v1.7h-3.1zm3.1-4.1v1.7h-3.1v-1.7h3.1zm0 4.8v1.7h-3.1v-1.7h3.1z" fill="currentColor" />
</svg>
)
case 'ppt':
case 'pptx':
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 122.88 116.03" className={cn("text-white", pptIconSizeClasses[size], className)}>
<g>
<path d="M0.38,12.11L69.16,0.09L69.69,0v0.54v114.96v0.53l-0.53-0.09L0.38,104.63L0,104.57v-0.38V12.55v-0.38L0.38,12.11 L0.38,12.11z M76.29,17.01h43.79c0.77,0,1.47,0.32,1.98,0.82c0.51,0.51,0.82,1.21,0.82,1.98v76.75c0,0.78-0.32,1.5-0.84,2.01 s-1.23,0.84-2.01,0.84H76.29h-0.45v-0.45v-9.16v-0.45h0.45h33.62v-6.15H76.29h-0.45v-0.45v-7.17V75.1h0.45h33.62v-6.15H76.29h-0.45 v-0.45v-8.49v-0.88l0.71,0.51c1.32,0.94,2.79,1.68,4.36,2.18c1.52,0.48,3.14,0.74,4.82,0.74c4.38,0,8.34-1.78,11.21-4.64 c2.82-2.82,4.59-6.7,4.64-11H85.83h-0.45v-0.45V30.86c-1.56,0.03-3.06,0.29-4.47,0.74c-1.57,0.5-3.04,1.24-4.36,2.18l-0.71,0.51 v-0.88V17.46v-0.45H76.29L76.29,17.01z M99.26,32.75c-2.76-2.77-6.54-4.52-10.73-4.65v15.48h15.36 C103.79,39.35,102.04,35.53,99.26,32.75L99.26,32.75z M30.91,80.41V63.97v-0.45h0.45h6.22c2.41,0,4.56-0.35,6.45-1.05 c1.87-0.7,3.49-1.75,4.86-3.15c1.37-1.4,2.39-3.04,3.08-4.91c0.69-1.88,1.03-4,1.03-6.37c0-1.61-0.16-3.12-0.48-4.55 c-0.32-1.42-0.79-2.76-1.43-4.01c-0.63-1.25-1.4-2.36-2.29-3.32c-0.89-0.96-1.91-1.78-3.06-2.45c-2.31-1.35-4.97-2.03-7.98-2.03 H22.07v48.75H30.91L30.91,80.41z M37.76,55.2h-6.39h-0.45v-0.45V40.43v-0.45h0.45h6.51l0.01,0c0.95,0.01,1.81,0.21,2.57,0.59 c0.76,0.38,1.41,0.95,1.96,1.71h0c0.54,0.74,0.95,1.6,1.21,2.58c0.27,0.97,0.4,2.05,0.4,3.24c0,1.1-0.13,2.08-0.39,2.94h0 c-0.27,0.88-0.67,1.63-1.21,2.26c-0.54,0.63-1.21,1.11-2,1.43C39.65,55.05,38.76,55.2,37.76,55.2L37.76,55.2z" fill="currentColor" />
</g>
</svg>
)
case 'video':
case 'mp4':
case 'mov':
case 'mkv':
case 'avi':
case 'webm':
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" className={cn("text-white", iconSizeClasses[size], className)}>
<path d="M16 4c-6.6 0-12 5.4-12 12s5.4 12 12 12 12-5.4 12-12S22.6 4 16 4zm-2 16.5V9.5l8 5.5-8 5.5z" fill="currentColor" />
</svg>
)
case 'audio':
case 'mp3':
case 'wav':
case 'ogg':
case 'flac':
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" className={cn("text-white", iconSizeClasses[size], className)}>
<path d="M16 4c-6.6 0-12 5.4-12 12s5.4 12 12 12 12-5.4 12-12S22.6 4 16 4zm-2 16.5V9.5l8 5.5-8 5.5z" fill="currentColor" />
</svg>
)
case 'image':
case 'jpg':
case 'jpeg':
case 'png':
case 'gif':
case 'webp':
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" className={cn("text-white", iconSizeClasses[size], className)}>
<path d="M26 4H6c-1.1 0-2 .9-2 2v20c0 1.1.9 2 2 2h20c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM6 26V6h20v20H6z" fill="currentColor" />
<path d="M10 12c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm12 8H10l4-6 3 4 2-3 7 5z" fill="currentColor" />
</svg>
)
case 'zip':
case 'rar':
case '7z':
case 'tar':
case 'gz':
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" className={cn("text-white", iconSizeClasses[size], className)}>
<path d="M26 4H6c-1.1 0-2 .9-2 2v20c0 1.1.9 2 2 2h20c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM6 26V6h20v20H6z" fill="currentColor" />
<path d="M10 8h12v2H10V8zm0 4h12v2H10v-2zm0 4h12v2H10v-2z" fill="currentColor" />
</svg>
)
default:
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" className={cn("text-white", iconSizeClasses[size], className)}>
<path d="M18 22a2 2 0 0 0 2-2V8l-6-6H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12zM13 4l5 5h-5V4zM7 8h3v2H7V8zm0 4h10v2H7v-2zm0 4h10v2H7v-2z" fill="currentColor" />
</svg>
)
}
}
const getBackgroundColor = () => {
switch (fileType.toLowerCase()) {
case 'pdf':
return 'bg-red-700'
case 'doc':
case 'docx':
return 'bg-[#1A5CBD]'
case 'xls':
case 'xlsx':
case 'csv':
return 'bg-green-700'
case 'ppt':
case 'pptx':
return 'bg-[#ED6C47]'
case 'video':
case 'mp4':
case 'mov':
case 'mkv':
case 'avi':
case 'webm':
return 'bg-purple-600'
case 'audio':
case 'mp3':
case 'wav':
case 'ogg':
case 'flac':
return 'bg-purple-600'
case 'image':
case 'jpg':
case 'jpeg':
case 'png':
case 'gif':
case 'webp':
return 'bg-blue-600'
case 'zip':
case 'rar':
case '7z':
case 'tar':
case 'gz':
return 'bg-yellow-600'
default:
return 'bg-gray-500'
}
}
const getTextColor = () => {
switch (fileType.toLowerCase()) {
case 'pdf':
return 'text-red-700'
case 'doc':
case 'docx':
return 'text-[#1A5CBD]'
case 'xls':
case 'xlsx':
case 'csv':
return 'text-green-700 dark:text-green-500'
case 'ppt':
case 'pptx':
return 'text-[#ED6C47]'
case 'video':
case 'mp4':
case 'mov':
case 'mkv':
case 'avi':
case 'webm':
return 'text-purple-600'
case 'audio':
case 'mp3':
case 'wav':
case 'ogg':
case 'flac':
return 'text-purple-600'
case 'image':
case 'jpg':
case 'jpeg':
case 'png':
case 'gif':
case 'webp':
return 'text-blue-600'
case 'zip':
case 'rar':
case '7z':
case 'tar':
case 'gz':
return 'text-yellow-600'
default:
return 'text-gray-50'
}
}
if (showBackground) {
return (
<div className={cn("rounded-md flex items-center justify-center", getBackgroundColor(), containerClass)}>
<RenderIcon />
</div>
)
}
return (
<div className={cn("flex items-center justify-center")}>
<RenderIcon className={getTextColor()} />
</div>
)
}

View File

@@ -1,383 +0,0 @@
import { FieldValues, RegisterOptions, useFormContext } from "react-hook-form"
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, FormRequiredIndicator, useFormField } from "@/components/ui/form"
import _ from "@/lib/translate"
import { Input } from "./input"
import { ComponentProps, FocusEventHandler, useCallback, useState } from "react"
import { parseDate } from "chrono-node"
import { formatDate, getUserDateFormat, toDate } from "@/lib/date"
import { Popover, PopoverContent, PopoverTrigger } from "./popover"
import { Button } from "./button"
import { CalendarIcon } from "lucide-react"
import { Calendar } from "./calendar"
import dayjs from "dayjs"
import { Textarea } from "./textarea"
import AccountsDropdown, { AccountsDropdownProps } from "../common/AccountsDropdown"
import PartyTypeDropdown, { PartyTypeDropdownProps } from "../common/PartyTypeDropdown"
import CurrencyInput from "react-currency-input-field"
import { getSystemDefault } from "@/lib/frappe"
import { getCurrencySymbol } from "@/lib/currency"
import { getCurrencyFormatInfo } from "@/lib/numbers"
import LinkFieldCombobox, { LinkFieldComboboxProps } from "../common/LinkFieldCombobox"
import { Select, SelectContent, SelectTrigger, SelectValue } from "./select"
import { InputGroup, InputGroupAddon } from "./input-group"
interface FormElementProps {
name: string,
rules?: Omit<RegisterOptions<FieldValues, string>, "disabled" | "valueAsNumber" | "valueAsDate" | "setValueAs">,
label: string,
isRequired?: boolean,
disabled?: boolean,
formDescription?: string,
hideLabel?: boolean,
readOnly?: boolean,
}
interface DataFieldProps extends FormElementProps {
inputProps?: Omit<ComponentProps<"input">, "value" | "onChange" | "onBlur" | "name" | "ref">
}
export const DataField = ({ name, rules, label, isRequired, formDescription, inputProps, hideLabel, disabled, readOnly }: DataFieldProps) => {
const { control } = useFormContext()
return <FormField
control={control}
disabled={disabled}
name={name}
rules={rules}
render={({ field }) => (
<FormItem className='flex flex-col'>
<FormLabel className={hideLabel ? 'sr-only' : ''}>{label}{isRequired && <FormRequiredIndicator />}</FormLabel>
<FormControl>
<Input {...field} maxLength={140} aria-readonly={readOnly} readOnly={readOnly} {...inputProps} />
</FormControl>
{formDescription && <FormDescription>{formDescription}</FormDescription>}
<FormMessage />
</FormItem>
)}
/>
}
interface SelectFieldProps extends FormElementProps {
children: React.ReactNode
}
export const SelectFormField = ({ name, rules, label, isRequired, formDescription, hideLabel, children, disabled, readOnly }: SelectFieldProps) => {
const { control } = useFormContext()
return <FormField
control={control}
name={name}
disabled={disabled}
rules={rules}
render={({ field }) => (
<FormItem>
<FormLabel className={hideLabel ? 'sr-only' : ''}>{label}{isRequired && <FormRequiredIndicator />}</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} value={field.value} disabled={disabled || readOnly} aria-readonly={readOnly}>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{children}
</SelectContent>
</Select>
</FormControl>
{formDescription && <FormDescription>{formDescription}</FormDescription>}
<FormMessage />
</FormItem>
)}
/>
}
interface DateFieldProps extends FormElementProps {
inputProps?: Omit<ComponentProps<"input">, "value" | "onChange" | "onBlur" | "name" | "ref">
}
export const DateField = ({ name, rules, label, isRequired, formDescription, inputProps, hideLabel, disabled }: DateFieldProps) => {
const { control } = useFormContext()
const DatePicker = ({ field }: { field: FieldValues }) => {
const userDateFormat = getUserDateFormat()
const [open, setOpen] = useState(false)
const [value, setValue] = useState<string | undefined>(field.value ? formatDate(field.value) : undefined)
const date = field.value ? toDate(field.value) : undefined
return <div className="relative flex gap-2">
<FormControl>
<Input className="pe-10"
name={field.name}
onBlur={() => {
setValue(formatDate(field.value))
field.onBlur()
}}
placeholder={userDateFormat}
value={value}
onChange={(e) => {
setValue(e.target.value)
if (e.target.value) {
// On change in value, try computing date usning standard formats first
const dateObj = toDate(e.target.value, userDateFormat)
// If we find a valid date, use it
if (dateObj && !isNaN(dateObj.getTime())) {
field.onChange(formatDate(dateObj, "YYYY-MM-DD"))
} else {
// If not, try parsing using chrono-node for things like "1st July 2025"
const date = parseDate(e.target.value)
if (date) {
field.onChange(formatDate(date, "YYYY-MM-DD"))
}
}
} else {
field.onChange("")
}
}}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault()
setOpen(true)
}
}}
maxLength={140}
{...inputProps} />
</FormControl>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id="date-picker-button"
variant="ghost"
className="absolute top-1/2 ltr:right-2 rtl:left-2 size-6 -translate-y-1/2"
>
<CalendarIcon className="size-3.5" />
<span className="sr-only">{_("Select date")}</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="center">
<Calendar
mode="single"
selected={date}
fixedWeeks
endMonth={dayjs().add(1, "year").toDate()}
captionLayout="dropdown"
defaultMonth={date}
onSelect={(date) => {
setValue(formatDate(date))
field.onChange(formatDate(date, "YYYY-MM-DD"))
setOpen(false)
}}
/>
</PopoverContent>
</Popover>
</div>
}
return <FormField
control={control}
name={name}
disabled={disabled}
rules={rules}
render={({ field }) => (
<FormItem className='flex flex-col'>
<FormLabel className={hideLabel ? 'sr-only' : ''}>{label}{isRequired && <FormRequiredIndicator />}</FormLabel>
<DatePicker field={field} />
{formDescription && <FormDescription>{formDescription}</FormDescription>}
<FormMessage />
</FormItem>
)}
/>
}
interface SmallTextFieldProps extends FormElementProps {
inputProps?: Omit<ComponentProps<"textarea">, "value" | "onChange" | "onBlur" | "name" | "ref">
}
export const SmallTextField = ({ name, rules, label, isRequired, formDescription, inputProps, hideLabel, disabled, readOnly }: SmallTextFieldProps) => {
const { control } = useFormContext()
return <FormField
control={control}
name={name}
disabled={disabled}
rules={rules}
render={({ field }) => (
<FormItem className='flex flex-col'>
<FormLabel className={hideLabel ? 'sr-only' : ''}>{label}{isRequired && <FormRequiredIndicator />}</FormLabel>
<FormControl>
<Textarea {...field} {...inputProps} readOnly={readOnly} aria-readonly={readOnly} />
</FormControl>
{formDescription && <FormDescription>{formDescription}</FormDescription>}
<FormMessage />
</FormItem>
)}
/>
}
interface AccountFormFieldProps extends Omit<AccountsDropdownProps, 'value' | 'onChange'>, FormElementProps {
}
export const AccountFormField = (props: AccountFormFieldProps) => {
const { control } = useFormContext()
return <FormField
control={control}
disabled={props.disabled}
name={props.name}
rules={props.rules}
render={({ field }) => (
<FormItem className='flex flex-col'>
<FormLabel className={props.hideLabel ? 'sr-only' : ''}>{props.label}{props.isRequired && <FormRequiredIndicator />}</FormLabel>
<AccountsDropdown {...props} value={field.value} onChange={field.onChange} useInForm readOnly={props.readOnly} />
{props.formDescription && <FormDescription>{props.formDescription}</FormDescription>}
<FormMessage />
</FormItem>
)}
/>
}
interface PartyTypeFormField extends FormElementProps {
inputProps?: Omit<PartyTypeDropdownProps, 'value' | 'onChange'>
}
export const PartyTypeFormField = ({ name, rules, label, isRequired, formDescription, hideLabel, inputProps, disabled, readOnly }: PartyTypeFormField) => {
const { control } = useFormContext()
return <FormField
control={control}
disabled={disabled}
name={name}
rules={rules}
render={({ field }) => (
<FormItem className='flex flex-col'>
<FormLabel className={hideLabel ? 'sr-only' : ''}>{label}{isRequired && <FormRequiredIndicator />}</FormLabel>
<PartyTypeDropdown {...inputProps} value={field.value} onChange={field.onChange} useInForm readOnly={readOnly} />
{formDescription && <FormDescription>{formDescription}</FormDescription>}
<FormMessage />
</FormItem>
)}
/>
}
interface CurrencyFormFieldProps extends FormElementProps {
currency?: string,
style?: React.CSSProperties,
leftSlot?: React.ReactNode,
}
export const CurrencyFormField = ({ name, rules, label, isRequired, formDescription, hideLabel, currency, disabled, readOnly, style = {}, leftSlot }: CurrencyFormFieldProps) => {
const { control } = useFormContext()
const defaultCurrency = getSystemDefault("currency")
const currencySymbol = getCurrencySymbol(currency ?? defaultCurrency)
const CurrencyField = ({ field }: { field: FieldValues }) => {
const onFocus: FocusEventHandler<HTMLInputElement> = useCallback((e) => {
// When the input is focused, select the text
// A short timeout is needed so that the input selects the text after the focus event
setTimeout(() => {
// Check if the input is focused - do not select text if the input is not focused
if (e.target.contains(document.activeElement)) {
e.target.select()
}
}, 100)
}, [])
const { formItemId } = useFormField()
// Get the correct separators for the currency
const formatInfo = getCurrencyFormatInfo(currency ?? defaultCurrency)
const groupSeparator = formatInfo.group_sep || ","
const decimalSeparator = formatInfo.decimal_str || "."
return <CurrencyInput
ref={field.ref}
name={field.name}
style={{
textAlign: 'right',
...style
}}
id={formItemId}
onBlur={field.onBlur}
disabled={field.disabled}
readOnly={readOnly}
aria-readonly={readOnly}
onFocus={onFocus}
groupSeparator={groupSeparator}
decimalSeparator={decimalSeparator}
placeholder={`${currencySymbol} 0${decimalSeparator}00`}
decimalsLimit={2}
value={field.value}
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 ?? ''
field.onChange(newValue)
}}
customInput={Input}
/>
}
return <FormField
control={control}
disabled={disabled}
name={name}
rules={rules}
render={({ field }) => (
<FormItem className='flex flex-col'>
<FormLabel className={hideLabel ? 'sr-only' : ''}>{label}{isRequired && <FormRequiredIndicator />}</FormLabel>
<FormControl>
<InputGroup>
{leftSlot && <InputGroupAddon>{leftSlot}</InputGroupAddon>}
<CurrencyField field={field} />
</InputGroup>
</FormControl>
{formDescription && <FormDescription>{formDescription}</FormDescription>}
<FormMessage />
</FormItem>
)}
/>
}
interface LinkFormFieldProps extends FormElementProps, Omit<LinkFieldComboboxProps, 'value' | 'onChange'> {
}
export const LinkFormField = ({ name, rules, label, isRequired, formDescription, hideLabel, disabled, readOnly, ...inputProps }: LinkFormFieldProps) => {
const { control } = useFormContext()
return <FormField
control={control}
name={name}
disabled={disabled}
rules={rules}
render={({ field }) => (
<FormItem className='flex flex-col'>
<FormLabel className={hideLabel ? 'sr-only' : ''}>{label}{isRequired && <FormRequiredIndicator />}</FormLabel>
<LinkFieldCombobox {...inputProps} value={field.value} onChange={field.onChange} useInForm disabled={disabled} readOnly={readOnly} />
{formDescription && <FormDescription>{formDescription}</FormDescription>}
<FormMessage />
</FormItem>
)}
/>
}

View File

@@ -1,174 +0,0 @@
import * as React from "react"
import { Label as LabelPrimitive, Slot as SlotPrimitive } from "radix-ui"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-1.5", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={className}
htmlFor={formItemId}
{...props}
/>
)
}
function FormRequiredIndicator({ className, ...props }: React.ComponentProps<"span">) {
return (
<span className={cn("text-ink-red-2", className)} {...props}>
*
</span>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof SlotPrimitive.Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<SlotPrimitive.Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-ink-gray-5 text-p-base", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-ink-red-4 text-p-base", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
FormRequiredIndicator,
}

View File

@@ -1,42 +0,0 @@
import * as React from "react"
import { HoverCard as HoverCardPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"rounded-lg border bg-surface-modal shadow-xl text-ink-gray-8 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) p-4 outline-hidden",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -1,161 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
const inputGroupVariants = cva(cn("group/input-group relative flex w-full items-center outline-none min-w-0 border border-transparent transition-all",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:ps-2",
"has-[>[data-align=inline-end]]:[&>input]:pe-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input]:focus-visible]:bg-surface-white has-[[data-slot=input]:focus-visible]:border-outline-gray-4 has-[[data-slot=input]:focus-visible]:shadow-focus-gray",
// Disabled state
"has-[>[data-slot=input]:disabled]:bg-surface-gray-1 has-[>[data-slot=input]:disabled]:text-ink-gray-3 has-[>[data-slot=input]:disabled]:cursor-not-allowed has-[>[data-slot=input]:disabled]:pointer-events-none",
// Error state.
"has-[[data-slot][aria-invalid=true]]:shadow-focus-red has-[[data-slot][aria-invalid=true]]:border-outline-red-3",
// Read only state
"has-[[data-slot][aria-readonly=true]]:bg-surface-gray-1 has-[[data-slot][aria-readonly=true]]:text-ink-gray-6 has-[[data-slot][aria-readonly=true]]:pointer-events-none",
),
{
variants: {
variant: {
subtle: "bg-surface-gray-2",
outline: "bg-surface-white border-outline-gray-2"
},
size: {
sm: "h-7 has-[>textarea]:h-auto rounded text-base",
md: "h-8 has-[>textarea]:h-auto rounded text-base",
lg: "h-10 has-[>textarea]:h-auto rounded-md text-lg"
}
},
defaultVariants: {
variant: "subtle",
size: "md"
}
}
)
function InputGroup({ className, variant = "subtle", size = "md", ...props }: React.ComponentProps<"div"> & VariantProps<typeof inputGroupVariants>) {
return (
<div
data-slot="input-group"
data-variant={variant}
data-size={size}
role="group"
className={cn(
inputGroupVariants({ variant, size }),
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-ink-gray-5 flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
"inline-start":
"order-first ps-3 has-[>button]:ms-[-0.45rem] has-[>kbd]:ms-[-0.35rem]",
"inline-end":
"order-last pe-3 has-[>button]:me-[-0.45rem] has-[>kbd]:me-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"text-sm shadow-none flex gap-2 items-center",
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-ink-gray-5 flex items-center gap-2 text-sm whitespace-nowrap [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
}

View File

@@ -1,49 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { cva, VariantProps } from "class-variance-authority"
const inputVariants = cva(cn("flex w-full min-w-0 transition-all outline-none border border-transparent",
"focus-visible:bg-surface-white focus-visible:border-outline-gray-4 focus-visible:shadow-focus-gray",
"active:bg-surface-white active:shadow-sm active:border-outline-gray-4",
"placeholder:text-ink-gray-4 text-ink-gray-7",
"disabled:bg-surface-gray-1 disabled:placeholder:text-ink-gray-3 disabled:text-ink-gray-3 disabled:cursor-not-allowed disabled:pointer-events-none",
"aria-readonly:bg-surface-gray-1 aria-readonly:text-ink-gray-6 aria-readonly:pointer-events-none aria-invalid:shadow-focus-red aria-invalid:border-outline-red-3",
"in-data-[slot=input-group]:border-transparent! in-data-[slot=input-group]:focus-visible:shadow-none! in-data-[slot=input-group]:bg-transparent!"),
{
variants: {
inputSize: {
sm: "text-base rounded py-1.5 px-2 h-7",
md: "text-base rounded py-2 px-2.5 h-8",
lg: "text-lg rounded-md py-[11px] px-3 h-10",
},
variant: {
subtle: "bg-surface-gray-2 hover:bg-surface-gray-3 aria-invalid:bg-surface-red-1",
outline: "bg-surface-white border-outline-gray-2 hover:border-outline-gray-3 active:border-outline-gray-4 disabled:border-outline-gray-2",
}
},
defaultVariants: {
inputSize: "md",
variant: "subtle"
}
}
)
function Input({ className, type, inputSize = "md", variant = "subtle", ...props }: React.ComponentProps<"input"> & VariantProps<typeof inputVariants>) {
return (
<input
type={type}
data-slot="input"
data-input-size={inputSize}
data-variant={variant}
className={cn(
"file:text-ink-gray-8 file:inline-flex file:border-0 file:bg-transparent file:text-sm file:font-medium",
inputVariants({ inputSize, variant }),
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -1,28 +0,0 @@
import { cn } from "@/lib/utils"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"bg-surface-gray-2 py-0.5 text-ink-gray-5 pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
"[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-surface-gray-6 [[data-slot=tooltip-content]_&]:text-ink-gray-1",
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }

View File

@@ -1,8 +0,0 @@
export const KeyboardMetaKeyIcon = () => {
if (navigator.platform.toUpperCase().indexOf('MAC') >= 0) {
return <span className="text-sm"></span>
} else {
return <span>Ctrl</span>
}
}

View File

@@ -1,22 +0,0 @@
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-base text-ink-gray-5 select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -1,510 +0,0 @@
import * as React from "react"
import {
type Cell,
type ColumnDef,
type ColumnSizingState,
type Header,
type OnChangeFn,
type Row,
type RowSelectionState,
flexRender,
functionalUpdate,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import { useVirtualizer } from "@tanstack/react-virtual"
import { useDebounceCallback } from "usehooks-ts"
import { Checkbox } from "@/components/ui/checkbox"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { useDirection } from "./direction"
/** Optional per-column layout hints for `ListView`. */
export type ListViewColumnMeta = {
/** CSS grid track (`1fr`, `2fr`, `minmax(0,1fr)`). When set, used instead of TanStack pixel `size` in `grid-template-columns`. */
gridWidth?: string
align?: "left" | "center" | "right"
/**
* Tabular figures for stable digit width. Default: on when `align` is `right` (amounts); set `false` to opt out, or `true` for dates/IDs.
*/
tabularNums?: boolean
/**
* Full text for an overflow tooltip (shown only when the cell truncates). If omitted, a string `accessorKey` value is used when available.
*/
getTooltipText?: (row: unknown) => string | null | undefined
/** `false` disables the overflow tooltip for this column. */
truncateTooltip?: boolean
/**
* `false` skips single-line truncation for cells with custom layouts (e.g. action buttons). Default `true`.
*/
truncate?: boolean
}
function alignClass(meta: ListViewColumnMeta | undefined) {
switch (meta?.align) {
case "center":
return "justify-center text-center"
case "right":
return "justify-end text-end"
default:
return "justify-start text-start"
}
}
function tabularNumsClass(meta: ListViewColumnMeta | undefined) {
if (meta?.tabularNums === false) return ""
if (meta?.tabularNums === true) return "tabular-nums"
if (meta?.align === "right") return "tabular-nums"
return ""
}
function resolveTooltipLabel<TData>(
row: Row<TData>,
meta: ListViewColumnMeta | undefined,
columnDef: ColumnDef<TData, unknown>,
): string | undefined {
if (meta?.truncateTooltip === false) return undefined
const fromMeta = meta?.getTooltipText?.(row.original as unknown)
if (fromMeta != null && String(fromMeta).length > 0) {
return String(fromMeta)
}
const key = "accessorKey" in columnDef ? columnDef.accessorKey : undefined
if (key !== undefined && key !== null && key !== "") {
try {
const v = row.getValue(String(key))
if (v != null && v !== "") return String(v)
} catch {
/* column may not expose a value */
}
}
return undefined
}
function ListViewCellBody<TData>({
cell,
row,
meta,
children,
}: {
cell: Cell<TData, unknown>
row: Row<TData>
meta: ListViewColumnMeta | undefined
children: React.ReactNode
}) {
const ref = React.useRef<HTMLDivElement>(null)
const [overflowing, setOverflowing] = React.useState(false)
const direction = useDirection()
const tooltipLabel = resolveTooltipLabel(row, meta, cell.column.columnDef)
const tooltipAlign = meta?.align === "right" && direction === "ltr" ? "end" : "start"
const measure = React.useCallback(() => {
const el = ref.current
if (!el) return
setOverflowing(el.scrollWidth > el.clientWidth + 1)
}, [])
React.useLayoutEffect(() => {
measure()
}, [measure, children, tooltipLabel])
React.useEffect(() => {
const el = ref.current
if (!el || typeof ResizeObserver === "undefined") return
const ro = new ResizeObserver(measure)
ro.observe(el)
return () => ro.disconnect()
}, [measure])
if (meta?.truncate === false) {
return <div className="min-w-0 flex-1 overflow-visible">{children}</div>
}
const inner = (
<div
ref={ref}
className={cn(
"min-h-0 min-w-0 flex-1 truncate",
)}
>
{children}
</div>
)
if (!tooltipLabel || !overflowing) {
return inner
}
return (
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>{inner}</TooltipTrigger>
<TooltipContent
side="bottom"
align={tooltipAlign}
className="max-w-sm text-balance wrap-break-word"
>
{tooltipLabel}
</TooltipContent>
</Tooltip>
)
}
function gridTemplateFromHeaders<TData>(headers: Header<TData, unknown>[]) {
return headers
.map((header) => {
const meta = header.column.columnDef.meta as ListViewColumnMeta | undefined
if (meta?.gridWidth) {
return meta.gridWidth
}
return `${header.getSize()}px`
})
.join(" ")
}
function defaultGetRowId<TData>(row: TData, index: number) {
const r = row as Record<string, unknown>
if (r && typeof r.name === "string") return r.name
if (r && typeof r.id === "string") return r.id
return String(index)
}
export type ListViewProps<TData> = {
data: TData[]
columns: ColumnDef<TData, unknown>[]
/**
* Stable row id for selection and keys. Defaults to `name`, then `id`, then row index (index is fragile if data order changes).
*/
getRowId?: (originalRow: TData, index: number) => string
/** Pixel height of each body row (default 40, matches frappe-ui ListView). */
rowHeight?: number
className?: string
/** Classes for the scrollable viewport (default includes max-height). */
scrollAreaClassName?: string
/** Max height of the scroll area; number is pixels. Default `420`. */
maxHeight?: number | string
emptyState?: React.ReactNode
enableColumnResizing?: boolean
columnSizing?: ColumnSizingState
onColumnSizingChange?: OnChangeFn<ColumnSizingState>
/** Debounced callback for persisting widths (e.g. localStorage). */
onColumnSizingCommit?: (sizing: ColumnSizingState) => void
columnSizingCommitDelayMs?: number
enableRowSelection?: boolean
rowSelection?: RowSelectionState
onRowSelectionChange?: OnChangeFn<RowSelectionState>
onRowClick?: (row: TData, event: React.MouseEvent) => void
}
function ListViewInner<TData>({
data,
columns: userColumns,
getRowId: getRowIdProp,
rowHeight = 40,
className,
scrollAreaClassName,
maxHeight = 420,
emptyState,
enableColumnResizing = true,
columnSizing: controlledColumnSizing,
onColumnSizingChange: controlledOnColumnSizingChange,
onColumnSizingCommit,
columnSizingCommitDelayMs = 250,
enableRowSelection = false,
rowSelection: controlledRowSelection,
onRowSelectionChange: controlledOnRowSelectionChange,
onRowClick,
}: ListViewProps<TData>) {
const parentRef = React.useRef<HTMLDivElement>(null)
const [internalColumnSizing, setInternalColumnSizing] = React.useState<ColumnSizingState>({})
const columnSizing = controlledColumnSizing ?? internalColumnSizing
const [internalRowSelection, setInternalRowSelection] = React.useState<RowSelectionState>({})
const rowSelection = controlledRowSelection ?? internalRowSelection
const setRowSelection = controlledOnRowSelectionChange ?? setInternalRowSelection
const debouncedSizingCommit = useDebounceCallback(
(sizing: ColumnSizingState) => {
onColumnSizingCommit?.(sizing)
},
columnSizingCommitDelayMs,
)
const selectionColumn = React.useMemo<ColumnDef<TData, unknown>>(
() => ({
id: "__list_view_select__",
size: 36,
minSize: 36,
maxSize: 36,
enableResizing: false,
meta: {
truncate: false,
truncateTooltip: false,
} satisfies ListViewColumnMeta,
header: ({ table }) => (
<div className="flex size-full items-center justify-center">
<Checkbox
aria-label="Select all rows"
checked={
table.getIsAllRowsSelected()
? true
: table.getIsSomeRowsSelected()
? "indeterminate"
: false
}
onCheckedChange={(value) => table.toggleAllRowsSelected(value === true)}
onClick={(e) => e.stopPropagation()}
/>
</div>
),
cell: ({ row }) => (
<div className="flex size-full items-center justify-center">
<Checkbox
aria-label="Select row"
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(value === true)}
onClick={(e) => e.stopPropagation()}
/>
</div>
),
}),
[],
)
const columns = React.useMemo(() => {
if (!enableRowSelection) return userColumns
return [selectionColumn, ...userColumns]
}, [enableRowSelection, selectionColumn, userColumns])
const getRowId = React.useCallback(
(originalRow: TData, index: number) =>
(getRowIdProp ?? defaultGetRowId)(originalRow, index),
[getRowIdProp],
)
const onColumnSizingChangeInternal = React.useCallback<OnChangeFn<ColumnSizingState>>(
(updater) => {
if (controlledOnColumnSizingChange) {
controlledOnColumnSizingChange(updater)
return
}
setInternalColumnSizing((old) => {
const next = functionalUpdate(updater, old)
debouncedSizingCommit(next)
return next
})
},
[controlledOnColumnSizingChange, debouncedSizingCommit],
)
const direction = useDirection()
const table = useReactTable({
data,
columns,
defaultColumn: {
minSize: 50,
size: 150,
},
columnResizeMode: "onChange",
columnResizeDirection: direction,
enableColumnResizing,
getCoreRowModel: getCoreRowModel(),
getRowId,
onColumnSizingChange: onColumnSizingChangeInternal,
onRowSelectionChange: setRowSelection,
state: {
columnSizing,
rowSelection,
},
enableRowSelection,
})
const headerGroup = table.getHeaderGroups()[0]
const gridTemplateColumns = headerGroup
? gridTemplateFromHeaders(headerGroup.headers)
: ""
const { rows } = table.getRowModel()
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => rowHeight,
overscan: 10,
})
const maxHeightStyle =
typeof maxHeight === "number" ? `${maxHeight}px` : maxHeight
if (data.length === 0) {
return (
<div
className={cn(
"bg-surface-gray-2 text-ink-gray-5 flex min-h-32 items-center justify-center rounded-md px-4 text-sm",
className,
)}
>
{emptyState ?? "No data"}
</div>
)
}
/** Tracks + column gaps + horizontal padding (`px-2` × 2) so header and body share one scroll width. */
const colCount = headerGroup?.headers.length ?? 0
const minTableOuterWidth =
table.getCenterTotalSize() +
Math.max(0, colCount - 1) * 16 +
16
return (
<div className={cn("flex min-w-0 flex-col", className)} role="grid">
<div
ref={parentRef}
className={cn("min-h-0 overflow-auto", scrollAreaClassName)}
style={{ maxHeight: maxHeightStyle }}
>
{headerGroup ? (
<div
className="bg-surface-gray-2 sticky top-0 z-10 mb-2 grid w-full items-center gap-x-4 rounded p-2"
role="row"
style={{
display: "grid",
gridTemplateColumns,
minWidth: `max(100%, ${minTableOuterWidth}px)`,
boxSizing: "border-box",
}}
>
{headerGroup.headers.map((header) => {
const meta = header.column.columnDef.meta as ListViewColumnMeta | undefined
return (
<div
key={header.id}
className={cn(
"text-ink-gray-5 group relative flex min-w-0 items-center px-0 text-sm",
alignClass(meta),
)}
role="columnheader"
>
<div className="min-w-0 flex-1 truncate">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</div>
{enableColumnResizing && header.column.getCanResize() ? (
<>
<span
aria-hidden
className={cn(
"pointer-events-none absolute ltr:-right-2 rtl:-left-2 z-1 w-0.5 bg-gray-400",
"opacity-0 transition-[opacity,background-color] ease-in-out duration-150",
"group-hover:opacity-100 group-hover:bg-gray-400",
header.column.getIsResizing() && "bg-outline-gray-6 opacity-100",
)}
style={{ height: "100%" }}
/>
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize column"
onMouseDown={(e) => {
e.preventDefault()
document.body.classList.add("select-none", "cursor-col-resize")
const end = () => {
document.body.classList.remove("select-none", "cursor-col-resize")
window.removeEventListener("mouseup", end)
window.removeEventListener("touchend", end)
}
window.addEventListener("mouseup", end)
window.addEventListener("touchend", end)
header.getResizeHandler()(e)
}}
onTouchStart={header.getResizeHandler()}
className="absolute top-0 ltr:-right-2 rtl:-left-2 z-10 h-full w-2 max-w-[12px] cursor-col-resize touch-none select-none bg-transparent"
/>
</>
) : null}
</div>
)
})}
</div>
) : null}
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
minWidth: `max(100%, ${minTableOuterWidth}px)`,
boxSizing: "border-box",
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index]
if (!row) return null
const leadDataColumnIndex = enableRowSelection ? 1 : 0
return (
<div
key={row.id}
data-index={virtualRow.index}
role="row"
className={cn(
"ease-in-out absolute top-0 ltr:left-0 rtl:right-0 w-full min-w-0 rounded px-2 transition-all duration-300",
// virtualRow.index > 0 && "border-t border-outline-gray-1",
!row.getIsSelected() && "hover:bg-surface-menu-bar",
row.getIsSelected() && "bg-surface-gray-2 hover:bg-surface-gray-3",
onRowClick && "cursor-pointer",
)}
style={{
display: "grid",
gridTemplateColumns,
boxSizing: "border-box",
columnGap: "1rem",
height: `${rowHeight}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
onClick={(e) => {
if (onRowClick) onRowClick(row.original, e)
}}
>
{virtualRow.index > 0 && <div className="absolute top-0 inset-s-2 inset-e-2 h-px bg-outline-gray-1" />}
{row.getVisibleCells().map((cell, cellIndex) => {
const meta = cell.column.columnDef.meta as ListViewColumnMeta | undefined
return (
<div
key={cell.id}
role="gridcell"
className={cn(
"flex min-w-0 items-center overflow-hidden text-sm",
cellIndex === leadDataColumnIndex
? "text-ink-gray-8"
: "text-ink-gray-7",
alignClass(meta),
tabularNumsClass(meta),
)}
>
<ListViewCellBody cell={cell} row={row} meta={meta}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</ListViewCellBody>
</div>
)
})}
</div>
)
})}
</div>
</div>
</div>
)
}
/**
* Div-based list with CSS Grid columns, optional resize handles, row virtualization, and frappe-uialigned Espresso tokens.
*/
export function ListView<TData>(props: ListViewProps<TData>) {
return <ListViewInner {...props} />
}
export type { ColumnSizingState, RowSelectionState }

View File

@@ -1,27 +0,0 @@
import { Skeleton } from "./skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./table"
export const TableLoader = ({ rows = 10, columns = 5 }: { rows?: number, columns?: number }) => {
return <Table>
<TableHeader>
<TableRow>
{Array.from({ length: columns }).map((_, index) => (
<TableHead key={index}>
<Skeleton className="h-4 w-full" />
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: rows }).map((_, index) => (
<TableRow key={index}>
{Array.from({ length: columns }).map((_, index) => (
<TableCell key={index}>
<Skeleton className="h-4 w-full" />
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
}

View File

@@ -1,28 +0,0 @@
import React from 'react'
import rehypeRaw from 'rehype-raw'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
// import './markdown.css'
interface MarkdownRendererProps {
content: string,
className?: string
}
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content }) => {
return <ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
// components={{
// p: (props) => <Text {...props} as='p' />,
// ul: (props) => <UnorderedList {...props} />,
// ol: (props) => <OrderedList {...props} />,
// li: (props) => <ListItem {...props} />,
// a: (props) => <Link {...props} />,
// }}>
>
{content}
</ReactMarkdown>
}
export default MarkdownRenderer

View File

@@ -1,87 +0,0 @@
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-surface-modal rounded-lg border p-3 shadow-xl outline-hidden 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin)",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-1 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-ink-gray-5", className)}
{...props}
/>
)
}
export {
Popover,
PopoverTrigger,
PopoverContent,
PopoverAnchor,
PopoverHeader,
PopoverTitle,
PopoverDescription,
}

View File

@@ -1,67 +0,0 @@
import * as React from "react"
import { Progress as ProgressPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { cva, VariantProps } from "class-variance-authority"
const progressVariants = cva(
"bg-surface-gray-2 relative w-full overflow-hidden rounded-full",
{
variants: {
size: {
sm: "h-0.5",
md: "h-1",
lg: "h-2.5",
xl: "h-3"
}
}
}
)
interface ProgressProps extends React.ComponentProps<typeof ProgressPrimitive.Root>, VariantProps<typeof progressVariants> {
/** Optional text label displayed on the progress bar */
label?: React.ReactNode,
/** Whether to show a hint/tooltip for the progress value */
hint?: boolean,
/** Override the default hint text with custom progress value */
hintText?: React.ReactNode
}
function Progress({
className,
value,
size = "sm",
label,
hint,
hintText,
...props
}: ProgressProps) {
const progressValue = hintText ? hintText : `${value}%`
return (
<div className="flex flex-col gap-2.5">
{label || hint ? <div className="flex items-center justify-between gap-1">
{label && <span className="text-base font-medium text-ink-gray-7">{label}</span>}
{hint && <span className="text-base font-medium text-ink-gray-5">{progressValue}</span>}
</div> : null}
<ProgressPrimitive.Root
data-slot="progress"
data-size={size}
className={cn(
progressVariants({ size }),
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-surface-gray-7 rounded-xl h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
</div>
)
}
export { Progress }

View File

@@ -1,43 +0,0 @@
import * as React from "react"
import { CircleIcon } from "lucide-react"
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-outline-gray-2 text-ink-gray-7 focus-visible:border-outline-gray-1 focus-visible:ring-outline-gray-1/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@@ -1,221 +0,0 @@
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { cva, VariantProps } from "class-variance-authority"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
const selectVariants = cva(cn("flex w-fit items-center justify-between gap-2 min-w-0 transition-all outline-none border border-transparent whitespace-nowrap",
"focus-visible:bg-surface-white focus-visible:border-outline-gray-4 focus-visible:shadow-focus-gray",
"active:bg-surface-white active:shadow-sm active:border-outline-gray-4 data-[state=open]:border-outline-gray-4",
"placeholder:text-ink-gray-4 text-ink-gray-7",
"disabled:bg-surface-gray-1 disabled:placeholder:text-ink-gray-3 disabled:text-ink-gray-3 disabled:cursor-not-allowed disabled:pointer-events-none",
"aria-readonly:bg-surface-gray-1 aria-readonly:text-ink-gray-6 aria-readonly:pointer-events-none aria-invalid:shadow-focus-red aria-invalid:border-outline-red-3",
// Disable most styles inside an input group
"in-data-[slot=input-group]:border-transparent! in-data-[slot=input-group]:focus-visible:shadow-none! in-data-[slot=input-group]:bg-transparent!"),
{
variants: {
inputSize: {
sm: "text-base rounded py-1.5 px-2 h-7",
md: "text-base rounded py-2 px-2.5 h-8",
lg: "text-lg rounded-md py-[11px] px-3 h-10",
},
variant: {
subtle: "bg-surface-gray-2 hover:bg-surface-gray-3 aria-invalid:bg-surface-red-1",
outline: "bg-surface-white border-outline-gray-2 hover:border-outline-gray-3 active:border-outline-gray-4 disabled:border-outline-gray-2",
}
},
defaultVariants: {
inputSize: "md",
variant: "subtle"
}
}
)
function SelectTrigger({
className,
inputSize = "md",
variant = "subtle",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & VariantProps<typeof selectVariants>) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-input-size={inputSize}
className={cn(
"data-placeholder:text-ink-gray-4 [&_svg:not([class*='text-'])]:text-ink-gray-7",
"*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
selectVariants({ inputSize, variant }),
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-surface-modal rounded-lg min-w-32 border shadow-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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-ink-gray-4 px-2 py-1.5 text-sm font-medium", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"outline-hidden select-none relative flex w-full cursor-default items-center gap-2 rounded py-1.5 pe-8 px-2",
"focus:bg-surface-gray-2 text-ink-gray-6 [&_svg:not([class*='text-'])]:text-ink-gray-6 text-base [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"data-disabled:pointer-events-none data-disabled:text-ink-gray-3",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute ltr:right-2 rtl:left-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-outline-gray-modals pointer-events-none mx-0.5 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -1,26 +0,0 @@
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-outline-gray-modals shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -1,273 +0,0 @@
import * as React from "react"
import { Tabs as TabsPrimitive, Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { DialogContent } from "./dialog"
/**
* Sample Usage:
*
* <Dialog open={open} onOpenChange={setOpen}>
* <DialogTrigger>
* ...your content...
* </DialogTrigger>
*
* <SettingsDialog onClose={() => setOpen(false)} defaultValue="preferences">
* <SettingsTabs>
* <SettingsTabGroup header="Configuration">
* <SettingsTabItem icon={<SlidersVerticalIcon />} label="Preferences" value="preferences" />
* <SettingsTabItem icon={<ZapIcon />} label="Matching Rules" value="rules" />
* </SettingsTabGroup>
* <SettingsTabGroup header="Setup">
* <SettingsTabItem icon={<LandmarkIcon />} label="Bank Accounts" value="bank-accounts" />
* <SettingsTabItem icon={<ListIcon />} label="Masters" value="masters" />
* </SettingsTabGroup>
* </SettingsTabs>
*
* <SettingsPanels>
* <SettingsPanel value="preferences"><Preferences /></SettingsPanel>
* <SettingsPanel value="rules"><MatchingRules /></SettingsPanel>
* <SettingsPanel value="bank-accounts"><BankAccounts /></SettingsPanel>
* <SettingsPanel value="masters"><Masters /></SettingsPanel>
* </SettingsPanels>
* </SettingsDialog>
* </Dialog>
*/
type SettingsDialogContextValue = {
onClose?: VoidFunction
}
const SettingsDialogContext = React.createContext<SettingsDialogContextValue>({})
/**
* Exposes `onClose` to descendant panels so they can dismiss the dialog after
* a successful save without prop-drilling.
*/
export const useSettingsDialog = () => React.useContext(SettingsDialogContext)
type SettingsDialogProps = Omit<
React.ComponentProps<typeof TabsPrimitive.Root>,
"orientation"
> & {
onClose?: VoidFunction
contentClassName?: string
}
function SettingsDialog({
children,
className,
contentClassName,
onClose,
...props
}: SettingsDialogProps) {
const contextValue = React.useMemo(() => ({ onClose }), [onClose])
return (
<DialogContent className={cn("min-w-5xl max-lg:min-w-[98vw] p-0 overflow-y-hidden", contentClassName)} showCloseButton={false}>
<SettingsDialogContext.Provider value={contextValue}>
<TabsPrimitive.Root
data-slot="settings-dialog"
orientation="vertical"
className={cn(
"flex h-[calc(100vh-8rem)] bg-surface-menu-bar",
className
)}
{...props}
>
{children}
</TabsPrimitive.Root>
</SettingsDialogContext.Provider>
</DialogContent>
)
}
function SettingsTabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="settings-tabs"
className={cn(
"flex flex-col w-56 bg-surface-menu-bar rounded-s-lg shrink-0 overflow-y-auto m-1",
className
)}
{...props}
/>
)
}
type SettingsTabGroupProps = React.ComponentProps<"div"> & {
header?: React.ReactNode
}
function SettingsTabGroup({
children,
header,
className,
...props
}: SettingsTabGroupProps) {
return (
<div data-slot="settings-tab-group" className={className} {...props}>
{header && (
<div className="h-7.5 px-2 py-[7px] my-[3px] flex cursor-default gap-1.5 text-xs font-medium text-ink-gray-5 transition-all duration-300 ease-in-out sticky top-0 z-10 bg-surface-menu-bar">
<span>{header}</span>
</div>
)}
<nav className="space-y-[3px] px-1">{children}</nav>
<div className="mb-0.5 mt-[5px]"></div>
</div>
)
}
type SettingsTabItemProps = React.ComponentProps<typeof TabsPrimitive.Trigger> & {
icon?: React.ReactNode
label: React.ReactNode
}
function SettingsTabItem({
icon,
label,
className,
...props
}: SettingsTabItemProps) {
return (
<TabsPrimitive.Trigger
data-slot="settings-tab-item"
className={cn(
"flex h-7.5 cursor-pointer items-center rounded text-ink-gray-6 duration-300 ease-in-out focus:outline-none focus:transition-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-outline-gray-3 w-full",
"hover:bg-surface-gray-3",
"data-[state=active]:bg-surface-selected data-[state=active]:shadow-sm data-[state=active]:hover:bg-surface-selected",
className
)}
{...props}
>
<div className="flex w-full items-center justify-between duration-300 ease-in-out px-2 py-[7px]">
<div className="flex items-center truncate">
{icon && (
<div className="[&_svg:not([class*='size-'])]:size-4 text-ink-gray-6 [&_svg:not([class*='text-'])]:text-ink-gray-6">
{icon}
</div>
)}
<span
className={cn(
"flex-1 shrink-0 truncate text-sm duration-300 ease-in-out w-auto opacity-100 text-ink-gray-6",
icon && "ms-2"
)}
>
{label}
</span>
</div>
</div>
</TabsPrimitive.Trigger>
)
}
function SettingsPanels({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="settings-panels"
className={cn(
"flex flex-col flex-1 overflow-y-auto bg-surface-modal",
className
)}
{...props}
/>
)
}
function SettingsPanel({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="settings-panel"
className={cn("flex flex-col h-full w-full text-ink-gray-8 py-8 px-6 gap-6", className)}
{...props}
/>
)
}
/**
* Usage:
*
* <SettingsPanelHeader actions={<><Button>Add</Button></>}>
*
* <SettingsPanelTitle>Settings</SettingsPanelTitle>
* <SettingsPanelDescription>Settings description</SettingsPanelDescription>
*
* </SettingsPanelHeader>
*/
function SettingsPanelHeader({
className,
children,
actions,
...props
}: React.ComponentProps<"div"> & { actions?: React.ReactNode }) {
return (
<div
data-slot="dialog-header"
className={cn("flex justify-between items-start px-2 text-ink-gray-7", className)}
{...props}
>
<div className="flex flex-col gap-1 w-full">
{children}
</div>
<div className="flex item-center space-x-2 w-fit justify-end">
{actions}
</div>
</div>
)
}
function SettingsPanelTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("flex gap-2 text-xl font-semibold leading-none h-5", className)}
{...props}
/>
)
}
function SettingsPanelDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-p-base text-ink-gray-6", className)}
{...props}
/>
)
}
function SettingsPanelContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div className={cn("flex-1 flex flex-col overflow-y-auto px-2", className)} {...props} />
)
}
export {
SettingsDialog,
SettingsTabs,
SettingsTabGroup,
SettingsTabItem,
SettingsPanels,
SettingsPanel,
SettingsPanelHeader,
SettingsPanelTitle,
SettingsPanelDescription,
SettingsPanelContent
}

View File

@@ -1,13 +0,0 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-surface-gray-2 animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -1,53 +0,0 @@
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
import { useTheme } from "./theme-provider"
const themeMap = {
"Automatic": "system",
"Dark": "dark",
"Light": "light",
}
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "Automatic" } = useTheme()
return (
<Sonner
theme={themeMap[theme as keyof typeof themeMap] as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--surface-gray-1)",
"--normal-text": "var(--text-ink-gray-8)",
"--normal-border": "var(--outline-gray-1)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -1,13 +0,0 @@
import { cn } from "@/lib/utils"
export const StatContainer = ({ children, className }: { children: React.ReactNode, className?: string }) => {
return <div className={cn("flex flex-col gap-1.5 p-2", className)}>{children}</div>
}
export const StatLabel = ({ children, className }: { children: React.ReactNode, className?: string }) => {
return <span className={cn("uppercase text-2xs font-medium text-ink-gray-6", className)}>{children}</span>
}
export const StatValue = ({ children, className }: { children: React.ReactNode, className?: string }) => {
return <span className={cn("text-xl text-ink-gray-8 font-semibold tabular-nums", className)}>{children}</span>
}

View File

@@ -1,45 +0,0 @@
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "sm",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "md"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer cursor-pointer group/switch inline-flex shrink-0 items-center rounded-full transition-all outline-none disabled:cursor-not-allowed",
"data-[state=unchecked]:bg-ink-gray-2 data-[state=unchecked]:hover:bg-ink-gray-3 data-[state=unchecked]:active:bg-ink-gray-4 data-[state=unchecked]:disabled:bg-ink-gray-1",
"data-[state=checked]:bg-ink-gray-8 data-[state=checked]:hover:bg-ink-gray-7 data-[state=checked]:active:bg-ink-gray-6 data-[state=checked]:disabled:bg-ink-gray-1",
"data-[size=sm]:h-4 data-[size=sm]:w-6.5 data-[size=md]:h-5 data-[size=md]:w-8",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"shadow-switch block pointer-events-none rounded-full ring-0 transition-transform bg-ink-white",
"group-data-[size=sm]/switch:size-3 group-data-[size=md]/switch:size-3.5",
// Unchecked: keep thumb near the start edge (mirrored by dir)
"ltr:data-[state=unchecked]:group-data-[size=sm]/switch:translate-x-0.5",
"ltr:data-[state=unchecked]:group-data-[size=md]/switch:translate-x-[3px]",
"rtl:data-[state=unchecked]:group-data-[size=sm]/switch:-translate-x-0.5",
"rtl:data-[state=unchecked]:group-data-[size=md]/switch:-translate-x-[3px]",
// Checked: move to opposite edge (mirrored by dir)
"ltr:data-[state=checked]:translate-x-[calc(100%-0px)]",
"rtl:data-[state=checked]:-translate-x-[calc(100%-0px)]",
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -1,114 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, containerClassName, ...props }: React.ComponentProps<"table"> & { containerClassName?: string }) {
return (
<div
data-slot="table-container"
className={cn("relative w-full overflow-x-auto rounded border-outline-gray-1 border", containerClassName)}
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-surface-gray-2 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-surface-gray-1 data-[state=selected]:bg-surface-gray-2 border-b transition-all",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"bg-surface-gray-2 text-ink-gray-5 text-sm p-2 text-start align-middle whitespace-nowrap [&:has([role=checkbox])]:pe-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle text-base whitespace-nowrap [&:has([role=checkbox])]:pe-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-ink-gray-5 my-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

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