mirror of
https://github.com/frappe/erpnext.git
synced 2026-05-16 19:49:18 +00:00
Compare commits
1977 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d1a86f4e5 | ||
|
|
55d6bc475e | ||
|
|
712403aae4 | ||
|
|
2773b7c002 | ||
|
|
69642860ee | ||
|
|
d22cd7b856 | ||
|
|
53b5de85bb | ||
|
|
28a2230d02 | ||
|
|
4380d710c7 | ||
|
|
78a79120ea | ||
|
|
930990434c | ||
|
|
c5e24eda69 | ||
|
|
06784d2a46 | ||
|
|
f9dec73042 | ||
|
|
3c993377aa | ||
|
|
45f05fbeaa | ||
|
|
cf5e8ce878 | ||
|
|
c740f77a6f | ||
|
|
fb6c05f186 | ||
|
|
bc07b2d3e5 | ||
|
|
d80a52ae22 | ||
|
|
d128fb92cf | ||
|
|
66914ac2fc | ||
|
|
20d6b54590 | ||
|
|
573e37a78d | ||
|
|
7a292f9ea6 | ||
|
|
876d4bdb75 | ||
|
|
24530fa349 | ||
|
|
5b7f07ddb1 | ||
|
|
1a4748759d | ||
|
|
c8f91ac4db | ||
|
|
1e7a265037 | ||
|
|
1b9eaed4d2 | ||
|
|
5560f6c270 | ||
|
|
9134db9cd3 | ||
|
|
6e349569c7 | ||
|
|
3532c1cc69 | ||
|
|
b5527cf328 | ||
|
|
631958314f | ||
|
|
422ff15be5 | ||
|
|
1d5ef62452 | ||
|
|
2e958de95b | ||
|
|
0729c9a9cd | ||
|
|
95705f18aa | ||
|
|
85206e0278 | ||
|
|
0f9cfeb2ef | ||
|
|
dfbe847307 | ||
|
|
f58242dca7 | ||
|
|
23e9ad3fd9 | ||
|
|
f4008adc16 | ||
|
|
03acbc3dc9 | ||
|
|
3deda25d21 | ||
|
|
8c3739eb08 | ||
|
|
346f080538 | ||
|
|
09d772f92e | ||
|
|
4dfe532475 | ||
|
|
6de5367f12 | ||
|
|
332026fe5e | ||
|
|
992800f3dd | ||
|
|
db74360396 | ||
|
|
0b6a372a52 | ||
|
|
a4a389bd41 | ||
|
|
4e850f31d5 | ||
|
|
6f9f3d0a5c | ||
|
|
26ca7445eb | ||
|
|
ddc6d2c4e0 | ||
|
|
385835a167 | ||
|
|
548e9a26db | ||
|
|
d04aa4408d | ||
|
|
bbb6d7c004 | ||
|
|
0fc96e8f7d | ||
|
|
d4bf9ee0ec | ||
|
|
e82b4d9ca7 | ||
|
|
d5549e2f6c | ||
|
|
5858b14071 | ||
|
|
e8777a1e34 | ||
|
|
fa0a9085ca | ||
|
|
ac7e5271b0 | ||
|
|
82cac9c40f | ||
|
|
75804a364b | ||
|
|
25f7fa548d | ||
|
|
28d9c2ca68 | ||
|
|
8efdab7e96 | ||
|
|
907a809f3f | ||
|
|
7028034cd6 | ||
|
|
757923b482 | ||
|
|
2370d04b41 | ||
|
|
fb7f9a81d4 | ||
|
|
f86568b078 | ||
|
|
b9e40a42b8 | ||
|
|
4f8cc1359b | ||
|
|
0cd0b8213d | ||
|
|
2d3190effb | ||
|
|
542eb6aca4 | ||
|
|
55619be732 | ||
|
|
a68769565b | ||
|
|
19234cafbe | ||
|
|
09623d4c0c | ||
|
|
032a282f84 | ||
|
|
ca093177e0 | ||
|
|
ea3cf57042 | ||
|
|
06ffe52d6e | ||
|
|
c120cc7ed1 | ||
|
|
25be38e23c | ||
|
|
2a720e7008 | ||
|
|
f38eca9124 | ||
|
|
ad89f88c93 | ||
|
|
78f654765d | ||
|
|
231dd1856f | ||
|
|
da081254a6 | ||
|
|
c543d15f3c | ||
|
|
ddf0e35009 | ||
|
|
88b82383f5 | ||
|
|
c4155b6c81 | ||
|
|
c933c2bd53 | ||
|
|
a04c028522 | ||
|
|
5c5a5361bc | ||
|
|
060defcc2b | ||
|
|
d0d8cff48f | ||
|
|
844f3dbc0b | ||
|
|
43937acd8b | ||
|
|
503b5bf140 | ||
|
|
3542087003 | ||
|
|
d68801e73a | ||
|
|
addec3aa8f | ||
|
|
b001884f9d | ||
|
|
d1a80d40c4 | ||
|
|
a8030c9713 | ||
|
|
54f20de7e3 | ||
|
|
f8893b04d5 | ||
|
|
1bade56e37 | ||
|
|
a2b96799ff | ||
|
|
d0f0e38e8d | ||
|
|
590f2ffe28 | ||
|
|
084c7f72f0 | ||
|
|
84aa54c540 | ||
|
|
5fc3ca1d4b | ||
|
|
d62fa3c464 | ||
|
|
07337ba9da | ||
|
|
2088a01c19 | ||
|
|
68cc518497 | ||
|
|
6f9089dd5b | ||
|
|
63edd5ddc6 | ||
|
|
2b3e047143 | ||
|
|
cb2e6e1e2e | ||
|
|
37e3493ec4 | ||
|
|
601581d6f8 | ||
|
|
8cf4402823 | ||
|
|
6fe08428c1 | ||
|
|
837cdc9cc3 | ||
|
|
5281d60f2d | ||
|
|
0aadd1e3a5 | ||
|
|
60a6b38c31 | ||
|
|
be2a4b7b2a | ||
|
|
5c839f60e4 | ||
|
|
6e77a45c05 | ||
|
|
2a6ddc7f67 | ||
|
|
fee5bcadb2 | ||
|
|
f572bc51e1 | ||
|
|
fba33b7e7a | ||
|
|
ebca389136 | ||
|
|
c94b8c41f3 | ||
|
|
e517eeaaa2 | ||
|
|
c3931d4e29 | ||
|
|
0b9fdcd8cd | ||
|
|
b4e941835b | ||
|
|
9132f0fc4a | ||
|
|
ce37530e70 | ||
|
|
889fdf2f11 | ||
|
|
5518e8c99f | ||
|
|
419b9b3279 | ||
|
|
a9e6f8efd8 | ||
|
|
26d3a25d18 | ||
|
|
0e20e35842 | ||
|
|
b4107b8fd5 | ||
|
|
a165b240a7 | ||
|
|
f6639db0e9 | ||
|
|
c35221852a | ||
|
|
3854d2cbf6 | ||
|
|
ab19b16fe2 | ||
|
|
1fd6c3ba1a | ||
|
|
4274c2aba3 | ||
|
|
79d6a51e1e | ||
|
|
4eb9107e22 | ||
|
|
5a915cb45e | ||
|
|
b8c3765b85 | ||
|
|
9ead8d4e3f | ||
|
|
7f8fa7cf5e | ||
|
|
fd4cedf5e4 | ||
|
|
435db260ee | ||
|
|
f5357c233d | ||
|
|
0d2da6d86c | ||
|
|
0349e7a0b8 | ||
|
|
7ae91cac01 | ||
|
|
b925469c4d | ||
|
|
f0ea20e579 | ||
|
|
3faeb1609b | ||
|
|
b16dd3f2dd | ||
|
|
ffae7e42d3 | ||
|
|
b5550f747e | ||
|
|
f6adef45bf | ||
|
|
07b023a934 | ||
|
|
53666974a3 | ||
|
|
c3e7f7f02f | ||
|
|
75a068aea8 | ||
|
|
6dca96b423 | ||
|
|
f6eb844d20 | ||
|
|
6d727c90b6 | ||
|
|
d8fc9444ea | ||
|
|
e65b9fc2ae | ||
|
|
1995fcfdd8 | ||
|
|
c2590c174d | ||
|
|
11fc3e5495 | ||
|
|
0edee23e53 | ||
|
|
f9232b209c | ||
|
|
d6bb0ae093 | ||
|
|
d73920be12 | ||
|
|
6545bcbbd9 | ||
|
|
bc8f63b6dd | ||
|
|
f4e77f63dd | ||
|
|
a2ec597e3d | ||
|
|
6c51e4cd1f | ||
|
|
f89448709f | ||
|
|
877d99c5a5 | ||
|
|
1614d33e5c | ||
|
|
336be9d820 | ||
|
|
742c5f1822 | ||
|
|
0a8c195ab9 | ||
|
|
53d7c9fd9c | ||
|
|
7f021fb705 | ||
|
|
e2b554e151 | ||
|
|
c1a469478d | ||
|
|
d61b5fd5f6 | ||
|
|
28f3429a54 | ||
|
|
af98963fa8 | ||
|
|
82438d6c72 | ||
|
|
1c65cc1088 | ||
|
|
23768ae0a5 | ||
|
|
3d87f3c070 | ||
|
|
43e5dfc3ca | ||
|
|
e6f17e0447 | ||
|
|
d6b379b936 | ||
|
|
40bcaa7bc3 | ||
|
|
31fe6a378c | ||
|
|
3ef6c24f07 | ||
|
|
b93f2350ee | ||
|
|
453fe376ab | ||
|
|
3c8a066484 | ||
|
|
b0fd152896 | ||
|
|
0359a3ed0b | ||
|
|
9eeb819106 | ||
|
|
d9b255b952 | ||
|
|
ba01d66c24 | ||
|
|
9e5d94c1e6 | ||
|
|
700572980d | ||
|
|
2018a90ad8 | ||
|
|
9ecbf57a84 | ||
|
|
d26cd69fe5 | ||
|
|
be711eacde | ||
|
|
0cad511136 | ||
|
|
eb89903dec | ||
|
|
6ee4b46be0 | ||
|
|
de747fe625 | ||
|
|
d51dbf5254 | ||
|
|
f555183ab6 | ||
|
|
2a8267e10a | ||
|
|
5f4641e55b | ||
|
|
cac7a358dd | ||
|
|
ee50767e42 | ||
|
|
257865deb2 | ||
|
|
e04a2e6da2 | ||
|
|
97efd51fb8 | ||
|
|
40012f6617 | ||
|
|
6a04c159ca | ||
|
|
940d3cfe0a | ||
|
|
ece85c770f | ||
|
|
3093409933 | ||
|
|
b8207d5ed1 | ||
|
|
d5c58277cb | ||
|
|
ff2d536943 | ||
|
|
2f4bb23125 | ||
|
|
2e577ed25b | ||
|
|
2e1d426c78 | ||
|
|
9e9c8a07a7 | ||
|
|
53e120269d | ||
|
|
89ebf48544 | ||
|
|
4198dd643d | ||
|
|
ab61a757e3 | ||
|
|
299e141cee | ||
|
|
af6974893b | ||
|
|
0969ec4186 | ||
|
|
ce2670b252 | ||
|
|
b083121421 | ||
|
|
41103a0622 | ||
|
|
08e8cc8575 | ||
|
|
b6b7e8e2f6 | ||
|
|
1cc2b159dd | ||
|
|
5ff2ae5a83 | ||
|
|
ea0d53e2f3 | ||
|
|
927f40b296 | ||
|
|
f37bf62824 | ||
|
|
3aeb7d6b01 | ||
|
|
8a72d7fafe | ||
|
|
2f025272d7 | ||
|
|
488747eb5d | ||
|
|
1e43c37452 | ||
|
|
e2ac476587 | ||
|
|
6c5788dfba | ||
|
|
aa9d35b28a | ||
|
|
84e5272f5d | ||
|
|
697f521e14 | ||
|
|
c805324a99 | ||
|
|
3e2b40ad4a | ||
|
|
c133f7156d | ||
|
|
a9bb3e2315 | ||
|
|
577a7591c7 | ||
|
|
f8d278b733 | ||
|
|
451e4fbb21 | ||
|
|
545e9e069a | ||
|
|
3617a9b674 | ||
|
|
39d93f35e0 | ||
|
|
44e0b36093 | ||
|
|
915fcc0166 | ||
|
|
e3019c827c | ||
|
|
a76336e3d9 | ||
|
|
e8d08df044 | ||
|
|
3e5d18c5c4 | ||
|
|
2f5fa3b207 | ||
|
|
1dcfd9174f | ||
|
|
1e64f392bb | ||
|
|
777d9161cc | ||
|
|
887d2a8379 | ||
|
|
9cdfe74de6 | ||
|
|
bd9427623f | ||
|
|
256a258b38 | ||
|
|
f02b3b6166 | ||
|
|
9bc5a30ea4 | ||
|
|
a48a29410e | ||
|
|
7eded60892 | ||
|
|
ee067e6015 | ||
|
|
b0e3fa3979 | ||
|
|
514c86cf4b | ||
|
|
56416d18d3 | ||
|
|
90a1d32098 | ||
|
|
a7ece65536 | ||
|
|
f5dda90f26 | ||
|
|
f09001a25e | ||
|
|
d7254bba47 | ||
|
|
71a17cfda9 | ||
|
|
7f0751539b | ||
|
|
7ef48a966a | ||
|
|
2515bf3aff | ||
|
|
d15cd08e72 | ||
|
|
38ed425ee2 | ||
|
|
ef454822d7 | ||
|
|
6e44b8913e | ||
|
|
9d16d06504 | ||
|
|
31319cb6ee | ||
|
|
c4d74483e1 | ||
|
|
086122f650 | ||
|
|
f9e2696745 | ||
|
|
f183885829 | ||
|
|
eb04706fee | ||
|
|
76a7781283 | ||
|
|
89560d4691 | ||
|
|
90fd6f2e40 | ||
|
|
edba5f3a06 | ||
|
|
6da5dff26c | ||
|
|
4a004a2a82 | ||
|
|
187542bfa5 | ||
|
|
98dfd64f63 | ||
|
|
4228885f1e | ||
|
|
e6a32a9d02 | ||
|
|
ffc59ebc9c | ||
|
|
b4be235bbf | ||
|
|
00c94afa78 | ||
|
|
17853931d6 | ||
|
|
f13d37fbf9 | ||
|
|
b892139342 | ||
|
|
ab1fc22431 | ||
|
|
93ad48bc1b | ||
|
|
13cc07237e | ||
|
|
4282b9c68a | ||
|
|
6d11a08cdd | ||
|
|
66780543bd | ||
|
|
ea392b2009 | ||
|
|
80272de0df | ||
|
|
54342539c3 | ||
|
|
eebc0d2ad3 | ||
|
|
fa814c0b71 | ||
|
|
83235b90d7 | ||
|
|
9b60d8e711 | ||
|
|
a953709640 | ||
|
|
adea316dd2 | ||
|
|
77578e41e5 | ||
|
|
1e90b9a148 | ||
|
|
c6695b613c | ||
|
|
8f83616b60 | ||
|
|
99da4f5147 | ||
|
|
135cb5fd67 | ||
|
|
0975583388 | ||
|
|
b9ef061911 | ||
|
|
7a2759b2f0 | ||
|
|
ccd017c737 | ||
|
|
bb53cce228 | ||
|
|
9417f55b7d | ||
|
|
eca3ec114c | ||
|
|
92dc95570d | ||
|
|
74b11710cc | ||
|
|
ad22256b2d | ||
|
|
dd7be2b370 | ||
|
|
f322dc9d69 | ||
|
|
e537896df8 | ||
|
|
0af8077bcc | ||
|
|
a71e8bb116 | ||
|
|
efd716e53d | ||
|
|
71fd18bdf9 | ||
|
|
3cf1ce8360 | ||
|
|
a6d41151ff | ||
|
|
2be8313819 | ||
|
|
1693698fed | ||
|
|
d32977e3a9 | ||
|
|
6988e2cbbc | ||
|
|
342a14d340 | ||
|
|
13b019ab8e | ||
|
|
d3d6b5c660 | ||
|
|
2e4e8bcaa7 | ||
|
|
1ed0124ad7 | ||
|
|
6394dead72 | ||
|
|
dba82720b6 | ||
|
|
b64f86148c | ||
|
|
b47dfacb3e | ||
|
|
68e97808c5 | ||
|
|
d4baa9a74a | ||
|
|
7846548a1b | ||
|
|
86ee9959a2 | ||
|
|
399faf0ced | ||
|
|
da778edf48 | ||
|
|
fbe5d128a8 | ||
|
|
d76ddf7271 | ||
|
|
1e85d72127 | ||
|
|
ef18b5cd93 | ||
|
|
7ff3dc0ac4 | ||
|
|
e9e510a76e | ||
|
|
b73b161cbe | ||
|
|
9b37f2d95c | ||
|
|
f5bf95ca65 | ||
|
|
4013092271 | ||
|
|
c2f419ac3d | ||
|
|
eaa4f7bb55 | ||
|
|
75fa2b2277 | ||
|
|
df753676c6 | ||
|
|
03e4df7a1a | ||
|
|
f1529a05b2 | ||
|
|
f452ad3ce2 | ||
|
|
6068dc959f | ||
|
|
dce5e46599 | ||
|
|
0696bd2082 | ||
|
|
820bd15e1e | ||
|
|
e7614e2290 | ||
|
|
f97877a60a | ||
|
|
28ac0effff | ||
|
|
fc8437c499 | ||
|
|
a9edd3f132 | ||
|
|
a18196f584 | ||
|
|
7d7a1efadb | ||
|
|
3449ab063a | ||
|
|
d827ab3d2e | ||
|
|
8ec15b537e | ||
|
|
7f87a5e5c6 | ||
|
|
c41730dfee | ||
|
|
fa5238ba12 | ||
|
|
b9f26a1f31 | ||
|
|
eda64cbd4d | ||
|
|
af994c1a22 | ||
|
|
91aaabdd31 | ||
|
|
e0ca34ae39 | ||
|
|
ddeb9775ed | ||
|
|
ad25c6d163 | ||
|
|
71a1dda958 | ||
|
|
875a2e4947 | ||
|
|
d28474a450 | ||
|
|
f3a794384a | ||
|
|
97e7916b66 | ||
|
|
893eb8c77a | ||
|
|
0e0a7f3563 | ||
|
|
f8738a791b | ||
|
|
6badf00313 | ||
|
|
5bbcb73808 | ||
|
|
998469d5c7 | ||
|
|
5c95f3347b | ||
|
|
90b9ab0bc8 | ||
|
|
935eea6463 | ||
|
|
2bf9d41797 | ||
|
|
6008fa710d | ||
|
|
47bb728f65 | ||
|
|
aef6c959ea | ||
|
|
bddc7a3e4a | ||
|
|
54c6948174 | ||
|
|
e45af4345f | ||
|
|
58dbb3d638 | ||
|
|
be4496e4ab | ||
|
|
c051536182 | ||
|
|
2aecf0103a | ||
|
|
e6cac26640 | ||
|
|
d2ee967383 | ||
|
|
0b6546ea06 | ||
|
|
8e8ee56e64 | ||
|
|
13505ddcfb | ||
|
|
5d4ac95e7a | ||
|
|
2b37d7514d | ||
|
|
8368feb9df | ||
|
|
3a78af7f42 | ||
|
|
3bedc6cf7e | ||
|
|
c1874cb7d5 | ||
|
|
5d088350dc | ||
|
|
f3148e052c | ||
|
|
d987688058 | ||
|
|
c283c1c472 | ||
|
|
6566acbe23 | ||
|
|
d6755c3d14 | ||
|
|
0d4f56bf84 | ||
|
|
5b1fa81451 | ||
|
|
8164d195fc | ||
|
|
5ec66169a7 | ||
|
|
9660debe28 | ||
|
|
f382b30b4e | ||
|
|
15996952f6 | ||
|
|
daa2420996 | ||
|
|
f37b6fde72 | ||
|
|
afa66e4785 | ||
|
|
913168e8b6 | ||
|
|
7e6bbcc3fb | ||
|
|
9715637c80 | ||
|
|
3f74733942 | ||
|
|
e136bfbb61 | ||
|
|
00b780362b | ||
|
|
1d202fe739 | ||
|
|
bc6561cdd0 | ||
|
|
d9760bbf4f | ||
|
|
a821c6669f | ||
|
|
d43d308e2f | ||
|
|
90cd957d6e | ||
|
|
7b0bfe76cc | ||
|
|
14a46bf920 | ||
|
|
bc2b8da597 | ||
|
|
fd2b76a4d2 | ||
|
|
8fd65d7afa | ||
|
|
2c53cf3902 | ||
|
|
d3cf8cb851 | ||
|
|
77f41e120d | ||
|
|
426b7db3c8 | ||
|
|
934740205a | ||
|
|
4454af8efd | ||
|
|
11fb00c21d | ||
|
|
31ce09204f | ||
|
|
6c354895d6 | ||
|
|
be55082751 | ||
|
|
a518a735f3 | ||
|
|
ec003342a0 | ||
|
|
3ba36212b0 | ||
|
|
da41057cd6 | ||
|
|
b9083411cc | ||
|
|
c99cec1071 | ||
|
|
4307cd5b1c | ||
|
|
9ed072ac83 | ||
|
|
342ce65401 | ||
|
|
ed76d6699a | ||
|
|
cd5b0ea5fd | ||
|
|
ae8068e833 | ||
|
|
d7c48d645a | ||
|
|
107fecd4b0 | ||
|
|
2859a143f2 | ||
|
|
bd9b0185f4 | ||
|
|
0faa261729 | ||
|
|
91da450a31 | ||
|
|
8ebc2e38ec | ||
|
|
96c79dfe8b | ||
|
|
958bb6c619 | ||
|
|
20787ef5da | ||
|
|
226aafa8cf | ||
|
|
30380851d8 | ||
|
|
248ea16d96 | ||
|
|
3f3ddf968e | ||
|
|
a14f834589 | ||
|
|
03c9d16ca6 | ||
|
|
b8d201658a | ||
|
|
cc93b14154 | ||
|
|
d4ecede3c3 | ||
|
|
99d55ab8d8 | ||
|
|
8ea0cc90df | ||
|
|
2be6bb694f | ||
|
|
31af13a5e6 | ||
|
|
41f986ff83 | ||
|
|
469bb0ba4e | ||
|
|
8fd9b88cd9 | ||
|
|
97bb100010 | ||
|
|
6b9fb77772 | ||
|
|
2af0d9cf6c | ||
|
|
75ce885684 | ||
|
|
3e56b8d71d | ||
|
|
2f72ae9afd | ||
|
|
ca877ba223 | ||
|
|
a908bc7e92 | ||
|
|
091899d0df | ||
|
|
ec3302d1c1 | ||
|
|
1466df91bd | ||
|
|
ee1aa10328 | ||
|
|
dcd0509089 | ||
|
|
613d36a139 | ||
|
|
7a61d6fcd5 | ||
|
|
5154102468 | ||
|
|
d99d16423a | ||
|
|
000b5b72d5 | ||
|
|
f483d9ff13 | ||
|
|
c1c3757943 | ||
|
|
3fcf308ed8 | ||
|
|
145a42eb30 | ||
|
|
fa35fbdb8e | ||
|
|
8e17c722fb | ||
|
|
ffd3e90806 | ||
|
|
7f70e62c30 | ||
|
|
44247f63d5 | ||
|
|
bb3cee8ef5 | ||
|
|
fc25d83a9e | ||
|
|
f48b03c6ec | ||
|
|
442fe9a833 | ||
|
|
086fea7cf0 | ||
|
|
256d267a3b | ||
|
|
0fdc1bc497 | ||
|
|
71293bcf73 | ||
|
|
f1e36b09f9 | ||
|
|
743970a8c6 | ||
|
|
03d8a7a6af | ||
|
|
31b44534df | ||
|
|
df7e6b7a79 | ||
|
|
eb143ba742 | ||
|
|
22c05d4b8f | ||
|
|
7e8f9f10f3 | ||
|
|
5263386bb2 | ||
|
|
72d08902fd | ||
|
|
23d35c6cca | ||
|
|
da8fcde4a8 | ||
|
|
6eea0a2299 | ||
|
|
9e63c14cef | ||
|
|
21b25ce96c | ||
|
|
7cad642a11 | ||
|
|
5237e58f29 | ||
|
|
e67165d6ce | ||
|
|
5a4a77f5d2 | ||
|
|
96b82624cd | ||
|
|
fc8fadf455 | ||
|
|
518800cd2f | ||
|
|
0f2f53cbd0 | ||
|
|
19e03ccdde | ||
|
|
7fea5a5ca2 | ||
|
|
cb693e05bf | ||
|
|
aa998219b1 | ||
|
|
4d65cb907f | ||
|
|
c2dea5245d | ||
|
|
07374b5dbc | ||
|
|
25279321ac | ||
|
|
0474d0c4e8 | ||
|
|
ec9b2f0567 | ||
|
|
6c27efeeea | ||
|
|
ffca80daa5 | ||
|
|
2c0466d637 | ||
|
|
a91ed14aec | ||
|
|
96f9fc3484 | ||
|
|
e01e3c0a62 | ||
|
|
4777b060ba | ||
|
|
ed099bcd85 | ||
|
|
d81fd25325 | ||
|
|
2eec0f704c | ||
|
|
b4245e9353 | ||
|
|
323a3dd573 | ||
|
|
56a5ddae8f | ||
|
|
a3d8bb8d21 | ||
|
|
18fe191929 | ||
|
|
f4d355a0e4 | ||
|
|
0ef004ce48 | ||
|
|
47cae808c5 | ||
|
|
1689d6a9fc | ||
|
|
12ae84401a | ||
|
|
8090caa026 | ||
|
|
ff87eedd96 | ||
|
|
57f94b3ac2 | ||
|
|
3b65364828 | ||
|
|
b5db1e9e1f | ||
|
|
79c23fc6c6 | ||
|
|
31718d2066 | ||
|
|
cd499d4955 | ||
|
|
4484863baa | ||
|
|
ddcd8a03ee | ||
|
|
ce50e23536 | ||
|
|
3c904cbc5f | ||
|
|
06ddc80292 | ||
|
|
7da61621cc | ||
|
|
075fe3f668 | ||
|
|
b2242d3cce | ||
|
|
c55628a55d | ||
|
|
3a5869e525 | ||
|
|
bb51e3147c | ||
|
|
5415e2ca82 | ||
|
|
e6885af96b | ||
|
|
6d86bfe2e3 | ||
|
|
70059d1ec0 | ||
|
|
d2da518e02 | ||
|
|
d119e3a8e8 | ||
|
|
577be4f9ad | ||
|
|
8dd9ab6475 | ||
|
|
ee8e96dbcf | ||
|
|
faaad3ae8e | ||
|
|
c5f2ef454f | ||
|
|
7224dcce26 | ||
|
|
f758ee4adc | ||
|
|
26d7900590 | ||
|
|
2d1db2e403 | ||
|
|
4111d4ee1d | ||
|
|
d8be59b1ba | ||
|
|
c38696157c | ||
|
|
dab31bc36d | ||
|
|
d790a1d3a6 | ||
|
|
298960bfc6 | ||
|
|
b8437f7f22 | ||
|
|
db49e6d830 | ||
|
|
7853b779bd | ||
|
|
23cdd82de1 | ||
|
|
5c85074f54 | ||
|
|
8d20da91d1 | ||
|
|
0b046647e7 | ||
|
|
9231dbbb2f | ||
|
|
0dbd10893e | ||
|
|
f2a85fd134 | ||
|
|
7a8ece76ea | ||
|
|
8b720ffd4f | ||
|
|
6e3ea35dda | ||
|
|
a4f8920d97 | ||
|
|
7b8e15c5b8 | ||
|
|
464c09ed10 | ||
|
|
bfb8837c54 | ||
|
|
7b26a4c2eb | ||
|
|
8c5276c5e1 | ||
|
|
2516cdafcb | ||
|
|
fcee9ad778 | ||
|
|
5b0c41a3f7 | ||
|
|
f5e69f2602 | ||
|
|
d69d2c374f | ||
|
|
524118e108 | ||
|
|
b64231ce2e | ||
|
|
1a6358ec70 | ||
|
|
0b2dbcf30c | ||
|
|
3b5667c007 | ||
|
|
e490d5044b | ||
|
|
87f0247f2b | ||
|
|
91875fdf8d | ||
|
|
3dc3b2b64e | ||
|
|
eb70060798 | ||
|
|
2edbbb6052 | ||
|
|
c2e9a91a94 | ||
|
|
81ef8655c2 | ||
|
|
def64ae864 | ||
|
|
379f12daee | ||
|
|
cee55a1518 | ||
|
|
1560619fe0 | ||
|
|
5b20935235 | ||
|
|
4eb23df627 | ||
|
|
253fbc2a70 | ||
|
|
ea2763432d | ||
|
|
03a64b67d9 | ||
|
|
2309bea1a0 | ||
|
|
3c14621ae0 | ||
|
|
27474eceee | ||
|
|
36a9893558 | ||
|
|
d37f2ada65 | ||
|
|
c0647cf93b | ||
|
|
95d6bbe7ad | ||
|
|
aeae6d4a10 | ||
|
|
0914fd695d | ||
|
|
a5a6a3bc9c | ||
|
|
56542c805a | ||
|
|
6041574209 | ||
|
|
0b14d1fe34 | ||
|
|
6572dc286a | ||
|
|
223737cbe2 | ||
|
|
b00df01817 | ||
|
|
ce8ce10ef1 | ||
|
|
97b922f9f1 | ||
|
|
92f0175e91 | ||
|
|
66c7815369 | ||
|
|
f6b6c93c10 | ||
|
|
8393c1b4f6 | ||
|
|
20b835a53b | ||
|
|
1fb26b5989 | ||
|
|
59144e03bc | ||
|
|
0feedd183b | ||
|
|
402c7df643 | ||
|
|
526fc1778f | ||
|
|
aa76fc5d7c | ||
|
|
a769c71642 | ||
|
|
3ea1283613 | ||
|
|
6fc0a53bae | ||
|
|
c6b661526d | ||
|
|
0c8145e924 | ||
|
|
033e826242 | ||
|
|
783d51e8cc | ||
|
|
aff6452075 | ||
|
|
33f4791698 | ||
|
|
4027b82714 | ||
|
|
5c112daa1e | ||
|
|
a00814d849 | ||
|
|
3cee89d827 | ||
|
|
2447042060 | ||
|
|
319e220efe | ||
|
|
8eef42d075 | ||
|
|
002b4fb048 | ||
|
|
4167609e41 | ||
|
|
6cb6a52ded | ||
|
|
0d8f6b05e3 | ||
|
|
072ec9b7ae | ||
|
|
aba1f34de0 | ||
|
|
31d14df37b | ||
|
|
b433852f8a | ||
|
|
fe5f16cb18 | ||
|
|
fe85dc10cc | ||
|
|
445aef7d17 | ||
|
|
f319857939 | ||
|
|
517310182e | ||
|
|
3ff2871f24 | ||
|
|
72835f9a58 | ||
|
|
dd4b83906d | ||
|
|
41fbb916a0 | ||
|
|
04b967bd6d | ||
|
|
ed3444de5a | ||
|
|
ef09cffa58 | ||
|
|
49581e7408 | ||
|
|
fc2edfbded | ||
|
|
13765e7557 | ||
|
|
4befa15198 | ||
|
|
26a9646407 | ||
|
|
a9e52833fe | ||
|
|
65ed936ff3 | ||
|
|
ef32622166 | ||
|
|
e2667ab098 | ||
|
|
0a38389bc3 | ||
|
|
953f089c06 | ||
|
|
87785a2886 | ||
|
|
4cd150ba7a | ||
|
|
bec9e48435 | ||
|
|
0ef7594536 | ||
|
|
09dd2f851d | ||
|
|
b5d24f5971 | ||
|
|
af3067ee23 | ||
|
|
5f2c24a199 | ||
|
|
fd7d5bdae1 | ||
|
|
ebb4a3d053 | ||
|
|
c09ea94133 | ||
|
|
9e8c70e6b4 | ||
|
|
008b296014 | ||
|
|
413b119ec6 | ||
|
|
7a8d1931ed | ||
|
|
5ba6446451 | ||
|
|
708e4fa2be | ||
|
|
e0fb31f81e | ||
|
|
a084feba96 | ||
|
|
10fe8580d5 | ||
|
|
b5a21855f6 | ||
|
|
c99598e22e | ||
|
|
be85ecba1b | ||
|
|
c069a1787d | ||
|
|
67a80127e3 | ||
|
|
56efe5e82c | ||
|
|
8e5692d8a3 | ||
|
|
b1c1bca2b8 | ||
|
|
4dbc72b301 | ||
|
|
b9a47d85db | ||
|
|
7dfe36fdce | ||
|
|
e85eb90ec7 | ||
|
|
9f755ad65a | ||
|
|
bd87a7e612 | ||
|
|
e68f149d3a | ||
|
|
20fc9c7b18 | ||
|
|
f08293efec | ||
|
|
6219a9e6f0 | ||
|
|
b4c82c0f1a | ||
|
|
7362f2e5fb | ||
|
|
62715787c1 | ||
|
|
b1e1c65774 | ||
|
|
1975ae4486 | ||
|
|
c6efc403cd | ||
|
|
e57de4311c | ||
|
|
999a72a16a | ||
|
|
d8a56e9943 | ||
|
|
c6e6859090 | ||
|
|
ad8c05426e | ||
|
|
0e888cc86b | ||
|
|
f5a3227349 | ||
|
|
7f644785ce | ||
|
|
3bb18d0baf | ||
|
|
831b1d3a79 | ||
|
|
bef4c010c6 | ||
|
|
ac124bdc7e | ||
|
|
9c0c39381f | ||
|
|
b1ff4daaf5 | ||
|
|
cb05f8a67a | ||
|
|
8d5566e783 | ||
|
|
417066d188 | ||
|
|
e4d79c6246 | ||
|
|
a3a57e20f1 | ||
|
|
a8dcf70459 | ||
|
|
a30599570c | ||
|
|
e107d3ca84 | ||
|
|
77cf0afa1a | ||
|
|
56f597f5ad | ||
|
|
793db90a14 | ||
|
|
e4a0d2ab0b | ||
|
|
9a4c7766e3 | ||
|
|
f1ac0376fb | ||
|
|
65c33e6b39 | ||
|
|
39e10c4ab0 | ||
|
|
38bdc99172 | ||
|
|
078b22d985 | ||
|
|
0205341cb5 | ||
|
|
fa34ebea94 | ||
|
|
39e68a9ce7 | ||
|
|
19533551f4 | ||
|
|
f3b270c927 | ||
|
|
330216ff0d | ||
|
|
86bad84504 | ||
|
|
24e0c4034d | ||
|
|
17eb983c40 | ||
|
|
ac8f3d7f96 | ||
|
|
0d42faac2e | ||
|
|
7cf7a967da | ||
|
|
ee187065c6 | ||
|
|
ac8a0b7b3d | ||
|
|
567d6c5102 | ||
|
|
20cdf744fd | ||
|
|
9f62ec5192 | ||
|
|
c4b3080eae | ||
|
|
cfb06cf247 | ||
|
|
53238ba94f | ||
|
|
1da71fee2b | ||
|
|
a6e78c2eea | ||
|
|
f2f47d6d88 | ||
|
|
8d470b92db | ||
|
|
6024c4a077 | ||
|
|
b0ec75d539 | ||
|
|
cd7845124c | ||
|
|
2e844a58fb | ||
|
|
5ca8641488 | ||
|
|
9cf529215f | ||
|
|
32447b8204 | ||
|
|
be819eb876 | ||
|
|
af817b8134 | ||
|
|
829dbbe12b | ||
|
|
9c27d66add | ||
|
|
47772f4e77 | ||
|
|
563184920a | ||
|
|
77367b5517 | ||
|
|
2b8a4a9b5f | ||
|
|
7e8a830f42 | ||
|
|
f496995415 | ||
|
|
1f19175fef | ||
|
|
314c882f3b | ||
|
|
adbe856c4e | ||
|
|
8de272a8a1 | ||
|
|
f8f61f0b81 | ||
|
|
053242d5bd | ||
|
|
56a073955a | ||
|
|
5c6056e76b | ||
|
|
bf38dea95f | ||
|
|
ae9ff767fa | ||
|
|
8027f5aafd | ||
|
|
cad6956935 | ||
|
|
d038ad2350 | ||
|
|
b6f9c0844e | ||
|
|
f67cfc48e0 | ||
|
|
56345f4354 | ||
|
|
b51c59d20d | ||
|
|
decd9343ee | ||
|
|
950bf682c3 | ||
|
|
1f4d20413e | ||
|
|
11a6db32ae | ||
|
|
805fc807a9 | ||
|
|
b4fb74c84d | ||
|
|
ac351bce4b | ||
|
|
208112e7a9 | ||
|
|
616bae1f84 | ||
|
|
573eb25d5b | ||
|
|
8267482ee9 | ||
|
|
a1c378c16f | ||
|
|
e6bebbfe81 | ||
|
|
9358a122cd | ||
|
|
3bd023d640 | ||
|
|
6702506f58 | ||
|
|
913ce9454e | ||
|
|
24f5fb7b44 | ||
|
|
6aaca38ce3 | ||
|
|
fbdb3a1f48 | ||
|
|
d2e039ad4e | ||
|
|
d6abbce4ec | ||
|
|
194d060f13 | ||
|
|
f316229f9e | ||
|
|
a15e5fdc4e | ||
|
|
82c3da5b1e | ||
|
|
76d8e8fec9 | ||
|
|
702adda000 | ||
|
|
2bc3c146a6 | ||
|
|
ba05608ce6 | ||
|
|
4e9a2a327f | ||
|
|
06e0effbf7 | ||
|
|
a3eafe5b18 | ||
|
|
50d6971d20 | ||
|
|
7e368c16b7 | ||
|
|
edac82f6e3 | ||
|
|
3c9f520e68 | ||
|
|
6603005822 | ||
|
|
15eb5d9827 | ||
|
|
7801dd5353 | ||
|
|
c1431105f4 | ||
|
|
6f93210b9b | ||
|
|
8aae46a25e | ||
|
|
99ed1c34f3 | ||
|
|
f07a6eb199 | ||
|
|
5fead1d17a | ||
|
|
8b9e02fd44 | ||
|
|
f23dc07914 | ||
|
|
5611e9168e | ||
|
|
b1149fe950 | ||
|
|
70b401e610 | ||
|
|
58c198a58b | ||
|
|
f2decc852c | ||
|
|
ee19c32c3a | ||
|
|
74b658fabf | ||
|
|
24825a16e0 | ||
|
|
083b571641 | ||
|
|
e37d4a6f7c | ||
|
|
9cb3dad079 | ||
|
|
700c14d5b3 | ||
|
|
c0148c7266 | ||
|
|
2d83069b82 | ||
|
|
2ec02e477f | ||
|
|
42ae954c16 | ||
|
|
e3a55d31d3 | ||
|
|
767925a481 | ||
|
|
e970578c47 | ||
|
|
723993fdf6 | ||
|
|
4335318482 | ||
|
|
17c715fde0 | ||
|
|
c76be620a8 | ||
|
|
2ce276df5b | ||
|
|
e1bad10e6c | ||
|
|
93f2a8ad6b | ||
|
|
81acefa8ad | ||
|
|
800810d23d | ||
|
|
a1124449c2 | ||
|
|
6342e78305 | ||
|
|
d2e04750b5 | ||
|
|
7ec0354a79 | ||
|
|
9a5b476d9c | ||
|
|
7ee08c9964 | ||
|
|
b666d9d5c1 | ||
|
|
bdf4e51da3 | ||
|
|
397de1274f | ||
|
|
4c39cf2d65 | ||
|
|
260fc6a7ce | ||
|
|
c031e345db | ||
|
|
d7fdab99cb | ||
|
|
35d814c7b3 | ||
|
|
91c6475f1c | ||
|
|
735631899f | ||
|
|
868a7eb5ca | ||
|
|
8c94396ad9 | ||
|
|
47b08eaa41 | ||
|
|
9b8f685c82 | ||
|
|
c5b3673a30 | ||
|
|
9538a9870c | ||
|
|
6b1aac4aee | ||
|
|
066442c236 | ||
|
|
52dd7665e7 | ||
|
|
79d0708ea7 | ||
|
|
7b3d85c74e | ||
|
|
733528a9f5 | ||
|
|
49d363b174 | ||
|
|
c37a56ec89 | ||
|
|
1592229b24 | ||
|
|
36726b0f7b | ||
|
|
3fad5fb9b9 | ||
|
|
cffb59ae73 | ||
|
|
9563af9ae1 | ||
|
|
e68d9af498 | ||
|
|
04127019f9 | ||
|
|
81fd3c86f4 | ||
|
|
30c3ff2efe | ||
|
|
5f12b0db3f | ||
|
|
b9d95711a2 | ||
|
|
86ade52dc6 | ||
|
|
74def423ed | ||
|
|
061d2e45a9 | ||
|
|
df6780bf1a | ||
|
|
6fc0ea02a1 | ||
|
|
81281d88c7 | ||
|
|
3dcdde83c7 | ||
|
|
27381ae77b | ||
|
|
63fe68bf28 | ||
|
|
6fbdcc3dd1 | ||
|
|
8397b1ee4b | ||
|
|
a5e4256491 | ||
|
|
eb2f986b11 | ||
|
|
5bae06ae96 | ||
|
|
2712bc8661 | ||
|
|
290f979fd3 | ||
|
|
a22ea1de86 | ||
|
|
13859dfb3c | ||
|
|
3078c4a451 | ||
|
|
73f72bda42 | ||
|
|
aec400c12d | ||
|
|
0874cbc268 | ||
|
|
048608556c | ||
|
|
66976c13e4 | ||
|
|
397d806f0f | ||
|
|
124ec4d3c2 | ||
|
|
61e01aff67 | ||
|
|
560c2511cc | ||
|
|
f582fdbf71 | ||
|
|
ffa6280431 | ||
|
|
565542879e | ||
|
|
b33f06701c | ||
|
|
1811bb7643 | ||
|
|
94ea72948e | ||
|
|
cfda569af1 | ||
|
|
4b72ac7d22 | ||
|
|
235e3adbcb | ||
|
|
c57e452efd | ||
|
|
4dfbacfd92 | ||
|
|
d82a0a9455 | ||
|
|
c2ed02d4e4 | ||
|
|
b10b205394 | ||
|
|
485166b668 | ||
|
|
153ad99f85 | ||
|
|
2d07b346de | ||
|
|
56732df641 | ||
|
|
8c46e06e04 | ||
|
|
b0d6751777 | ||
|
|
5d46e1a7b1 | ||
|
|
1cdf439e38 | ||
|
|
4c374e182d | ||
|
|
4c7175fad1 | ||
|
|
39b0e3522a | ||
|
|
f220960d91 | ||
|
|
7ef187b1eb | ||
|
|
d90ec49241 | ||
|
|
2517369270 | ||
|
|
d9ac198bad | ||
|
|
cf09f725ca | ||
|
|
4f43f655cf | ||
|
|
a37ffe6e2a | ||
|
|
dd0bcf4dbd | ||
|
|
1d4c835843 | ||
|
|
61661a159d | ||
|
|
1e297a0e5f | ||
|
|
9ef7f05712 | ||
|
|
10257bbb6e | ||
|
|
bd9e5e97d7 | ||
|
|
bf8b730259 | ||
|
|
cecea63627 | ||
|
|
d6888a32ae | ||
|
|
1a6a16be5b | ||
|
|
dced32088c | ||
|
|
bb7fdd59dc | ||
|
|
c2a1dbeb67 | ||
|
|
8a2cb96c2a | ||
|
|
7aa3dbcd86 | ||
|
|
570f574758 | ||
|
|
fbf5529ddd | ||
|
|
0ea22f9796 | ||
|
|
bdcb2c1512 | ||
|
|
ddb497acb5 | ||
|
|
539d1a4e42 | ||
|
|
13995a64b8 | ||
|
|
ed7315d78e | ||
|
|
9541cb226d | ||
|
|
dd910bb4cc | ||
|
|
2f3ac06eff | ||
|
|
e476dff842 | ||
|
|
60108590b0 | ||
|
|
f7ff61be5d | ||
|
|
a15e2ecf78 | ||
|
|
4ba43a538e | ||
|
|
d638f3e033 | ||
|
|
792a1a7ab7 | ||
|
|
ced0c94e8e | ||
|
|
0e356dc2e3 | ||
|
|
99a5886f3a | ||
|
|
e5e164c536 | ||
|
|
bde15cb64b | ||
|
|
ae3453c84b | ||
|
|
a0c8ac524b | ||
|
|
30e0285ae7 | ||
|
|
2f635a1021 | ||
|
|
9a253c4f54 | ||
|
|
68144c5be2 | ||
|
|
7df9d951c6 | ||
|
|
db00860662 | ||
|
|
a85a0aef52 | ||
|
|
3e87059939 | ||
|
|
96aa37eff5 | ||
|
|
a8b538cc67 | ||
|
|
60d2f2d304 | ||
|
|
8b2a971019 | ||
|
|
9478ccca27 | ||
|
|
9198bc1b8f | ||
|
|
7b3c10769b | ||
|
|
a2afabab79 | ||
|
|
5e5487faae | ||
|
|
2b72aab671 | ||
|
|
b7f45e6963 | ||
|
|
cbea4493c1 | ||
|
|
371efce88a | ||
|
|
f133b96cb4 | ||
|
|
853090729a | ||
|
|
3677609838 | ||
|
|
8e14249335 | ||
|
|
e088a596c7 | ||
|
|
021aa63e24 | ||
|
|
7100d61f3c | ||
|
|
3584d2bf23 | ||
|
|
60e96633c4 | ||
|
|
cfbdfcf515 | ||
|
|
d58171987c | ||
|
|
d54d0c25a2 | ||
|
|
15dfc08a31 | ||
|
|
e2682d2690 | ||
|
|
ad511755ec | ||
|
|
d790c60a93 | ||
|
|
f53ea90113 | ||
|
|
a4652e0d5f | ||
|
|
da16cc1727 | ||
|
|
a8fe1e24b3 | ||
|
|
a24efc6e34 | ||
|
|
61f6daf55e | ||
|
|
ddc3842eb7 | ||
|
|
ad7a52e2cb | ||
|
|
589f1246cb | ||
|
|
cbb3bf6f6a | ||
|
|
3ce05fc0dc | ||
|
|
7388b051ed | ||
|
|
e4c4f1a0c2 | ||
|
|
d4f2ea3358 | ||
|
|
d490b2a3c6 | ||
|
|
8915df90ae | ||
|
|
0ac09df5d9 | ||
|
|
0f0f8bd64b | ||
|
|
3eb0d5fd73 | ||
|
|
6f77a11522 | ||
|
|
3f2603a34d | ||
|
|
48fc867b7b | ||
|
|
4674b5a207 | ||
|
|
c2b8b8469d | ||
|
|
50daec2972 | ||
|
|
63defa40cb | ||
|
|
f7830fddeb | ||
|
|
f65d50b7e4 | ||
|
|
dcd47223fa | ||
|
|
d6531189f9 | ||
|
|
b49adbd74c | ||
|
|
4c76786ce4 | ||
|
|
7cff0ba626 | ||
|
|
94fb7e11b4 | ||
|
|
74ac28fc70 | ||
|
|
d0323dea65 | ||
|
|
519e115eb2 | ||
|
|
1a4d7ad937 | ||
|
|
c2f19036f3 | ||
|
|
969f01fca3 | ||
|
|
d6e1ca0f10 | ||
|
|
32d09acba9 | ||
|
|
59492cf08e | ||
|
|
9902473321 | ||
|
|
ba96d37c11 | ||
|
|
1352dc79bb | ||
|
|
548cdb55d7 | ||
|
|
b2afda0462 | ||
|
|
866bca70f9 | ||
|
|
d3911cd7d8 | ||
|
|
387fb1b202 | ||
|
|
0ef15e6364 | ||
|
|
6e95eebfa4 | ||
|
|
a4175553fc | ||
|
|
b8d7bb2c08 | ||
|
|
2504f0fc0c | ||
|
|
732c98b72f | ||
|
|
6342e9a3e2 | ||
|
|
8fe0bf4ba3 | ||
|
|
58b8af0fa8 | ||
|
|
870254b710 | ||
|
|
6513185cb7 | ||
|
|
57f3048d27 | ||
|
|
fa5eae08a0 | ||
|
|
4561e3a389 | ||
|
|
21423676c9 | ||
|
|
531112e364 | ||
|
|
4d40c84a31 | ||
|
|
fc54565d95 | ||
|
|
37323480dd | ||
|
|
29bc300282 | ||
|
|
21452b4c6e | ||
|
|
c98e1454fc | ||
|
|
1fc2eddf6f | ||
|
|
024c268fe0 | ||
|
|
85c8296548 | ||
|
|
b15716f65a | ||
|
|
a6f5130db1 | ||
|
|
37b9402214 | ||
|
|
390294a92b | ||
|
|
a6bb44b421 | ||
|
|
d3ebcd85b4 | ||
|
|
73d5aa35a1 | ||
|
|
39d3f4580e | ||
|
|
40391252d5 | ||
|
|
1f62cecdfd | ||
|
|
e5f138d004 | ||
|
|
23ccc2a8c5 | ||
|
|
fad53f6fe4 | ||
|
|
7f9066c05a | ||
|
|
eb17284711 | ||
|
|
e159fc2779 | ||
|
|
747595082d | ||
|
|
beffc016da | ||
|
|
f9ec45eda7 | ||
|
|
1bc0b40a2e | ||
|
|
f36962fc58 | ||
|
|
36dcf8b2a5 | ||
|
|
e317ab1479 | ||
|
|
ea04f55d12 | ||
|
|
cac6b65dee | ||
|
|
c6a292f6a9 | ||
|
|
e6825476e7 | ||
|
|
54144f72a5 | ||
|
|
debe868950 | ||
|
|
773cc80ed4 | ||
|
|
3fd5a0f100 | ||
|
|
83f5c03494 | ||
|
|
eb0fa4d26d | ||
|
|
4f4bd98943 | ||
|
|
34edbed00b | ||
|
|
84773a3a32 | ||
|
|
08529964b4 | ||
|
|
2c011bb9ad | ||
|
|
ee9ce0dee8 | ||
|
|
3b521b74ea | ||
|
|
1d3d09f48c | ||
|
|
e8ef6f1afb | ||
|
|
a374d0b3e7 | ||
|
|
de2843d9f1 | ||
|
|
ae0be7f6ce | ||
|
|
7860673855 | ||
|
|
fd72132743 | ||
|
|
1eb9f5b803 | ||
|
|
b3e6b304e4 | ||
|
|
c8c54a42e2 | ||
|
|
72f4fd08ee | ||
|
|
4defb104db | ||
|
|
48cafc8e57 | ||
|
|
8adf8aed41 | ||
|
|
341dc4be7a | ||
|
|
0f2ed28ab7 | ||
|
|
c2282eaf08 | ||
|
|
38f35acffe | ||
|
|
25f979a825 | ||
|
|
afa14e6cd4 | ||
|
|
802507d321 | ||
|
|
12ff8e6279 | ||
|
|
219cf6bc57 | ||
|
|
a7ec72b25c | ||
|
|
4bd3d4d36c | ||
|
|
d425270c88 | ||
|
|
f8c8c1cc2d | ||
|
|
cc02c411b6 | ||
|
|
65dd7d4851 | ||
|
|
47b80cbc2b | ||
|
|
20d2317f61 | ||
|
|
d2a4b8bfd8 | ||
|
|
2b26d859c3 | ||
|
|
3516cda350 | ||
|
|
4d51d0d351 | ||
|
|
303b7a117c | ||
|
|
83006a3ed1 | ||
|
|
b3e9fa2142 | ||
|
|
45d7a398b6 | ||
|
|
ffb88c30b9 | ||
|
|
40fd9a8008 | ||
|
|
eb357a2805 | ||
|
|
fd2cadd431 | ||
|
|
e66bbe4eec | ||
|
|
f23a49a25e | ||
|
|
1c749c01cd | ||
|
|
f95ff68c10 | ||
|
|
5815a92273 | ||
|
|
bb8d84e65e | ||
|
|
c28020a1bd | ||
|
|
95b0ec70cb | ||
|
|
1c0f2e1fba | ||
|
|
83338675f9 | ||
|
|
a6f808702f | ||
|
|
8e71060931 | ||
|
|
659112842e | ||
|
|
a8636e4f59 | ||
|
|
45febbabd7 | ||
|
|
cdc62e7327 | ||
|
|
4499e974a0 | ||
|
|
2017edca88 | ||
|
|
894771029c | ||
|
|
bdf909e94e | ||
|
|
63323a2611 | ||
|
|
36393e447e | ||
|
|
fe01206a73 | ||
|
|
e766bb0cd7 | ||
|
|
53e3222935 | ||
|
|
47353fbceb | ||
|
|
a23c2f2166 | ||
|
|
07db5941aa | ||
|
|
dd9e1814b0 | ||
|
|
0e262de96f | ||
|
|
6b7fed7f59 | ||
|
|
7ba2ed6f3f | ||
|
|
5e34325604 | ||
|
|
9bb60405e7 | ||
|
|
be3d2422a7 | ||
|
|
bd9d9c3cc7 | ||
|
|
1218c0f518 | ||
|
|
7105e3fb69 | ||
|
|
ae985c00e9 | ||
|
|
803c09a4ae | ||
|
|
1b288682e8 | ||
|
|
7d14833856 | ||
|
|
2c6054d51a | ||
|
|
464e1929db | ||
|
|
33d0e9559a | ||
|
|
20af546b26 | ||
|
|
7e99a1f4b0 | ||
|
|
2adde497c8 | ||
|
|
97d6a35edc | ||
|
|
5c8cb1e7ec | ||
|
|
d1217dfedd | ||
|
|
4ddb3c7a6c | ||
|
|
3ed7627ec3 | ||
|
|
1b5d5c5be8 | ||
|
|
4946a13d45 | ||
|
|
578783af8e | ||
|
|
223d560b32 | ||
|
|
71e8941285 | ||
|
|
7de0ca164d | ||
|
|
0bafa347a5 | ||
|
|
2cfdcc1af4 | ||
|
|
678d261f40 | ||
|
|
c00bf7df6a | ||
|
|
047b278791 | ||
|
|
aaf5f923b0 | ||
|
|
fdfa7bc963 | ||
|
|
3ab978ab46 | ||
|
|
4da3d43013 | ||
|
|
51709f032f | ||
|
|
67d8223f73 | ||
|
|
da07f84e44 | ||
|
|
23a73c9cdb | ||
|
|
7b197cbe1c | ||
|
|
99cd29d88f | ||
|
|
0cf4c2ded5 | ||
|
|
271a982cb4 | ||
|
|
f22b9e297b | ||
|
|
61d0863239 | ||
|
|
803cc7c653 | ||
|
|
b4341df44c | ||
|
|
4a77a8b840 | ||
|
|
680117e97e | ||
|
|
4bab80faaa | ||
|
|
74a1707e3f | ||
|
|
f145331ae5 | ||
|
|
be5df6b2b7 | ||
|
|
14a5ff2b54 | ||
|
|
f585ddea71 | ||
|
|
958ba61f66 | ||
|
|
f7b18159a6 | ||
|
|
ae759481e1 | ||
|
|
c2bafcf83b | ||
|
|
04b5e1b3a0 | ||
|
|
3207a9e642 | ||
|
|
67e8286a57 | ||
|
|
7c0c41f3de | ||
|
|
c5da02dbd0 | ||
|
|
ee1ae88ecd | ||
|
|
e7b8c257a3 | ||
|
|
93078bcf0c | ||
|
|
566072a17f | ||
|
|
31191096ad | ||
|
|
0975222e3a | ||
|
|
576279fa92 | ||
|
|
21eec60924 | ||
|
|
e98b68c38f | ||
|
|
155f6afb48 | ||
|
|
1e45195ef9 | ||
|
|
3a2e4ff5e5 | ||
|
|
b2a0d6559f | ||
|
|
22123dd955 | ||
|
|
376ab0e346 | ||
|
|
da0322e994 | ||
|
|
479b44c610 | ||
|
|
a34e8c99cd | ||
|
|
b8e1758dcf | ||
|
|
2660907ac8 | ||
|
|
9f8c08fd18 | ||
|
|
47ac67f7a2 | ||
|
|
0cf9915698 | ||
|
|
dd1027f165 | ||
|
|
bd521d9089 | ||
|
|
ebce5d2635 | ||
|
|
277ba9cb79 | ||
|
|
86389b46e2 | ||
|
|
bf20ecca60 | ||
|
|
8c960620cc | ||
|
|
7be1c885cd | ||
|
|
3957df6511 | ||
|
|
a9829f5f7b | ||
|
|
9db3fa1b65 | ||
|
|
93d1716eb5 | ||
|
|
4f59d580c4 | ||
|
|
d1b81b96a5 | ||
|
|
9f7bfc0e36 | ||
|
|
eb7932ed73 | ||
|
|
e8e8d233ab | ||
|
|
f2f509234b | ||
|
|
e90a3b5a56 | ||
|
|
c36fa5bdb6 | ||
|
|
f7b9221324 | ||
|
|
4e7794bfc3 | ||
|
|
71ad323cb6 | ||
|
|
32ea37035e | ||
|
|
81dceceb4f | ||
|
|
d02a85df50 | ||
|
|
000af2bc96 | ||
|
|
cfdc554a19 | ||
|
|
6e744bcaa7 | ||
|
|
ee501e884a | ||
|
|
edfcaee99b | ||
|
|
31ec3af164 | ||
|
|
70053fea8e | ||
|
|
ac9232d888 | ||
|
|
1a22e3cb61 | ||
|
|
57d14f695f | ||
|
|
1415472674 | ||
|
|
dca2cfd009 | ||
|
|
5fb5b7b30e | ||
|
|
a632d5f313 | ||
|
|
4136ac26c4 | ||
|
|
a67b548f44 | ||
|
|
ef44528ba5 | ||
|
|
6c49d5dc7d | ||
|
|
95baf953a8 | ||
|
|
c86e316670 | ||
|
|
7e584dd84a | ||
|
|
cb4e6ad825 | ||
|
|
4584893542 | ||
|
|
6b0fe32180 | ||
|
|
e08fe10f31 | ||
|
|
99ddc36c26 | ||
|
|
26854d8d42 | ||
|
|
1c5e36017c | ||
|
|
611f7d07c2 | ||
|
|
481deee4b2 | ||
|
|
116ea657cd | ||
|
|
e9befff0cb | ||
|
|
1770d92ea6 | ||
|
|
a64b5f2c5d | ||
|
|
812d7de7f7 | ||
|
|
21d0ee8db1 | ||
|
|
14818b8311 | ||
|
|
c9dbd58e7f | ||
|
|
e63ff83f24 | ||
|
|
d3e269a3d3 | ||
|
|
b33a805702 | ||
|
|
efc4c900f3 | ||
|
|
e4df0a393a | ||
|
|
a3bd73b9db | ||
|
|
8db29b0a81 | ||
|
|
8cf31548f2 | ||
|
|
3a91f6793f | ||
|
|
ced2f33a63 | ||
|
|
954173d629 | ||
|
|
f3ea1863ae | ||
|
|
175fe9279c | ||
|
|
3b45485351 | ||
|
|
b755ca12ca | ||
|
|
0687e683f8 | ||
|
|
7e08154217 | ||
|
|
a734f90920 | ||
|
|
27091e5168 | ||
|
|
3f8af9b3c9 | ||
|
|
e8d1e9d946 | ||
|
|
6d64a6a1d1 | ||
|
|
2f2e16fc33 | ||
|
|
16f09141da | ||
|
|
036f64013d | ||
|
|
51cfd6119f | ||
|
|
b719a6c767 | ||
|
|
6fde0a6261 | ||
|
|
671726a3e4 | ||
|
|
f1b4fe12a2 | ||
|
|
4c4d8859f6 | ||
|
|
a3ba0a18c4 | ||
|
|
2e51e7fb78 | ||
|
|
6387aebed8 | ||
|
|
b478f0da89 | ||
|
|
6d41e35242 | ||
|
|
eb2119e292 | ||
|
|
c5df570262 | ||
|
|
12f8bb2937 | ||
|
|
7fd6278c18 | ||
|
|
c90f77af44 | ||
|
|
c29bcbfed8 | ||
|
|
a29710dc07 | ||
|
|
f45a5a63a7 | ||
|
|
82aa32f74e | ||
|
|
5793322c30 | ||
|
|
61d8308e81 | ||
|
|
b41c1858a3 | ||
|
|
4a7d606099 | ||
|
|
de8f8ef9f4 | ||
|
|
93a4b9d49f | ||
|
|
9160aeb30e | ||
|
|
ee2f8d8ebc | ||
|
|
2d312bcfe8 | ||
|
|
3ee11307f1 | ||
|
|
8e9365eb3b | ||
|
|
77893933a2 | ||
|
|
e565d2283e | ||
|
|
ff2f015d67 | ||
|
|
be0040ddc7 | ||
|
|
12ec997027 | ||
|
|
7767000ccf | ||
|
|
d54259c99c | ||
|
|
46b4cf3add | ||
|
|
42f2abf5ae | ||
|
|
dbbeca6308 | ||
|
|
06a7c85c93 | ||
|
|
7a1c4a5ded | ||
|
|
6cc802147b | ||
|
|
4d9412181c | ||
|
|
0d372a62a1 | ||
|
|
62a98a0504 | ||
|
|
135a433018 | ||
|
|
af6a1eb189 | ||
|
|
a8068b89ed | ||
|
|
75b2c2c83d | ||
|
|
d1cba1073f | ||
|
|
c176af4fe3 | ||
|
|
2d6b43fd54 | ||
|
|
20ce96e358 | ||
|
|
43e2495df8 | ||
|
|
0e4c37bb6d | ||
|
|
f3eb6c7078 | ||
|
|
35e53d28df | ||
|
|
6180e5eb53 | ||
|
|
c58887b44a | ||
|
|
c7fd855092 | ||
|
|
53e58f6678 | ||
|
|
5ea7a91fe1 | ||
|
|
200cefa0a5 | ||
|
|
f5d74b4572 | ||
|
|
d9998a977c | ||
|
|
b24ae5e9a2 | ||
|
|
54d96bb761 | ||
|
|
6423ce2fa7 | ||
|
|
6066fef1c9 | ||
|
|
b20f57321f | ||
|
|
33b6fd408f | ||
|
|
36f1e3572c | ||
|
|
4cc306d2d8 | ||
|
|
3e9a3c1185 | ||
|
|
d3f44a425c | ||
|
|
058be326f0 | ||
|
|
46b742cd4c | ||
|
|
95fdbe55f9 | ||
|
|
fc8647d1da | ||
|
|
f82f1da706 | ||
|
|
6e17ccf499 | ||
|
|
40bfd08866 | ||
|
|
89f6f0f46f | ||
|
|
838d245215 | ||
|
|
b565dd3da8 | ||
|
|
e1b6ec340c | ||
|
|
c38f884095 | ||
|
|
464560a949 | ||
|
|
f7f2e73f79 | ||
|
|
646688c291 | ||
|
|
dd4fd89ef8 | ||
|
|
f0dccc3cd7 | ||
|
|
91b1df49a4 | ||
|
|
7f6f39f5e7 | ||
|
|
cdcf3fa593 | ||
|
|
728c678cf9 | ||
|
|
e11ba21b42 | ||
|
|
1a4ecba742 | ||
|
|
4e19c7e8bd | ||
|
|
578b06e027 | ||
|
|
2c19c1fd06 | ||
|
|
3d65db2ac3 | ||
|
|
fabc26bb69 | ||
|
|
27226b1d82 | ||
|
|
0dc804f9b4 | ||
|
|
3192f3f011 | ||
|
|
3c6eb9a531 | ||
|
|
8dae178728 | ||
|
|
6f9cd8c261 | ||
|
|
d6189b8101 | ||
|
|
48f4a44fb5 | ||
|
|
f0332c4dc7 | ||
|
|
ec41f1b0f5 | ||
|
|
7e9647f3f0 | ||
|
|
fb9656b975 | ||
|
|
1b6fe8498d | ||
|
|
5eeebbde7f | ||
|
|
f7abf9c1da | ||
|
|
99406ccc15 | ||
|
|
1295d7aa30 | ||
|
|
5a680d5037 | ||
|
|
7528d42187 | ||
|
|
cbdc945287 | ||
|
|
faf0dcb102 | ||
|
|
5e02b4009e | ||
|
|
8125f9035c | ||
|
|
efa3973b77 | ||
|
|
71371b0ba5 | ||
|
|
543b6e51c0 | ||
|
|
3460a7efb5 | ||
|
|
e78c750b4e | ||
|
|
d82c92a237 | ||
|
|
826cf66af8 | ||
|
|
b49c679a50 | ||
|
|
5f05714e9d | ||
|
|
37cdae2f34 | ||
|
|
3b27f49d79 | ||
|
|
525b3960e1 | ||
|
|
04cdf88715 | ||
|
|
f8f626975f | ||
|
|
31c536e33f | ||
|
|
c1fef8269a | ||
|
|
e5ba0e6401 | ||
|
|
696ea68f86 | ||
|
|
71d00f5290 | ||
|
|
0fb37ad792 | ||
|
|
88069779b2 | ||
|
|
c5a4164a6b | ||
|
|
334e8ada30 | ||
|
|
ba8eadda52 | ||
|
|
297a2ea259 | ||
|
|
e129e1438e | ||
|
|
e810cd8440 | ||
|
|
50b3396064 | ||
|
|
9322095786 | ||
|
|
8a1b8259bd | ||
|
|
d905f78984 | ||
|
|
7250ee4429 | ||
|
|
2068299766 | ||
|
|
f5378b6573 | ||
|
|
c4e35f1284 | ||
|
|
6a9c0e22de | ||
|
|
809b29fe90 | ||
|
|
d68a04ad16 | ||
|
|
6954811c55 | ||
|
|
8cdc21c264 | ||
|
|
466668c6b8 | ||
|
|
e7e22c809e | ||
|
|
2606ca6fa9 | ||
|
|
54acaa2aec | ||
|
|
a030ea6fde | ||
|
|
3f8a0a4833 | ||
|
|
e5d25a7f04 | ||
|
|
40e86b6670 | ||
|
|
d0c9924c37 | ||
|
|
ede4faa152 | ||
|
|
05cf1dcab8 | ||
|
|
43a6dd5657 | ||
|
|
343ee9695b | ||
|
|
49e64f4e1c | ||
|
|
1e3db9f916 | ||
|
|
70ec977cb2 | ||
|
|
d6bbe43fa0 | ||
|
|
9e51701e2a | ||
|
|
035b3cb61e | ||
|
|
97c36d1edc | ||
|
|
7170a1bd78 | ||
|
|
936f13eb20 | ||
|
|
ed51db3217 | ||
|
|
5bacb67d36 | ||
|
|
c919b1de38 | ||
|
|
46ab5e8e46 | ||
|
|
3960c01798 | ||
|
|
3c5071cefc | ||
|
|
6e4b90055f | ||
|
|
37ee560eae | ||
|
|
4b3000b071 | ||
|
|
ad6cb177e3 | ||
|
|
d256365f4a | ||
|
|
05fea7f66f | ||
|
|
7535931571 | ||
|
|
27915c9ce2 | ||
|
|
93b131f48a | ||
|
|
10d5463a40 | ||
|
|
017cc9d9f9 | ||
|
|
b691de0147 | ||
|
|
beabbb1fa2 | ||
|
|
65c3020d1b | ||
|
|
f003b3c378 | ||
|
|
a268316322 | ||
|
|
090dabeea5 | ||
|
|
edba9efb5e | ||
|
|
ad205546c3 | ||
|
|
57bd1facf5 | ||
|
|
a378fee8e0 | ||
|
|
47ee9ce0e2 | ||
|
|
aea70c5ec1 | ||
|
|
7532ab01d6 | ||
|
|
0209f0fe29 | ||
|
|
51fd15e2af | ||
|
|
7b5f69bae8 | ||
|
|
11d198fcd6 | ||
|
|
ad11914fca | ||
|
|
fbac8b032e | ||
|
|
63d71ff90a | ||
|
|
04a2a52639 | ||
|
|
7dc8b74aa1 | ||
|
|
15047235cb | ||
|
|
020bdfb5bc | ||
|
|
3a85c38417 | ||
|
|
60ed4ada10 | ||
|
|
4b27bcd432 | ||
|
|
a2ae2c1a1a | ||
|
|
56e58ef301 | ||
|
|
7102036500 | ||
|
|
dfcbee9cc0 | ||
|
|
22dee50348 | ||
|
|
1ccc7365a7 | ||
|
|
a074d81754 | ||
|
|
c0149925ad | ||
|
|
d8d74236dd | ||
|
|
73bcfc4710 | ||
|
|
0c0f43f7f7 | ||
|
|
998f206da1 | ||
|
|
e7f6125df8 | ||
|
|
f00aeec9b4 | ||
|
|
83919119f8 | ||
|
|
9a79beda04 | ||
|
|
e6366e830c | ||
|
|
218c255543 | ||
|
|
167e9c5341 | ||
|
|
7cbd644782 | ||
|
|
d2e01e97f0 | ||
|
|
56f5df6847 | ||
|
|
cb696a8880 | ||
|
|
b3efb3084f | ||
|
|
4fe1b214c1 | ||
|
|
96c3fccb05 | ||
|
|
6a876de838 | ||
|
|
765487a087 | ||
|
|
d472888bf0 | ||
|
|
b83640fae7 | ||
|
|
c519cd0268 | ||
|
|
30263b26a5 | ||
|
|
3fe5b5c80d | ||
|
|
e8510287e3 | ||
|
|
310cca6939 | ||
|
|
e51b7155aa | ||
|
|
96ade0b821 | ||
|
|
b3db2981de | ||
|
|
0d7b2d812c | ||
|
|
f959b2c59a | ||
|
|
7549f1ba95 | ||
|
|
047343ca11 | ||
|
|
e2c3d0fa94 | ||
|
|
b8d4522ea1 | ||
|
|
22fd1a1cfd | ||
|
|
876c815bd8 | ||
|
|
8fd1d6aec8 | ||
|
|
589a393b5c | ||
|
|
19ae405742 | ||
|
|
f952b92d71 | ||
|
|
b567184dd7 | ||
|
|
c5b0787de6 | ||
|
|
3d0f649411 | ||
|
|
b54067e04d | ||
|
|
8d188cd32b | ||
|
|
5ebaee03da | ||
|
|
7ff31a1d91 | ||
|
|
1db9ce205f | ||
|
|
5a53c45321 | ||
|
|
050ea96cc6 | ||
|
|
fb6e0be5fe | ||
|
|
66fe1aa85d | ||
|
|
7ef8c81caf | ||
|
|
2f3d4ddc58 | ||
|
|
257f0c338c | ||
|
|
9406c07c42 | ||
|
|
1d35e2b261 | ||
|
|
0643beb079 | ||
|
|
22e0ca2d7e | ||
|
|
ce7be9fad5 | ||
|
|
6d3f6d73d0 | ||
|
|
8b445e04e5 | ||
|
|
b6312bca9c | ||
|
|
201a04c49a | ||
|
|
5e2c7a08d3 | ||
|
|
0da98e6769 | ||
|
|
da87f358c4 | ||
|
|
9de3b07223 | ||
|
|
d3cd887f5e | ||
|
|
d65cd605a1 | ||
|
|
6ec41fa47e | ||
|
|
d879a91165 | ||
|
|
d21cfae095 | ||
|
|
be5f2b6cf0 | ||
|
|
37b3a22825 | ||
|
|
bb307dec0a | ||
|
|
3bc58fb46f | ||
|
|
73b038084b | ||
|
|
e6133ad6d4 | ||
|
|
eeb6d0e9bf | ||
|
|
ca97f34092 | ||
|
|
784e338be4 | ||
|
|
a1192e34d7 | ||
|
|
3e39d13172 | ||
|
|
22e9cb4cf4 | ||
|
|
7c7ba0154a | ||
|
|
500c44e3f5 | ||
|
|
5f00239bba | ||
|
|
b1704ccef1 | ||
|
|
f7004aa8c3 | ||
|
|
8379b39aaf | ||
|
|
02e96039ac | ||
|
|
4987b2fe26 | ||
|
|
7e7e83440f | ||
|
|
ff9b936634 | ||
|
|
43d1d685c6 | ||
|
|
cda8a97f4a | ||
|
|
bf430fce09 | ||
|
|
6bdaeb983d | ||
|
|
c81dee137f | ||
|
|
323636b396 | ||
|
|
64f391adf7 | ||
|
|
c0a85faa68 | ||
|
|
825e3717ca | ||
|
|
007258d657 | ||
|
|
c84986d00e | ||
|
|
8d186d6b3f | ||
|
|
1296829b9c | ||
|
|
86b0f67dbc | ||
|
|
4adeaedfde | ||
|
|
23b094f151 | ||
|
|
e7e6567792 | ||
|
|
9eeccb765d | ||
|
|
a88fe2ecab | ||
|
|
9a2710b9d7 | ||
|
|
50f73a5072 | ||
|
|
ae594e81f9 | ||
|
|
57d34ab146 | ||
|
|
ff0b37055b | ||
|
|
c87b5d3132 | ||
|
|
38a4642479 | ||
|
|
58cdb9503b | ||
|
|
ec1eb6d222 | ||
|
|
7b89c12470 | ||
|
|
b8e06b9636 | ||
|
|
2ea6508fa5 | ||
|
|
a41297d841 | ||
|
|
4ad1474e32 | ||
|
|
87c59f471c |
70
.github/CONTRIBUTING.md
vendored
70
.github/CONTRIBUTING.md
vendored
@@ -1,36 +1,70 @@
|
||||
### Introduction (first timers)
|
||||
### Introduction (For First-Time Contributors)
|
||||
|
||||
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.
|
||||
Thank you for your interest in raising an issue with ERPNext. An issue can be either a bug report or a feature request.
|
||||
|
||||
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.
|
||||
By reporting bugs, you contribute directly to improving ERPNext. Bug reports help developers identify and fix issues quickly before they affect more users.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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).
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
### Reply and Closing Policy
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
### General Issue Guidelines
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
### Bug Report Guidelines
|
||||
|
||||
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`.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
### Feature Request Guidelines
|
||||
|
||||
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.
|
||||
1. **Clarity:**
|
||||
Clearly describe the expected behavior. Avoid vague statements.
|
||||
|
||||
### What if my Issue is closed
|
||||
2. **Proposed solution:**
|
||||
Suggest how the feature should work.
|
||||
|
||||
Don't worry, take the feedback, supply the correct information and re-open it!
|
||||
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.
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -60,7 +60,7 @@ body:
|
||||
description: Share exact version number of Frappe and ERPNext you are using.
|
||||
placeholder: |
|
||||
Frappe version -
|
||||
ERPNext Verion -
|
||||
ERPNext version -
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
24
.github/ISSUE_TEMPLATE/feature_request.md
vendored
24
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea to improve ERPNext
|
||||
about: Suggest an idea or enhancement for ERPNext
|
||||
title: ''
|
||||
labels: feature-request
|
||||
assignees: ''
|
||||
@@ -17,17 +17,21 @@ 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 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 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 to a feature, please try the following channels to get paid developments done quickly:
|
||||
If you're in urgent need of a feature, please try the following channels to get paid developments done quickly:
|
||||
1. Certified ERPNext partners: https://erpnext.com/partners
|
||||
2. Developer community on ERPNext forums: https://discuss.frappe.io/c/framework/5
|
||||
3. Telegram group for ERPNext/Frappe development work: https://t.me/erpnext_opps
|
||||
|
||||
-->
|
||||
## Before Submitting
|
||||
- [ ] I searched existing issues and confirmed this is not a duplicate
|
||||
- [ ] This is a feature request, not a bug or support question
|
||||
- [ ] For support: https://discuss.frappe.io/c/erpnext/6
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
A clear and concise description of what the problem is. Ex. As a [role], I have to [painful task] because [missing feature].
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
@@ -35,5 +39,17 @@ 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 -->
|
||||
|
||||
|
||||
7
.github/workflows/generate-pot-file.yml
vendored
7
.github/workflows/generate-pot-file.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
branch: ["develop"]
|
||||
branch: ["develop", "version-16-hotfix"]
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -30,6 +30,11 @@ 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
|
||||
|
||||
2
.github/workflows/initiate_release.yml
vendored
2
.github/workflows/initiate_release.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version: ["14", "15"]
|
||||
version: ["15", "16"]
|
||||
|
||||
steps:
|
||||
- uses: octokit/request-action@v2.x
|
||||
|
||||
3
.github/workflows/linters.yml
vendored
3
.github/workflows/linters.yml
vendored
@@ -43,3 +43,6 @@ 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
|
||||
|
||||
6
.github/workflows/patch.yml
vendored
6
.github/workflows/patch.yml
vendored
@@ -113,8 +113,8 @@ jobs:
|
||||
jq 'del(.install_apps)' ~/frappe-bench/sites/test_site/site_config.json > tmp.json
|
||||
mv tmp.json ~/frappe-bench/sites/test_site/site_config.json
|
||||
|
||||
wget https://erpnext.com/files/v13-erpnext.sql.gz
|
||||
bench --site test_site --force restore ~/frappe-bench/v13-erpnext.sql.gz
|
||||
wget https://frappe.io/files/erpnext-v14.sql.gz
|
||||
bench --site test_site --force restore ~/frappe-bench/erpnext-v14.sql.gz
|
||||
|
||||
git -C "apps/frappe" remote set-url upstream https://github.com/frappe/frappe.git
|
||||
git -C "apps/erpnext" remote set-url upstream https://github.com/frappe/erpnext.git
|
||||
@@ -142,8 +142,8 @@ jobs:
|
||||
bench --site test_site migrate
|
||||
}
|
||||
|
||||
update_to_version 14 3.11
|
||||
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##*/}}"
|
||||
|
||||
@@ -4,8 +4,8 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: server-individual-tests-develop-${{ github.event_name }}-${{ github.event.number || github.event_name == 'workflow_dispatch' && github.run_id || '' }}
|
||||
cancel-in-progress: false
|
||||
group: server-individual-tests-lightmode-develop
|
||||
cancel-in-progress: true
|
||||
|
||||
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 '*/doctype/*/test_*.py' | xargs grep -l 'def test_' | awk '{
|
||||
matrix=$(find . -path '*/test_*.py' | xargs grep -l 'def test_' | sort | awk '{
|
||||
# Remove ./ prefix, file extension, and replace / with .
|
||||
gsub(/^\.\//, "", $0)
|
||||
gsub(/\.py$/, "", $0)
|
||||
@@ -58,6 +58,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{fromJson(needs.discover.outputs.matrix)}}
|
||||
max-parallel: 14
|
||||
|
||||
name: Test
|
||||
|
||||
@@ -130,4 +131,13 @@ jobs:
|
||||
FRAPPE_BRANCH: ${{ github.event.inputs.branch }}
|
||||
|
||||
- name: Run Tests
|
||||
run: 'cd ~/frappe-bench/ && bench --site test_site run-tests --app erpnext --module ${{ matrix.test }}'
|
||||
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
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
paths:
|
||||
- "**.js"
|
||||
- "**.css"
|
||||
- "**.svg"
|
||||
- "**.md"
|
||||
- "**.html"
|
||||
- 'crowdin.yml'
|
||||
|
||||
4
.github/workflows/server-tests-mariadb.yml
vendored
4
.github/workflows/server-tests-mariadb.yml
vendored
@@ -41,6 +41,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
TZ: 'Asia/Kolkata'
|
||||
NODE_ENV: "production"
|
||||
WITH_COVERAGE: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -56,6 +57,7 @@ jobs:
|
||||
mysql:
|
||||
image: mariadb:10.6
|
||||
env:
|
||||
TZ: 'Asia/Kolkata'
|
||||
MARIADB_ROOT_PASSWORD: 'root'
|
||||
ports:
|
||||
- 3306:3306
|
||||
@@ -129,7 +131,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 --app erpnext --total-builds ${{ strategy.job-total }} --build-number ${{ matrix.container }} --with-coverage'
|
||||
run: 'cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --lightmode --app erpnext --total-builds ${{ strategy.job-total }} --build-number ${{ matrix.container }} --with-coverage'
|
||||
env:
|
||||
TYPE: server
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -19,3 +19,7 @@ node_modules/
|
||||
.backportrc.json
|
||||
# Aider AI Chat
|
||||
.aider*
|
||||
|
||||
# Banking SPA
|
||||
erpnext/public/banking
|
||||
erpnext/www/banking.html
|
||||
@@ -50,13 +50,13 @@ pull_request_rules:
|
||||
- version-15-hotfix
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
- name: backport to version-16-beta
|
||||
- name: backport to version-16-hotfix
|
||||
conditions:
|
||||
- label="backport version-16-beta"
|
||||
- label="backport version-16-hotfix"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- version-16-beta
|
||||
- version-16-hotfix
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
- name: Automatic merge on CI success and review
|
||||
|
||||
12
CODEOWNERS
12
CODEOWNERS
@@ -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
|
||||
erpnext/maintenance/ @rohitwaghchaure @mihir-kandoi
|
||||
erpnext/manufacturing/ @rohitwaghchaure @mihir-kandoi
|
||||
erpnext/quality_management/ @rohitwaghchaure
|
||||
erpnext/quality_management/ @rohitwaghchaure @mihir-kandoi
|
||||
erpnext/stock/ @rohitwaghchaure @mihir-kandoi
|
||||
erpnext/subcontracting @mihir-kandoi
|
||||
erpnext/subcontracting/ @mihir-kandoi
|
||||
erpnext/projects/ @nishkagosalia
|
||||
|
||||
erpnext/controllers/ @ruthra-kumar @rohitwaghchaure @mihir-kandoi
|
||||
erpnext/patches/ @ruthra-kumar
|
||||
erpnext/patches/ @ruthra-kumar @rohitwaghchaure @mihir-kandoi
|
||||
|
||||
.github/ @ruthra-kumar
|
||||
.github/ @ruthra-kumar @mihir-kandoi
|
||||
pyproject.toml @ruthra-kumar
|
||||
|
||||
72
README.md
72
README.md
@@ -1,20 +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="80xp"/>
|
||||
<img src="./erpnext/public/images/v16/erpnext.svg" alt="ERPNext Logo" height="80px" width="80px"/>
|
||||
</a>
|
||||
<h2>ERPNext</h2>
|
||||
<p align="center">
|
||||
<div align="center">
|
||||
<p>Powerful, Intuitive and Open-Source ERP</p>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
[](https://frappe.school)<br><br>
|
||||
[](https://github.com/frappe/erpnext/actions/workflows/server-tests-mariadb.yml)
|
||||
[](https://hub.docker.com/r/frappe/erpnext-worker)
|
||||
[](https://hub.docker.com/r/frappe/erpnext)
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<img src="./erpnext/public/images/v16/hero_image.png"/>
|
||||
<img src="./erpnext/public/images/v16/hero_image.png" alt="ERPNext Hero Image"/>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
@@ -27,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 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.
|
||||
Running a business is a complex task - handling invoices, tracking stock, managing personnel, and other daily operations. In a market where software is sold separately to manage each of these tasks, ERPNext does all of the above and more, for free.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Accounting**: All the tools you need to manage cash flow in one place, right from recording transactions to summarizing and analyzing financial reports.
|
||||
- **Order Management**: Track inventory levels, replenish stock, and manage sales orders, customers, suppliers, shipments, deliverables, and order fulfillment.
|
||||
- **Manufacturing**: Simplifies the production cycle, helps track material consumption, exhibits capacity planning, handles subcontracting, and more!
|
||||
- **Asset Management**: From purchase to 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.
|
||||
- **Asset Management**: From purchase to disposal, IT infrastructure to equipment. Covers every branch of your organization, all in one centralized system.
|
||||
- **Projects**: Deliver both internal and external projects on time, budget and profitability. Track tasks, timesheets, and issues by project.
|
||||
|
||||
<details open>
|
||||
|
||||
@@ -52,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.
|
||||
|
||||
@@ -60,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 with peace of mind.
|
||||
You can try [Frappe Cloud](https://frappecloud.com), a simple, user-friendly, and sophisticated [open-source](https://github.com/frappe/press) platform to host Frappe applications reliably and securely.
|
||||
|
||||
It 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.
|
||||
It handles installation, setup, upgrades, monitoring, maintenance, and support of your Frappe deployments. It is a fully featured developer platform with an ability to manage and control multiple Frappe deployments.
|
||||
|
||||
<div>
|
||||
<a href="https://erpnext-demo.frappe.cloud/app/home" target="_blank">
|
||||
<a href="https://erpnext-demo.frappe.cloud/app/home" target="_blank" rel="noopener noreferrer">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://frappe.io/files/try-on-fc-white.png">
|
||||
<img src="https://frappe.io/files/try-on-fc-black.png" alt="Try on Frappe Cloud" height="28" />
|
||||
@@ -74,25 +75,40 @@ It takes care of installation, setup, upgrades, monitoring, maintenance and supp
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
### Self-Hosted
|
||||
#### Docker
|
||||
|
||||
Prerequisites: docker, docker-compose, git. Refer [Docker Documentation](https://docs.docker.com) for more details on Docker setup.
|
||||
See [Frappe Docker Documentation](https://github.com/frappe/frappe_docker) for full documentation & FAQ on Docker setup
|
||||
|
||||
Run following commands:
|
||||
#### Prerequisites
|
||||
|
||||
```
|
||||
- [Docker](https://docs.docker.com/get-docker/)
|
||||
- [Docker Compose v2](https://docs.docker.com/compose/)
|
||||
- [git](https://docs.github.com/en/get-started/getting-started-with-git/set-up-git)
|
||||
|
||||
> For Docker basics and best practices refer to Docker's [documentation](https://docs.docker.com)
|
||||
|
||||
### Try on your environment
|
||||
|
||||
> **⚠️ Disposable demo only**
|
||||
>
|
||||
> **This setup is intended for quick evaluation. Expect to throw the environment away.** You will not be able to install custom apps to this setup. For production deployments, custom configurations, and detailed explanations, see the full documentation.
|
||||
|
||||
First clone the repo:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/frappe/frappe_docker
|
||||
cd frappe_docker
|
||||
docker compose -f pwd.yml up -d
|
||||
```
|
||||
|
||||
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
|
||||
Then run:
|
||||
|
||||
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.
|
||||
```sh
|
||||
docker compose -f pwd.yml up -d
|
||||
```
|
||||
Wait for a couple of minutes for ERPNext site to be created or check the `create-site` container logs before opening browser on port `8080`. (username: `Administrator`, password: `admin`)
|
||||
|
||||
See [Frappe Docker](https://github.com/frappe/frappe_docker/blob/main/docs/01-getting-started/03-arm64.md) for ARM based docker setup
|
||||
|
||||
|
||||
## Development Setup
|
||||
@@ -100,7 +116,7 @@ See [Frappe Docker](https://github.com/frappe/frappe_docker?tab=readme-ov-file#t
|
||||
|
||||
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
|
||||
@@ -129,20 +145,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 community of ERPNext users and service providers.
|
||||
3. [Discussion Forum](https://discuss.frappe.io/c/erpnext/6) - Engage with the community of ERPNext users and service providers.
|
||||
4. [Telegram Group](https://erpnext_public.t.me) - Get instant help from huge community of users.
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
1. [Issue Guidelines](https://github.com/frappe/erpnext/wiki/Issue-Guidelines)
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
## Logo and Trademark Policy
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Security Policy
|
||||
|
||||
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).
|
||||
The ERPNext team and community take security issues seriously. To report a security issue, please go through the information mentioned [here](https://frappe.io/security).
|
||||
|
||||
You can help us make ERPNext and all it's users more secure by following the [Reporting guidelines](https://erpnext.com/security).
|
||||
You can help us make ERPNext and all its users more secure by following the [Reporting guidelines](https://frappe.io/security).
|
||||
|
||||
We appreciate your efforts to responsibly disclose your findings. We'll endeavor to respond quickly, and will keep you updated throughout the process.
|
||||
We appreciate your efforts to responsibly disclose your findings. We'll endeavor to respond quickly, and will keep you updated throughout the process.
|
||||
|
||||
@@ -18,8 +18,9 @@ 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:
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
**/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
banking/.env.production
Normal file
1
banking/.env.production
Normal file
@@ -0,0 +1 @@
|
||||
VITE_BASE_NAME="banking"
|
||||
24
banking/.gitignore
vendored
Normal file
24
banking/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# 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?
|
||||
73
banking/README.md
Normal file
73
banking/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# 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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
24
banking/eslint.config.js
Normal file
24
banking/eslint.config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
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,
|
||||
},
|
||||
]);
|
||||
50
banking/index.html
Normal file
50
banking/index.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<!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>
|
||||
66
banking/package.json
Normal file
66
banking/package.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
13
banking/proxyOptions.ts
Normal file
13
banking/proxyOptions.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
65
banking/src/App.tsx
Normal file
65
banking/src/App.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
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
|
||||
228
banking/src/components/common/AccountsDropdown.tsx
Normal file
228
banking/src/components/common/AccountsDropdown.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
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
|
||||
26
banking/src/components/common/BankLogo.tsx
Normal file
26
banking/src/components/common/BankLogo.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
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
|
||||
17
banking/src/components/common/FileUploadBanner.tsx
Normal file
17
banking/src/components/common/FileUploadBanner.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
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
|
||||
301
banking/src/components/common/LinkFieldCombobox.tsx
Normal file
301
banking/src/components/common/LinkFieldCombobox.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
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
|
||||
82
banking/src/components/common/PartyTypeDropdown.tsx
Normal file
82
banking/src/components/common/PartyTypeDropdown.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
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
|
||||
475
banking/src/components/features/ActionLog/ActionLog.tsx
Normal file
475
banking/src/components/features/ActionLog/ActionLog.tsx
Normal file
@@ -0,0 +1,475 @@
|
||||
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
|
||||
@@ -0,0 +1,334 @@
|
||||
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
|
||||
@@ -0,0 +1,355 @@
|
||||
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
|
||||
@@ -0,0 +1,831 @@
|
||||
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
|
||||
@@ -0,0 +1,134 @@
|
||||
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
|
||||
@@ -0,0 +1,275 @@
|
||||
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
|
||||
@@ -0,0 +1,315 @@
|
||||
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
|
||||
@@ -0,0 +1,422 @@
|
||||
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
|
||||
@@ -0,0 +1,125 @@
|
||||
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
|
||||
@@ -0,0 +1,92 @@
|
||||
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
|
||||
@@ -0,0 +1,229 @@
|
||||
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
|
||||
@@ -0,0 +1,949 @@
|
||||
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}
|
||||
|
||||
<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
|
||||
@@ -0,0 +1,93 @@
|
||||
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
|
||||
@@ -0,0 +1,10 @@
|
||||
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>
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
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
|
||||
@@ -0,0 +1,101 @@
|
||||
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
|
||||
@@ -0,0 +1,799 @@
|
||||
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}
|
||||
|
||||
/>
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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
|
||||
@@ -0,0 +1,47 @@
|
||||
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
|
||||
@@ -0,0 +1,555 @@
|
||||
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
|
||||
@@ -0,0 +1,85 @@
|
||||
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,
|
||||
})
|
||||
410
banking/src/components/features/BankReconciliation/logos.ts
Normal file
410
banking/src/components/features/BankReconciliation/logos.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
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',
|
||||
}
|
||||
]
|
||||
457
banking/src/components/features/BankReconciliation/utils.ts
Normal file
457
banking/src/components/features/BankReconciliation/utils.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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
|
||||
@@ -0,0 +1,151 @@
|
||||
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
|
||||
@@ -0,0 +1,351 @@
|
||||
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
|
||||
@@ -0,0 +1,42 @@
|
||||
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
|
||||
})
|
||||
|
||||
}
|
||||
115
banking/src/components/features/Settings/KeyboardShortcuts.tsx
Normal file
115
banking/src/components/features/Settings/KeyboardShortcuts.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
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
|
||||
46
banking/src/components/features/Settings/MatchingRules.tsx
Normal file
46
banking/src/components/features/Settings/MatchingRules.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
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
|
||||
261
banking/src/components/features/Settings/Preferences.tsx
Normal file
261
banking/src/components/features/Settings/Preferences.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
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>
|
||||
}
|
||||
314
banking/src/components/features/Settings/Rules/RuleList.tsx
Normal file
314
banking/src/components/features/Settings/Rules/RuleList.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
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
|
||||
95
banking/src/components/features/Settings/Settings.tsx
Normal file
95
banking/src/components/features/Settings/Settings.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
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
|
||||
196
banking/src/components/ui/alert-dialog.tsx
Normal file
196
banking/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
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,
|
||||
}
|
||||
104
banking/src/components/ui/alert.tsx
Normal file
104
banking/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
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 }
|
||||
188
banking/src/components/ui/badge.tsx
Normal file
188
banking/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
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 }
|
||||
109
banking/src/components/ui/breadcrumb.tsx
Normal file
109
banking/src/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
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,
|
||||
}
|
||||
263
banking/src/components/ui/button.tsx
Normal file
263
banking/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
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 }
|
||||
218
banking/src/components/ui/calendar.tsx
Normal file
218
banking/src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
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 }
|
||||
92
banking/src/components/ui/card.tsx
Normal file
92
banking/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
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,
|
||||
}
|
||||
44
banking/src/components/ui/checkbox.tsx
Normal file
44
banking/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
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 }
|
||||
183
banking/src/components/ui/command.tsx
Normal file
183
banking/src/components/ui/command.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
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,
|
||||
}
|
||||
156
banking/src/components/ui/dialog.tsx
Normal file
156
banking/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
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,
|
||||
}
|
||||
20
banking/src/components/ui/direction.tsx
Normal file
20
banking/src/components/ui/direction.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
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 }
|
||||
262
banking/src/components/ui/dropdown-menu.tsx
Normal file
262
banking/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
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,
|
||||
}
|
||||
85
banking/src/components/ui/empty.tsx
Normal file
85
banking/src/components/ui/empty.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
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,
|
||||
}
|
||||
51
banking/src/components/ui/error-banner.tsx
Normal file
51
banking/src/components/ui/error-banner.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
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
|
||||
289
banking/src/components/ui/file-dropzone.tsx
Normal file
289
banking/src/components/ui/file-dropzone.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
383
banking/src/components/ui/form-elements.tsx
Normal file
383
banking/src/components/ui/form-elements.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
174
banking/src/components/ui/form.tsx
Normal file
174
banking/src/components/ui/form.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
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,
|
||||
}
|
||||
42
banking/src/components/ui/hover-card.tsx
Normal file
42
banking/src/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
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 }
|
||||
161
banking/src/components/ui/input-group.tsx
Normal file
161
banking/src/components/ui/input-group.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
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,
|
||||
}
|
||||
49
banking/src/components/ui/input.tsx
Normal file
49
banking/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
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 }
|
||||
28
banking/src/components/ui/kbd.tsx
Normal file
28
banking/src/components/ui/kbd.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
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 }
|
||||
8
banking/src/components/ui/keyboard-keys.tsx
Normal file
8
banking/src/components/ui/keyboard-keys.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
export const KeyboardMetaKeyIcon = () => {
|
||||
if (navigator.platform.toUpperCase().indexOf('MAC') >= 0) {
|
||||
return <span className="text-sm">⌘</span>
|
||||
} else {
|
||||
return <span>Ctrl</span>
|
||||
}
|
||||
}
|
||||
22
banking/src/components/ui/label.tsx
Normal file
22
banking/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
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 }
|
||||
510
banking/src/components/ui/list-view.tsx
Normal file
510
banking/src/components/ui/list-view.tsx
Normal file
@@ -0,0 +1,510 @@
|
||||
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-ui–aligned Espresso tokens.
|
||||
*/
|
||||
export function ListView<TData>(props: ListViewProps<TData>) {
|
||||
return <ListViewInner {...props} />
|
||||
}
|
||||
|
||||
export type { ColumnSizingState, RowSelectionState }
|
||||
27
banking/src/components/ui/loaders.tsx
Normal file
27
banking/src/components/ui/loaders.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
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>
|
||||
}
|
||||
28
banking/src/components/ui/markdown.tsx
Normal file
28
banking/src/components/ui/markdown.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
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
|
||||
87
banking/src/components/ui/popover.tsx
Normal file
87
banking/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
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,
|
||||
}
|
||||
67
banking/src/components/ui/progress.tsx
Normal file
67
banking/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
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 }
|
||||
43
banking/src/components/ui/radio-group.tsx
Normal file
43
banking/src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
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 }
|
||||
221
banking/src/components/ui/select.tsx
Normal file
221
banking/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
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,
|
||||
}
|
||||
26
banking/src/components/ui/separator.tsx
Normal file
26
banking/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
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 }
|
||||
273
banking/src/components/ui/settings-dialog.tsx
Normal file
273
banking/src/components/ui/settings-dialog.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
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
|
||||
}
|
||||
13
banking/src/components/ui/skeleton.tsx
Normal file
13
banking/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
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 }
|
||||
53
banking/src/components/ui/sonner.tsx
Normal file
53
banking/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
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 }
|
||||
13
banking/src/components/ui/stats.tsx
Normal file
13
banking/src/components/ui/stats.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
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>
|
||||
}
|
||||
45
banking/src/components/ui/switch.tsx
Normal file
45
banking/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
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 }
|
||||
114
banking/src/components/ui/table.tsx
Normal file
114
banking/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
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
Reference in New Issue
Block a user