@arcforgelabs/flow 2026.10.35 → 2026.10.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/npm-shrinkwrap.json +81 -50
- package/package.json +6 -5
- package/server/api/invoicing.js +13 -1
- package/server/ui/custom-fields.js +223 -56
- package/server/ui/flow.css +74 -0
- package/server/ui/invoicing.js +57 -15
- package/server/ui/routes.js +10 -0
package/README.md
CHANGED
|
@@ -48,6 +48,7 @@ run a lab.
|
|
|
48
48
|
| `docs/CUSTOM_FIELDS.md` | Custom fields: the lab's own fields for its jobs, kept in Flow only, and how a board move checks for conflicts |
|
|
49
49
|
| `docs/JOB_BOARD_MOVES.md` | Moving jobs on the Jobs board: custom field moves stay in Flow; Seazona stage moves become a hand-off |
|
|
50
50
|
| `docs/PRICE_LIST.md` | The price list: Flow's own codes and prices, their links to Xero items and Seazona products, and the price check |
|
|
51
|
+
| `docs/AI_BOTS.md` | The AI bots: ClawSweeper reviews and the Docs Agent, their ClawRouter budget and cost, and setup |
|
|
51
52
|
| `docs/LOW_RISK_MERGE.md` | The low-risk merge lane: which pull requests merge themselves when green, with ClawSweeper's risk labels |
|
|
52
53
|
| `docs/RELEASING.md` | Versions and channels, the release workflows, how to cut and roll back a release, and the one-time setup |
|
|
53
54
|
| `docs/SELF_HOSTING.md` | Running a Flow release with Docker, Node or Replit, the update notice, and Clerk sign-in |
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.36",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@arcforgelabs/flow",
|
|
9
|
-
"version": "2026.10.
|
|
9
|
+
"version": "2026.10.36",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@clerk/backend": "3.
|
|
12
|
+
"@clerk/backend": "3.25.0",
|
|
13
13
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
|
14
14
|
"@fontsource-variable/dm-sans": "5.3.0",
|
|
15
15
|
"@fontsource-variable/geist": "5.3.0",
|
|
@@ -21,10 +21,11 @@
|
|
|
21
21
|
"@fontsource-variable/playfair-display": "5.3.0",
|
|
22
22
|
"@fontsource-variable/plus-jakarta-sans": "5.3.0",
|
|
23
23
|
"@fontsource-variable/space-grotesk": "5.3.0",
|
|
24
|
-
"@sentry/node": "11.
|
|
25
|
-
"drizzle-orm": "0.45.
|
|
24
|
+
"@sentry/node": "11.8.0",
|
|
25
|
+
"drizzle-orm": "0.45.4",
|
|
26
26
|
"express": "5.3.0",
|
|
27
|
-
"express-rate-limit": "8.7.
|
|
27
|
+
"express-rate-limit": "8.7.1",
|
|
28
|
+
"lucide-static": "1.59.0",
|
|
28
29
|
"pdfkit": "0.20.2",
|
|
29
30
|
"pg": "8.23.1"
|
|
30
31
|
},
|
|
@@ -39,9 +40,9 @@
|
|
|
39
40
|
}
|
|
40
41
|
},
|
|
41
42
|
"node_modules/@clerk/backend": {
|
|
42
|
-
"version": "3.
|
|
43
|
-
"resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.
|
|
44
|
-
"integrity": "sha512-
|
|
43
|
+
"version": "3.25.0",
|
|
44
|
+
"resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.25.0.tgz",
|
|
45
|
+
"integrity": "sha512-hmJS0a61FO08PDaV2MtZ/hj6pE+cQBRmpxR8oUFGQTXewOQLLOAbcgob0rmFAnMNuHgXfsYf3NVxcQCUOWE+QA==",
|
|
45
46
|
"license": "MIT",
|
|
46
47
|
"dependencies": {
|
|
47
48
|
"@clerk/shared": "^4.40.0",
|
|
@@ -809,6 +810,9 @@
|
|
|
809
810
|
"cpu": [
|
|
810
811
|
"arm64"
|
|
811
812
|
],
|
|
813
|
+
"libc": [
|
|
814
|
+
"glibc"
|
|
815
|
+
],
|
|
812
816
|
"license": "MIT",
|
|
813
817
|
"optional": true,
|
|
814
818
|
"os": [
|
|
@@ -825,6 +829,9 @@
|
|
|
825
829
|
"cpu": [
|
|
826
830
|
"arm64"
|
|
827
831
|
],
|
|
832
|
+
"libc": [
|
|
833
|
+
"musl"
|
|
834
|
+
],
|
|
828
835
|
"license": "MIT",
|
|
829
836
|
"optional": true,
|
|
830
837
|
"os": [
|
|
@@ -841,6 +848,9 @@
|
|
|
841
848
|
"cpu": [
|
|
842
849
|
"ppc64"
|
|
843
850
|
],
|
|
851
|
+
"libc": [
|
|
852
|
+
"glibc"
|
|
853
|
+
],
|
|
844
854
|
"license": "MIT",
|
|
845
855
|
"optional": true,
|
|
846
856
|
"os": [
|
|
@@ -857,6 +867,9 @@
|
|
|
857
867
|
"cpu": [
|
|
858
868
|
"riscv64"
|
|
859
869
|
],
|
|
870
|
+
"libc": [
|
|
871
|
+
"glibc"
|
|
872
|
+
],
|
|
860
873
|
"license": "MIT",
|
|
861
874
|
"optional": true,
|
|
862
875
|
"os": [
|
|
@@ -873,6 +886,9 @@
|
|
|
873
886
|
"cpu": [
|
|
874
887
|
"riscv64"
|
|
875
888
|
],
|
|
889
|
+
"libc": [
|
|
890
|
+
"musl"
|
|
891
|
+
],
|
|
876
892
|
"license": "MIT",
|
|
877
893
|
"optional": true,
|
|
878
894
|
"os": [
|
|
@@ -889,6 +905,9 @@
|
|
|
889
905
|
"cpu": [
|
|
890
906
|
"s390x"
|
|
891
907
|
],
|
|
908
|
+
"libc": [
|
|
909
|
+
"glibc"
|
|
910
|
+
],
|
|
892
911
|
"license": "MIT",
|
|
893
912
|
"optional": true,
|
|
894
913
|
"os": [
|
|
@@ -905,6 +924,9 @@
|
|
|
905
924
|
"cpu": [
|
|
906
925
|
"x64"
|
|
907
926
|
],
|
|
927
|
+
"libc": [
|
|
928
|
+
"glibc"
|
|
929
|
+
],
|
|
908
930
|
"license": "MIT",
|
|
909
931
|
"optional": true,
|
|
910
932
|
"os": [
|
|
@@ -921,6 +943,9 @@
|
|
|
921
943
|
"cpu": [
|
|
922
944
|
"x64"
|
|
923
945
|
],
|
|
946
|
+
"libc": [
|
|
947
|
+
"musl"
|
|
948
|
+
],
|
|
924
949
|
"license": "MIT",
|
|
925
950
|
"optional": true,
|
|
926
951
|
"os": [
|
|
@@ -1004,12 +1029,12 @@
|
|
|
1004
1029
|
}
|
|
1005
1030
|
},
|
|
1006
1031
|
"node_modules/@sentry/bundler-plugins": {
|
|
1007
|
-
"version": "11.
|
|
1008
|
-
"resolved": "https://registry.npmjs.org/@sentry/bundler-plugins/-/bundler-plugins-11.
|
|
1009
|
-
"integrity": "sha512-
|
|
1032
|
+
"version": "11.8.0",
|
|
1033
|
+
"resolved": "https://registry.npmjs.org/@sentry/bundler-plugins/-/bundler-plugins-11.8.0.tgz",
|
|
1034
|
+
"integrity": "sha512-rQrbL2lYPyiiO6ngdpbbfPKyEzVVQsz1FG+aE8jUiUYs72gvmDmZmRyl9VDCGLrHZfIEwaQMT1t6WQH5nopu5A==",
|
|
1010
1035
|
"license": "MIT",
|
|
1011
1036
|
"dependencies": {
|
|
1012
|
-
"@sentry/core": "11.
|
|
1037
|
+
"@sentry/core": "11.8.0",
|
|
1013
1038
|
"dotenv": "^17.4.2",
|
|
1014
1039
|
"glob": "^13.0.6",
|
|
1015
1040
|
"magic-string": "~0.30.8 || ^1.0.0",
|
|
@@ -1038,52 +1063,52 @@
|
|
|
1038
1063
|
}
|
|
1039
1064
|
},
|
|
1040
1065
|
"node_modules/@sentry/conventions": {
|
|
1041
|
-
"version": "0.
|
|
1042
|
-
"resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.
|
|
1043
|
-
"integrity": "sha512-
|
|
1066
|
+
"version": "0.27.0",
|
|
1067
|
+
"resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.27.0.tgz",
|
|
1068
|
+
"integrity": "sha512-Mrqd/o7n/z7qWSYfQQA7x4fFTA0IqOG6JNsbBpnC97khPwZk7n6DPtOO7YgaYplSl8R8QLRbBz9Xv2lVhToSAg==",
|
|
1044
1069
|
"license": "MIT",
|
|
1045
1070
|
"engines": {
|
|
1046
1071
|
"node": ">=14"
|
|
1047
1072
|
}
|
|
1048
1073
|
},
|
|
1049
1074
|
"node_modules/@sentry/core": {
|
|
1050
|
-
"version": "11.
|
|
1051
|
-
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-11.
|
|
1052
|
-
"integrity": "sha512-
|
|
1075
|
+
"version": "11.8.0",
|
|
1076
|
+
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-11.8.0.tgz",
|
|
1077
|
+
"integrity": "sha512-4MVTthk6C/1oQFjbVIfygHyNkx2YGbwNLml0kJji0vjJXy6ZNls9zdIp0FTBTi4e3189IcSc5OXD4qcJdmEeKA==",
|
|
1053
1078
|
"license": "MIT",
|
|
1054
1079
|
"dependencies": {
|
|
1055
|
-
"@sentry/conventions": "^0.
|
|
1080
|
+
"@sentry/conventions": "^0.27.0"
|
|
1056
1081
|
},
|
|
1057
1082
|
"engines": {
|
|
1058
1083
|
"node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
|
|
1059
1084
|
}
|
|
1060
1085
|
},
|
|
1061
1086
|
"node_modules/@sentry/node": {
|
|
1062
|
-
"version": "11.
|
|
1063
|
-
"resolved": "https://registry.npmjs.org/@sentry/node/-/node-11.
|
|
1064
|
-
"integrity": "sha512-
|
|
1087
|
+
"version": "11.8.0",
|
|
1088
|
+
"resolved": "https://registry.npmjs.org/@sentry/node/-/node-11.8.0.tgz",
|
|
1089
|
+
"integrity": "sha512-8vFPfCRYoUYmsno+OYdvtf5akIUUvd7FvcuIGcfrge8S/bSLFRKq58YtFmX25JFcrMNZ2oQD8r0jkgZfm/2iuA==",
|
|
1065
1090
|
"license": "MIT",
|
|
1066
1091
|
"dependencies": {
|
|
1067
1092
|
"@opentelemetry/api": "^1.9.1",
|
|
1068
|
-
"@sentry/bundler-plugins": "11.
|
|
1069
|
-
"@sentry/conventions": "^0.
|
|
1070
|
-
"@sentry/core": "11.
|
|
1071
|
-
"@sentry/opentelemetry": "11.
|
|
1072
|
-
"@sentry/server-runtime-injection": "11.
|
|
1073
|
-
"@sentry/server-utils": "11.
|
|
1093
|
+
"@sentry/bundler-plugins": "11.8.0",
|
|
1094
|
+
"@sentry/conventions": "^0.27.0",
|
|
1095
|
+
"@sentry/core": "11.8.0",
|
|
1096
|
+
"@sentry/opentelemetry": "11.8.0",
|
|
1097
|
+
"@sentry/server-runtime-injection": "11.8.0",
|
|
1098
|
+
"@sentry/server-utils": "11.8.0"
|
|
1074
1099
|
},
|
|
1075
1100
|
"engines": {
|
|
1076
1101
|
"node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
|
|
1077
1102
|
}
|
|
1078
1103
|
},
|
|
1079
1104
|
"node_modules/@sentry/opentelemetry": {
|
|
1080
|
-
"version": "11.
|
|
1081
|
-
"resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-11.
|
|
1082
|
-
"integrity": "sha512-
|
|
1105
|
+
"version": "11.8.0",
|
|
1106
|
+
"resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-11.8.0.tgz",
|
|
1107
|
+
"integrity": "sha512-wKuG2SbHHiGDT/fgT4X5n2usO4AjVkYfAj41002mZ18JZU3mNLeCq+qw/nnDIYbcsfIMOUm/eMh3ShKCE+DpXQ==",
|
|
1083
1108
|
"license": "MIT",
|
|
1084
1109
|
"dependencies": {
|
|
1085
|
-
"@sentry/conventions": "^0.
|
|
1086
|
-
"@sentry/core": "11.
|
|
1110
|
+
"@sentry/conventions": "^0.27.0",
|
|
1111
|
+
"@sentry/core": "11.8.0"
|
|
1087
1112
|
},
|
|
1088
1113
|
"engines": {
|
|
1089
1114
|
"node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
|
|
@@ -1093,27 +1118,27 @@
|
|
|
1093
1118
|
}
|
|
1094
1119
|
},
|
|
1095
1120
|
"node_modules/@sentry/server-runtime-injection": {
|
|
1096
|
-
"version": "11.
|
|
1097
|
-
"resolved": "https://registry.npmjs.org/@sentry/server-runtime-injection/-/server-runtime-injection-11.
|
|
1098
|
-
"integrity": "sha512-
|
|
1121
|
+
"version": "11.8.0",
|
|
1122
|
+
"resolved": "https://registry.npmjs.org/@sentry/server-runtime-injection/-/server-runtime-injection-11.8.0.tgz",
|
|
1123
|
+
"integrity": "sha512-4uB4E8+9ni5NcJ463aYK/+n1hTMw/vSt4s+AkQ84CKigRDwv2FNNnD1AWpipWNj/xEQ6eQbUitGl7wg7/Ri8Pg==",
|
|
1099
1124
|
"license": "MIT",
|
|
1100
1125
|
"dependencies": {
|
|
1101
|
-
"@sentry/core": "11.
|
|
1102
|
-
"@sentry/server-utils": "11.
|
|
1126
|
+
"@sentry/core": "11.8.0",
|
|
1127
|
+
"@sentry/server-utils": "11.8.0"
|
|
1103
1128
|
},
|
|
1104
1129
|
"engines": {
|
|
1105
1130
|
"node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
|
|
1106
1131
|
}
|
|
1107
1132
|
},
|
|
1108
1133
|
"node_modules/@sentry/server-utils": {
|
|
1109
|
-
"version": "11.
|
|
1110
|
-
"resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-11.
|
|
1111
|
-
"integrity": "sha512-
|
|
1134
|
+
"version": "11.8.0",
|
|
1135
|
+
"resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-11.8.0.tgz",
|
|
1136
|
+
"integrity": "sha512-2MRFz1rXxnuDGafl6o/itwKHZeZn5hUZYeXNbQK012uO/a2pXhEc8Zh3FeO5L4tjGrIWHx419saAFnKwbVC2IQ==",
|
|
1112
1137
|
"license": "MIT",
|
|
1113
1138
|
"dependencies": {
|
|
1114
1139
|
"@opentelemetry/api": "^1.9.1",
|
|
1115
|
-
"@sentry/conventions": "^0.
|
|
1116
|
-
"@sentry/core": "11.
|
|
1140
|
+
"@sentry/conventions": "^0.27.0",
|
|
1141
|
+
"@sentry/core": "11.8.0"
|
|
1117
1142
|
},
|
|
1118
1143
|
"engines": {
|
|
1119
1144
|
"node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
|
|
@@ -1399,9 +1424,9 @@
|
|
|
1399
1424
|
}
|
|
1400
1425
|
},
|
|
1401
1426
|
"node_modules/drizzle-orm": {
|
|
1402
|
-
"version": "0.45.
|
|
1403
|
-
"resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.
|
|
1404
|
-
"integrity": "sha512-
|
|
1427
|
+
"version": "0.45.4",
|
|
1428
|
+
"resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.4.tgz",
|
|
1429
|
+
"integrity": "sha512-NiM1YZpJ2UxyeE67DpDDZ+Tsw4YLX3trSlkNT7Tm9eKoUNb1LvaAaQzXgYQsWs+AQ9TUmmwMmBOCXuqqxUWKKQ==",
|
|
1405
1430
|
"license": "Apache-2.0",
|
|
1406
1431
|
"peerDependencies": {
|
|
1407
1432
|
"@aws-sdk/client-rds-data": ">=3",
|
|
@@ -1687,9 +1712,9 @@
|
|
|
1687
1712
|
}
|
|
1688
1713
|
},
|
|
1689
1714
|
"node_modules/express-rate-limit": {
|
|
1690
|
-
"version": "8.7.
|
|
1691
|
-
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.
|
|
1692
|
-
"integrity": "sha512-
|
|
1715
|
+
"version": "8.7.1",
|
|
1716
|
+
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz",
|
|
1717
|
+
"integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==",
|
|
1693
1718
|
"license": "MIT",
|
|
1694
1719
|
"dependencies": {
|
|
1695
1720
|
"debug": "^4.4.3",
|
|
@@ -2021,6 +2046,12 @@
|
|
|
2021
2046
|
"node": "20 || >=22"
|
|
2022
2047
|
}
|
|
2023
2048
|
},
|
|
2049
|
+
"node_modules/lucide-static": {
|
|
2050
|
+
"version": "1.59.0",
|
|
2051
|
+
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.59.0.tgz",
|
|
2052
|
+
"integrity": "sha512-MuHZYaXbHgzN2QO6D3OM19bj09A+8GJiFSdXIMlbnLLEGQQ9+NRH2KaCxuivevD3Y23vM74kV17XBifgtqhfEg==",
|
|
2053
|
+
"license": "ISC"
|
|
2054
|
+
},
|
|
2024
2055
|
"node_modules/magic-string": {
|
|
2025
2056
|
"version": "1.4.3",
|
|
2026
2057
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.36",
|
|
4
4
|
"description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"db:generate": "drizzle-kit generate && node scripts/db-guard-migrations.js"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@clerk/backend": "3.
|
|
40
|
+
"@clerk/backend": "3.25.0",
|
|
41
41
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
|
42
42
|
"@fontsource-variable/dm-sans": "5.3.0",
|
|
43
43
|
"@fontsource-variable/geist": "5.3.0",
|
|
@@ -49,10 +49,11 @@
|
|
|
49
49
|
"@fontsource-variable/playfair-display": "5.3.0",
|
|
50
50
|
"@fontsource-variable/plus-jakarta-sans": "5.3.0",
|
|
51
51
|
"@fontsource-variable/space-grotesk": "5.3.0",
|
|
52
|
-
"@sentry/node": "11.
|
|
53
|
-
"drizzle-orm": "0.45.
|
|
52
|
+
"@sentry/node": "11.8.0",
|
|
53
|
+
"drizzle-orm": "0.45.4",
|
|
54
54
|
"express": "5.3.0",
|
|
55
|
-
"express-rate-limit": "8.7.
|
|
55
|
+
"express-rate-limit": "8.7.1",
|
|
56
|
+
"lucide-static": "1.59.0",
|
|
56
57
|
"pdfkit": "0.20.2",
|
|
57
58
|
"pg": "8.23.1"
|
|
58
59
|
},
|
package/server/api/invoicing.js
CHANGED
|
@@ -236,6 +236,15 @@ const jobOf = (o) => (o ? {
|
|
|
236
236
|
id: o.id, sourceSystem: o.sourceSystem, sourceJobRef: o.sourceJobRef, caseRef: o.caseRef, practitioner: o.practitioner, shippedOn: o.shippedOn,
|
|
237
237
|
} : null);
|
|
238
238
|
|
|
239
|
+
// A job with its work system's name and its link in that system's web app
|
|
240
|
+
// (jobLink: no live call), for the Linked column.
|
|
241
|
+
function withLink(job, systems) {
|
|
242
|
+
if (!job) return null;
|
|
243
|
+
const source = systems.find((a) => a.id === job.sourceSystem) ?? null;
|
|
244
|
+
const link = typeof source?.capabilities.jobLink === 'function' ? source.capabilities.jobLink({ sourceJobRef: job.sourceJobRef }) : null;
|
|
245
|
+
return { ...job, systemName: source?.name ?? job.sourceSystem, link: link ?? null };
|
|
246
|
+
}
|
|
247
|
+
|
|
239
248
|
async function jobsByInvoice(db, organisationId, invoiceIds) {
|
|
240
249
|
if (!invoiceIds.length) return new Map();
|
|
241
250
|
const rows = await db.select().from(billingObligations)
|
|
@@ -343,6 +352,7 @@ export function createInvoicingRouter({
|
|
|
343
352
|
return { rows, jobs: await jobsByInvoice(db, organisationId, rows.map((r) => r.invoice.id)) };
|
|
344
353
|
})();
|
|
345
354
|
const [views, counted, { rows, jobs }, mode] = await Promise.all([checking, counting, listing, sendMode]);
|
|
355
|
+
const systems = work();
|
|
346
356
|
const counts = Object.fromEntries(INVOICE_STATUSES.map((s) => [s, 0]));
|
|
347
357
|
for (const { status: s, n } of counted) counts[s] = n;
|
|
348
358
|
counts.to_check = views.toCheck.length;
|
|
@@ -355,7 +365,7 @@ export function createInvoicingRouter({
|
|
|
355
365
|
invoices: rows.map(({ invoice, contactName }) => ({
|
|
356
366
|
...invoice,
|
|
357
367
|
contactName,
|
|
358
|
-
job: jobs.get(invoice.id) ?? null,
|
|
368
|
+
job: withLink(jobs.get(invoice.id) ?? null, systems),
|
|
359
369
|
view: views.problems.has(invoice.id) ? 'problem' : ready.has(invoice.id) ? 'ready' : toCheck.has(invoice.id) ? 'to_check'
|
|
360
370
|
: views.sending.has(invoice.id) ? 'sending' : null,
|
|
361
371
|
problems: views.problems.get(invoice.id) ?? [],
|
|
@@ -363,6 +373,8 @@ export function createInvoicingRouter({
|
|
|
363
373
|
counts,
|
|
364
374
|
sendsTo: system?.name ?? null,
|
|
365
375
|
sendMode: mode,
|
|
376
|
+
// The Linked column: one icon for each connected system.
|
|
377
|
+
linksTo: [...systems.map((a) => ({ id: a.id, name: a.name, kind: 'work' })), ...(system ? [{ id: system.id, name: system.name, kind: 'accounting' }] : [])],
|
|
366
378
|
});
|
|
367
379
|
}));
|
|
368
380
|
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// Settings, Custom fields (/custom-fields; server/core/custom-fields.js,
|
|
2
|
-
// docs/CUSTOM_FIELDS.md). The fields a lab makes for its jobs, like
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
2
|
+
// docs/CUSTOM_FIELDS.md). The fields a lab makes for its jobs, laid out like
|
|
3
|
+
// ClickUp's Custom Field Manager: one table, with a group per type that folds
|
|
4
|
+
// open and closed, and one row per field with its type icon, who made it,
|
|
5
|
+
// when, and where it applies. Search, a type filter and Filter (archived
|
|
6
|
+
// fields) sit above it. A row's Edit opens the field's dialog (name and
|
|
7
|
+
// options); its ⋯ menu has Rename, Duplicate, Copy field ID and Archive. The
|
|
8
|
+
// work systems' own fields (Seazona's stage) sit in their own group, read
|
|
9
|
+
// only. Everyone can read the page; an owner or admin changes it. The caller
|
|
10
|
+
// passes in app.js's shared pieces as `ui`.
|
|
8
11
|
|
|
9
12
|
const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/CUSTOM_FIELDS.md';
|
|
10
13
|
const TYPE_LABELS = {
|
|
@@ -18,13 +21,44 @@ const TYPE_HINTS = {
|
|
|
18
21
|
number: 'A number, for example units.',
|
|
19
22
|
text: 'A short note.',
|
|
20
23
|
};
|
|
24
|
+
// Each type's icon (Lucide, served at /assets/icons/) and group colour.
|
|
25
|
+
const TYPE_ICONS = {
|
|
26
|
+
dropdown: 'square-chevron-down', person: 'user-round', checkbox: 'square-check', date: 'calendar', number: 'hash', text: 'type',
|
|
27
|
+
};
|
|
28
|
+
const TYPE_COLORS = { dropdown: 'green', person: 'blue', checkbox: 'purple', date: 'teal', number: 'amber', text: 'gray' };
|
|
29
|
+
const FOLDED_KEY = 'flow_custom_fields_folded';
|
|
30
|
+
|
|
31
|
+
// A Lucide icon as a mask, so it takes the text colour.
|
|
32
|
+
export function lucide(h, name, label = null) {
|
|
33
|
+
return h('span', { class: 'fx-lucide', style: `--icon: url(/assets/icons/${name}.svg)`, ...(label ? { role: 'img', 'aria-label': label, title: label } : { 'aria-hidden': 'true' }) });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// "Today", "Yesterday", else d/m/yy, like ClickUp's Date created.
|
|
37
|
+
export function createdDay(iso, now = new Date()) {
|
|
38
|
+
if (!iso) return '';
|
|
39
|
+
const day = new Date(iso);
|
|
40
|
+
const start = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
41
|
+
const days = Math.round((start(now) - start(day)) / 86_400_000);
|
|
42
|
+
if (days === 0) return 'Today';
|
|
43
|
+
if (days === 1) return 'Yesterday';
|
|
44
|
+
return `${day.getDate()}/${day.getMonth() + 1}/${String(day.getFullYear()).slice(2)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const initials = (name) => (name ?? '').split(/[\s@._-]+/).filter(Boolean).slice(0, 2).map((w) => w[0].toUpperCase()).join('') || '?';
|
|
48
|
+
|
|
49
|
+
function readFolded() {
|
|
50
|
+
try { return new Set(JSON.parse(localStorage.getItem(FOLDED_KEY) ?? '[]')); } catch { return new Set(); }
|
|
51
|
+
}
|
|
52
|
+
function saveFolded(set) {
|
|
53
|
+
try { localStorage.setItem(FOLDED_KEY, JSON.stringify([...set])); } catch { /* this browser keeps no state */ }
|
|
54
|
+
}
|
|
21
55
|
|
|
22
56
|
export async function renderCustomFields(main, ui) {
|
|
23
57
|
const { h, flow, toast, notice, dialog, pageHeader, me } = ui;
|
|
24
58
|
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
25
|
-
const host = h('div', { class: 'fx-card
|
|
26
|
-
const addButton = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled }, 'Create field');
|
|
27
|
-
main.append(pageHeader('Custom fields', { subtitle: 'Fields for your jobs. They stay in Flow and never change Seazona or Xero.'
|
|
59
|
+
const host = h('div', { class: 'fx-card fx-cfm__card' }, h('div', { class: 'fx-skeleton' }));
|
|
60
|
+
const addButton = h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', disabled }, 'Create field');
|
|
61
|
+
main.append(pageHeader('Custom fields', { subtitle: 'Fields for your jobs. They stay in Flow and never change Seazona or Xero.' }), host);
|
|
28
62
|
|
|
29
63
|
let data = null;
|
|
30
64
|
|
|
@@ -39,11 +73,6 @@ export async function renderCustomFields(main, ui) {
|
|
|
39
73
|
}
|
|
40
74
|
}
|
|
41
75
|
|
|
42
|
-
const row = (name, hint, control) => h('div', { class: 'fx-set__row' },
|
|
43
|
-
h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), hint),
|
|
44
|
-
h('div', { class: 'fx-set__control fx-rule__controls' }, control));
|
|
45
|
-
const optionChip = (o) => h('span', { class: 'fx-cf__opt', 'data-color': o.color ?? 'gray' }, o.name);
|
|
46
|
-
const typeChip = (type) => h('span', { class: 'fx-chip fx-chip--static' }, TYPE_LABELS[type] ?? type);
|
|
47
76
|
|
|
48
77
|
// The option list editor for a dropdown: one line per option (name,
|
|
49
78
|
// colour, move up, remove), in lane order.
|
|
@@ -119,60 +148,198 @@ export async function renderCustomFields(main, ui) {
|
|
|
119
148
|
return load();
|
|
120
149
|
}
|
|
121
150
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
151
|
+
// ---- The manager: toolbar, then one table of groups ----
|
|
152
|
+
const view = { q: '', types: new Set(), archived: false, desc: false, folded: readFolded(), renaming: null };
|
|
153
|
+
let menuCount = 0;
|
|
154
|
+
|
|
155
|
+
// A popover menu under a button (the Jobs board's toolbar menus work the same way).
|
|
156
|
+
function popMenu(button, build) {
|
|
157
|
+
const id = `fx-cf-menu-${++menuCount}`;
|
|
158
|
+
const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
|
|
159
|
+
button.setAttribute('popovertarget', id);
|
|
160
|
+
button.setAttribute('aria-haspopup', 'menu');
|
|
161
|
+
button.setAttribute('aria-expanded', 'false');
|
|
162
|
+
panel.addEventListener('toggle', (event) => {
|
|
163
|
+
button.setAttribute('aria-expanded', String(event.newState === 'open'));
|
|
164
|
+
if (event.newState !== 'open') return;
|
|
165
|
+
panel.replaceChildren(...build(() => panel.hidePopover()).filter(Boolean));
|
|
166
|
+
const at = button.getBoundingClientRect();
|
|
167
|
+
panel.style.top = `${Math.round(at.bottom + 6)}px`;
|
|
168
|
+
panel.style.left = `${Math.round(Math.max(8, Math.min(at.right - panel.offsetWidth, innerWidth - panel.offsetWidth - 8)))}px`;
|
|
169
|
+
});
|
|
170
|
+
return panel;
|
|
171
|
+
}
|
|
172
|
+
const item = (iconName, text, onclick, { disabled: off = false, title = null, role = 'menuitem', checked = null } = {}) => h('button', {
|
|
173
|
+
type: 'button', class: 'fx-pop__item', role, disabled: off, title, onclick, ...(checked === null ? {} : { 'aria-checked': String(checked) }),
|
|
174
|
+
}, h('span', { class: 'fx-pop__tick' }, iconName ? lucide(h, iconName) : null), h('span', {}, text));
|
|
175
|
+
|
|
176
|
+
const search = h('input', { class: 'fx-input fx-cfm__search', type: 'search', placeholder: 'Search fields…', 'aria-label': 'Search fields' });
|
|
177
|
+
search.addEventListener('input', () => { view.q = search.value.trim().toLowerCase(); draw(); });
|
|
178
|
+
const typeButton = h('button', { class: 'fx-chip fx-cfm__tool', type: 'button' });
|
|
179
|
+
const typeMenu = popMenu(typeButton, (close) => [
|
|
180
|
+
...data.types.map((t) => item(view.types.has(t) ? 'check' : null, TYPE_LABELS[t], () => {
|
|
181
|
+
if (view.types.has(t)) view.types.delete(t); else view.types.add(t);
|
|
182
|
+
close(); draw();
|
|
183
|
+
}, { role: 'menuitemcheckbox', checked: view.types.has(t) })),
|
|
184
|
+
view.types.size ? h('div', { class: 'fx-pop__sep' }) : null,
|
|
185
|
+
view.types.size ? item(null, 'All types', () => { view.types.clear(); close(); draw(); }) : null,
|
|
186
|
+
]);
|
|
187
|
+
const filterButton = h('button', { class: 'fx-chip fx-cfm__tool fx-cfm__tool--dashed', type: 'button' });
|
|
188
|
+
const filterMenu = popMenu(filterButton, (close) => [
|
|
189
|
+
item(view.archived ? 'check' : null, 'Show archived fields', () => { view.archived = !view.archived; close(); draw(); }, { role: 'menuitemcheckbox', checked: view.archived }),
|
|
190
|
+
]);
|
|
191
|
+
const toolbar = h('div', { class: 'fx-cfm__bar' }, search, typeButton, typeMenu, filterButton, filterMenu, h('span', { class: 'fx-cfm__gap' }), addButton);
|
|
192
|
+
const table = h('div', { class: 'fx-cfm', role: 'table', 'aria-label': 'Custom fields' });
|
|
193
|
+
host.replaceChildren(toolbar, table);
|
|
194
|
+
|
|
195
|
+
const personName = (id) => data.people?.find((p) => p.id === id)?.name ?? null;
|
|
196
|
+
const matches = (f) => !view.q || f.name.toLowerCase().includes(view.q)
|
|
197
|
+
|| (f.options ?? []).some((o) => !o.archived && o.name.toLowerCase().includes(view.q));
|
|
198
|
+
const byName = (a, b) => a.name.localeCompare(b.name) * (view.desc ? -1 : 1);
|
|
199
|
+
|
|
200
|
+
async function rename(field, name) {
|
|
201
|
+
view.renaming = null;
|
|
202
|
+
const clean = name.trim();
|
|
203
|
+
if (clean && clean !== field.name && await send(`/api/custom-fields/${field.id}`, 'PUT', { name: clean }, 'Renamed.')) return load();
|
|
204
|
+
return draw();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function duplicate(field) {
|
|
208
|
+
const taken = new Set(data.fields.map((f) => f.name.toLowerCase()));
|
|
209
|
+
let name = `${field.name} copy`.slice(0, 60);
|
|
210
|
+
for (let n = 2; taken.has(name.toLowerCase()); n += 1) name = `${field.name} copy ${n}`.slice(0, 60);
|
|
211
|
+
const payload = { name, type: field.type };
|
|
212
|
+
if (field.type === 'dropdown') payload.options = field.options.filter((o) => !o.archived).map(({ name: n, color }) => ({ name: n, color }));
|
|
213
|
+
if (await send('/api/custom-fields', 'POST', payload, `Made ${name}.`)) await load();
|
|
126
214
|
}
|
|
127
215
|
|
|
128
|
-
function
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
216
|
+
async function copyId(field) {
|
|
217
|
+
try {
|
|
218
|
+
await navigator.clipboard.writeText(field.id);
|
|
219
|
+
toast('Field ID copied.', 'success');
|
|
220
|
+
} catch {
|
|
221
|
+
toast(`Field ID: ${field.id}`, 'info');
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function setArchived(field, archived) {
|
|
226
|
+
if (await send(`/api/custom-fields/${field.id}/archive`, 'POST', { archived }, archived ? 'Archived. Its values are kept.' : 'Brought back.')) await load();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function rowMenu(field) {
|
|
230
|
+
const button = h('button', { class: 'fx-cfm__more', type: 'button', 'aria-label': `More for ${field.name}` }, lucide(h, 'ellipsis'));
|
|
231
|
+
const panel = popMenu(button, (close) => (field.archived
|
|
232
|
+
? [item('archive-restore', 'Bring back', () => { close(); setArchived(field, false); }, { disabled })]
|
|
233
|
+
: [
|
|
234
|
+
item('square-pen', 'Edit', () => { close(); edit(field); }, { disabled }),
|
|
235
|
+
item('pencil', 'Rename', () => { close(); view.renaming = field.id; draw(); }, { disabled }),
|
|
236
|
+
item('copy', 'Duplicate', () => { close(); duplicate(field); }, { disabled }),
|
|
237
|
+
item('repeat', 'Convert', null, { disabled: true, title: 'Changing a field\'s type is not in Flow yet.' }),
|
|
238
|
+
item('clipboard-copy', 'Copy field ID', () => { close(); copyId(field); }),
|
|
239
|
+
h('div', { class: 'fx-pop__sep' }),
|
|
240
|
+
item('archive', 'Archive', () => { close(); setArchived(field, true); }, { disabled }),
|
|
241
|
+
]));
|
|
242
|
+
return [button, panel];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function nameCell(field) {
|
|
246
|
+
if (view.renaming === field.id) {
|
|
247
|
+
const input = h('input', { class: 'fx-input fx-cfm__rename', value: field.name, maxlength: '60', 'aria-label': 'Field name' });
|
|
248
|
+
input.addEventListener('keydown', (event) => {
|
|
249
|
+
if (event.key === 'Enter') input.blur();
|
|
250
|
+
if (event.key === 'Escape') { view.renaming = null; draw(); }
|
|
144
251
|
});
|
|
145
|
-
|
|
252
|
+
input.addEventListener('blur', () => { if (view.renaming === field.id) rename(field, input.value); });
|
|
253
|
+
queueMicrotask(() => { input.focus(); input.select(); });
|
|
254
|
+
return h('div', { class: 'fx-cfm__name', role: 'cell' }, input);
|
|
146
255
|
}
|
|
147
|
-
|
|
256
|
+
const editButton = field.archived || disabled || field.source ? null
|
|
257
|
+
: h('button', { class: 'fx-cfm__edit', type: 'button', onclick: () => edit(field) }, 'Edit', lucide(h, 'panel-right'));
|
|
258
|
+
return h('div', { class: 'fx-cfm__name', role: 'cell' },
|
|
259
|
+
field.source ? lucide(h, 'lock', 'Read only') : null,
|
|
260
|
+
h('span', { class: 'fx-cfm__label', title: field.name }, field.name), editButton);
|
|
148
261
|
}
|
|
149
262
|
|
|
150
|
-
function
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
h('
|
|
263
|
+
function fieldRow(field) {
|
|
264
|
+
const by = field.source ? field.sourceLabel : personName(field.createdBy);
|
|
265
|
+
return h('div', { class: 'fx-cfm__row', role: 'row', 'data-archived': field.archived ? 'true' : null },
|
|
266
|
+
nameCell(field),
|
|
267
|
+
h('div', { class: 'fx-cfm__type', role: 'cell', 'data-color': TYPE_COLORS[field.type] }, lucide(h, TYPE_ICONS[field.type], TYPE_LABELS[field.type])),
|
|
268
|
+
h('div', { class: 'fx-cfm__by', role: 'cell' }, by ? h('span', { class: 'fx-cfm__avatar', title: by }, field.source ? lucide(h, 'plug') : initials(by)) : h('span', { class: 'fx-dim' }, '—')),
|
|
269
|
+
h('div', { class: 'fx-cfm__date', role: 'cell' }, field.source ? h('span', { class: 'fx-dim' }, '—') : createdDay(field.createdAt)),
|
|
270
|
+
h('div', { class: 'fx-cfm__where', role: 'cell' }, field.source ? `${field.sourceLabel} jobs` : 'Jobs'),
|
|
271
|
+
h('div', { class: 'fx-cfm__end', role: 'cell' }, field.source ? null : rowMenu(field)));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// One group: a header that folds it, its rows, and "Create … field". A
|
|
275
|
+
// search opens every group that has a match.
|
|
276
|
+
function group(key, pill, rows, { create = null } = {}) {
|
|
277
|
+
const open = Boolean(view.q) || !view.folded.has(key);
|
|
278
|
+
const toggle = h('button', { class: 'fx-cfm__fold', type: 'button', 'aria-expanded': String(open), 'aria-label': `${open ? 'Fold' : 'Open'} ${pill.text}` }, lucide(h, 'chevron-down'));
|
|
279
|
+
toggle.addEventListener('click', () => {
|
|
280
|
+
if (open) view.folded.add(key); else view.folded.delete(key);
|
|
281
|
+
saveFolded(view.folded);
|
|
282
|
+
draw();
|
|
283
|
+
});
|
|
284
|
+
const head = h('div', { class: 'fx-cfm__group', role: 'row' }, toggle,
|
|
285
|
+
h('span', { class: 'fx-cfm__pill', 'data-color': pill.color }, pill.icon ? lucide(h, pill.icon) : null, pill.text),
|
|
286
|
+
h('span', { class: 'fx-cfm__count' }, String(rows.length)));
|
|
287
|
+
if (!open) return [head];
|
|
288
|
+
const out = [head, ...rows.map(fieldRow)];
|
|
289
|
+
if (create) out.push(create);
|
|
290
|
+
return out;
|
|
155
291
|
}
|
|
156
292
|
|
|
157
293
|
function draw() {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
294
|
+
if (!data) return;
|
|
295
|
+
typeButton.replaceChildren(lucide(h, 'layers'), h('span', {}, view.types.size === 1 ? TYPE_LABELS[[...view.types][0]] : view.types.size ? `${view.types.size} types` : 'Field type'), lucide(h, 'chevron-down'));
|
|
296
|
+
typeButton.classList.toggle('fx-chip--on', view.types.size > 0);
|
|
297
|
+
filterButton.replaceChildren(lucide(h, 'list-filter'), h('span', {}, view.archived ? 'Archived shown' : 'Filter'), lucide(h, 'chevron-down'));
|
|
298
|
+
filterButton.classList.toggle('fx-chip--on', view.archived);
|
|
299
|
+
|
|
300
|
+
const shown = (type) => !view.types.size || view.types.has(type);
|
|
301
|
+
const sortHead = h('button', { class: 'fx-cfm__sort', type: 'button', 'aria-label': `Sort by name, ${view.desc ? 'Z to A' : 'A to Z'}` }, 'Name', lucide(h, view.desc ? 'arrow-up-z-a' : 'arrow-down-a-z'));
|
|
302
|
+
sortHead.addEventListener('click', () => { view.desc = !view.desc; draw(); });
|
|
303
|
+
const parts = [h('div', { class: 'fx-cfm__head', role: 'row' },
|
|
304
|
+
h('div', { role: 'columnheader' }, sortHead), h('div', { role: 'columnheader' }, 'Type'), h('div', { role: 'columnheader', class: 'fx-cfm__by' }, 'Created by'),
|
|
305
|
+
h('div', { role: 'columnheader', class: 'fx-cfm__date' }, 'Date created'), h('div', { role: 'columnheader', class: 'fx-cfm__where' }, 'Location'), h('div', { role: 'columnheader' }))];
|
|
306
|
+
if (disabled) parts.unshift(notice('Only an owner or admin can change these. Everyone can set a job\'s values.', 'info'));
|
|
307
|
+
|
|
308
|
+
// Type groups in ClickUp's order: by type name.
|
|
309
|
+
const types = [...data.types].sort((a, b) => TYPE_LABELS[a].localeCompare(TYPE_LABELS[b]));
|
|
310
|
+
let count = 0;
|
|
311
|
+
for (const type of types) {
|
|
312
|
+
if (!shown(type)) continue;
|
|
313
|
+
const fields = data.fields.filter((f) => f.type === type && matches(f)).sort(byName);
|
|
314
|
+
if (!fields.length && (view.q || view.types.size === 0)) continue;
|
|
315
|
+
count += fields.length;
|
|
316
|
+
const create = view.q || disabled ? null : h('button', { class: 'fx-cfm__create', type: 'button', onclick: () => edit(null, { type, name: '', options: undefined }) },
|
|
317
|
+
lucide(h, 'plus'), `Create ${TYPE_LABELS[type].toLowerCase()} field`);
|
|
318
|
+
parts.push(...group(type, { text: TYPE_LABELS[type], icon: TYPE_ICONS[type], color: TYPE_COLORS[type] }, fields, { create }));
|
|
319
|
+
}
|
|
320
|
+
// The work systems' own fields: one read-only group per system.
|
|
321
|
+
for (const source of [...new Set(data.systemFields.map((f) => f.source))]) {
|
|
322
|
+
const fields = data.systemFields.filter((f) => f.source === source && shown(f.type))
|
|
323
|
+
.map((f) => ({ ...f, id: `${f.source}:${f.name}`, name: `${f.sourceLabel} ${f.name.toLowerCase()}` })).filter(matches);
|
|
324
|
+
if (!fields.length) continue;
|
|
325
|
+
count += fields.length;
|
|
326
|
+
parts.push(...group(`source:${source}`, { text: `From ${fields[0].sourceLabel}`, icon: 'lock', color: 'gray' }, fields));
|
|
327
|
+
}
|
|
328
|
+
if (view.archived) {
|
|
329
|
+
const fields = data.archived.filter((f) => f.archived && shown(f.type) && matches(f)).sort(byName);
|
|
330
|
+
count += fields.length;
|
|
331
|
+
parts.push(...group('archived', { text: 'Archived', icon: 'archive', color: 'gray' }, fields));
|
|
169
332
|
}
|
|
170
|
-
if (
|
|
171
|
-
|
|
333
|
+
if (!count && (view.q || view.types.size)) parts.push(h('p', { class: 'fx-cfm__empty' }, 'No field matches.'));
|
|
334
|
+
if (!data.fields.length && !view.q && !view.types.size) {
|
|
335
|
+
parts.push(h('p', { class: 'fx-cfm__empty' }, 'No fields yet. Create one, for example Priority or Technician, to group the Jobs board by it.'));
|
|
172
336
|
}
|
|
173
|
-
|
|
174
|
-
|
|
337
|
+
table.replaceChildren(...parts);
|
|
338
|
+
foot.replaceChildren(h('span', {}, `${data.fields.length} ${data.fields.length === 1 ? 'field' : 'fields'}`),
|
|
339
|
+
h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How custom fields work'));
|
|
175
340
|
}
|
|
341
|
+
const foot = h('div', { class: 'fx-cfm__foot' });
|
|
342
|
+
host.append(foot);
|
|
176
343
|
|
|
177
344
|
async function load() {
|
|
178
345
|
try {
|
package/server/ui/flow.css
CHANGED
|
@@ -223,6 +223,17 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
|
|
|
223
223
|
.fx-status[data-tone="warn"] { color: var(--t-warn); background: var(--t-warn-bg); }
|
|
224
224
|
.fx-status[data-tone="mute"] { color: var(--t-dim); background: var(--fx-hover); }
|
|
225
225
|
.fx-status[data-tone="danger"] { color: var(--t-danger); background: var(--t-danger-bg); }
|
|
226
|
+
.fx-links { white-space: nowrap; }
|
|
227
|
+
.fx-links > * + * { margin-left: 6px; }
|
|
228
|
+
.fx-link { position: relative; display: inline-grid; place-items: center; width: 26px; height: 26px; border: 1px solid var(--fx-line); border-radius: 8px; vertical-align: middle; }
|
|
229
|
+
.fx-link img { width: 15px; height: 15px; }
|
|
230
|
+
a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
|
|
231
|
+
.fx-link--none { border-style: dashed; }
|
|
232
|
+
.fx-link--none img { filter: grayscale(1); opacity: .35; }
|
|
233
|
+
.fx-link__dot { position: absolute; right: -3px; bottom: -3px; width: 10px; height: 10px; border-radius: 50%; border: 2px solid var(--t-paper); background: var(--t-dim); }
|
|
234
|
+
.fx-link__dot[data-tone="success"] { background: var(--t-success); }
|
|
235
|
+
.fx-link__dot[data-tone="warn"] { background: var(--t-warn); }
|
|
236
|
+
.fx-link__dot[data-tone="danger"] { background: var(--t-danger); }
|
|
226
237
|
.fx-tag {
|
|
227
238
|
margin-left: 8px; padding: 1px 7px; border: 1px solid var(--fx-line); border-radius: 999px;
|
|
228
239
|
color: var(--t-dim); font-size: 11.5px; font-weight: 600;
|
|
@@ -856,6 +867,69 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
856
867
|
.fx-cf__line .fx-input, .fx-cf__line .fx-select { width: 100%; min-width: 0; }
|
|
857
868
|
.fx-cf__color { border-left: 4px solid var(--cf); }
|
|
858
869
|
@media (max-width: 560px) { .fx-cf__line { grid-template-columns: minmax(0, 1fr) 96px; } }
|
|
870
|
+
/* Custom fields manager (custom-fields.js), like ClickUp's Custom Field
|
|
871
|
+
Manager: a toolbar, then one table of type groups that fold. */
|
|
872
|
+
.fx-lucide { display: inline-block; flex: none; width: 16px; height: 16px; background: currentColor; -webkit-mask: var(--icon) center / contain no-repeat; mask: var(--icon) center / contain no-repeat; }
|
|
873
|
+
.fx-cfm__card { padding: 0; overflow: hidden; }
|
|
874
|
+
.fx-cfm__bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 12px 16px; border-bottom: 1px solid var(--fx-line-soft); }
|
|
875
|
+
.fx-cfm__search { width: 220px; min-width: 0; height: 32px; }
|
|
876
|
+
.fx-cfm__tool { min-height: 32px; }
|
|
877
|
+
.fx-cfm__tool--dashed { border-style: dashed; }
|
|
878
|
+
.fx-cfm__gap { flex: 1; }
|
|
879
|
+
.fx-cfm { display: grid; grid-template-columns: minmax(160px, 1fr) 64px 96px 120px minmax(90px, .6fr) 44px; }
|
|
880
|
+
.fx-cfm > [role="row"], .fx-cfm > .fx-notice, .fx-cfm > .fx-cfm__create, .fx-cfm > .fx-cfm__empty { grid-column: 1 / -1; }
|
|
881
|
+
.fx-cfm__head, .fx-cfm__row { display: grid; grid-template-columns: subgrid; align-items: center; }
|
|
882
|
+
.fx-cfm__head { height: 36px; padding: 0 8px 0 36px; color: var(--t-dim); font-size: 12.5px; border-bottom: 1px solid var(--fx-line-soft); }
|
|
883
|
+
.fx-cfm__sort { all: unset; cursor: pointer; display: inline-flex; gap: 4px; align-items: center; }
|
|
884
|
+
.fx-cfm__sort:focus-visible { box-shadow: var(--fx-focus); border-radius: 4px; }
|
|
885
|
+
.fx-cfm__sort .fx-lucide { width: 13px; height: 13px; }
|
|
886
|
+
.fx-cfm__group { display: flex; align-items: center; gap: 8px; height: 44px; margin-top: 12px; padding: 0 16px 0 8px; border-bottom: 1px solid var(--fx-line-soft); }
|
|
887
|
+
.fx-cfm__group:first-of-type { margin-top: 4px; }
|
|
888
|
+
.fx-cfm__fold { all: unset; cursor: pointer; display: inline-grid; place-items: center; width: 24px; height: 24px; border-radius: 6px; color: var(--t-dim); }
|
|
889
|
+
.fx-cfm__fold:hover { background: var(--fx-hover); color: var(--t-strong); }
|
|
890
|
+
.fx-cfm__fold:focus-visible { box-shadow: var(--fx-focus); }
|
|
891
|
+
.fx-cfm__fold .fx-lucide { width: 14px; height: 14px; transition: transform .15s var(--fx-ease); }
|
|
892
|
+
.fx-cfm__fold[aria-expanded="false"] .fx-lucide { transform: rotate(-90deg); }
|
|
893
|
+
.fx-cfm__pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 6px; font-size: 12.5px; font-weight: 600; color: var(--cf); background: color-mix(in srgb, var(--cf) 14%, transparent); }
|
|
894
|
+
.fx-cfm__pill .fx-lucide { width: 14px; height: 14px; }
|
|
895
|
+
.fx-cfm__count { color: var(--t-dim); font-size: 12.5px; font-weight: 600; }
|
|
896
|
+
.fx-cfm__row { min-height: 36px; padding: 0 8px 0 36px; border-bottom: 1px solid var(--fx-line-soft); font-size: 14px; }
|
|
897
|
+
.fx-cfm__row:hover { background: var(--fx-hover); }
|
|
898
|
+
.fx-cfm__row[data-archived] .fx-cfm__label { color: var(--t-dim); }
|
|
899
|
+
.fx-cfm__name { display: flex; align-items: center; gap: 8px; min-width: 0; padding-right: 8px; color: var(--t-strong); }
|
|
900
|
+
.fx-cfm__name > .fx-lucide { width: 13px; height: 13px; color: var(--t-dim); }
|
|
901
|
+
.fx-cfm__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
902
|
+
.fx-cfm__edit { display: none; align-items: center; gap: 5px; flex: none; height: 24px; padding: 0 7px; border: 1px solid var(--fx-line); border-radius: 6px; background: var(--t-paper); color: var(--t-text); font: inherit; font-size: 12px; cursor: pointer; }
|
|
903
|
+
.fx-cfm__edit .fx-lucide { width: 13px; height: 13px; }
|
|
904
|
+
.fx-cfm__row:hover .fx-cfm__edit, .fx-cfm__edit:focus-visible { display: inline-flex; }
|
|
905
|
+
.fx-cfm__edit:focus-visible { box-shadow: var(--fx-focus); outline: none; }
|
|
906
|
+
.fx-cfm__rename { height: 28px; width: 100%; max-width: 320px; }
|
|
907
|
+
.fx-cfm__type { color: var(--cf); }
|
|
908
|
+
.fx-cfm__type .fx-lucide { width: 17px; height: 17px; }
|
|
909
|
+
.fx-cfm__avatar { display: inline-grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: var(--t-primary); color: var(--t-on-primary); font-size: 10.5px; font-weight: 700; letter-spacing: .02em; }
|
|
910
|
+
.fx-cfm__avatar .fx-lucide { width: 13px; height: 13px; }
|
|
911
|
+
.fx-cfm__date, .fx-cfm__where { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
912
|
+
.fx-cfm__row .fx-cfm__where { font-weight: 600; }
|
|
913
|
+
.fx-cfm__end { justify-self: end; }
|
|
914
|
+
.fx-cfm__more { all: unset; cursor: pointer; display: inline-grid; place-items: center; width: 30px; height: 28px; border-radius: 6px; color: var(--t-dim); }
|
|
915
|
+
.fx-cfm__more:hover, .fx-cfm__more[aria-expanded="true"] { background: var(--fx-hover); color: var(--t-strong); }
|
|
916
|
+
.fx-cfm__more:focus-visible { box-shadow: var(--fx-focus); }
|
|
917
|
+
.fx-cfm__create { all: unset; cursor: pointer; display: flex; align-items: center; gap: 6px; height: 36px; padding: 0 16px 0 36px; color: var(--t-dim); font-size: 13px; }
|
|
918
|
+
.fx-cfm__create:hover, .fx-cfm__create:focus-visible { color: var(--t-strong); background: var(--fx-hover); }
|
|
919
|
+
.fx-cfm__create .fx-lucide { width: 14px; height: 14px; }
|
|
920
|
+
.fx-cfm__empty { margin: 0; padding: 20px 16px; color: var(--t-dim); }
|
|
921
|
+
.fx-cfm > .fx-notice { margin: 12px 16px 0; }
|
|
922
|
+
.fx-cfm__foot { display: flex; justify-content: space-between; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--fx-line-soft); color: var(--t-dim); font-size: 13px; }
|
|
923
|
+
.fx-pop__item:disabled { opacity: .5; cursor: default; }
|
|
924
|
+
.fx-pop__item .fx-lucide { width: 15px; height: 15px; }
|
|
925
|
+
@media (max-width: 720px) {
|
|
926
|
+
.fx-cfm { grid-template-columns: minmax(0, 1fr) 40px 40px; }
|
|
927
|
+
.fx-cfm .fx-cfm__by, .fx-cfm .fx-cfm__date, .fx-cfm .fx-cfm__where { display: none; }
|
|
928
|
+
.fx-cfm__head, .fx-cfm__row { padding-left: 16px; }
|
|
929
|
+
.fx-cfm__create { padding-left: 16px; }
|
|
930
|
+
.fx-cfm__search { flex: 1 1 100%; width: auto; }
|
|
931
|
+
.fx-cfm__edit { display: none !important; }
|
|
932
|
+
}
|
|
859
933
|
/* Invoice rules (invoice-rules.js): a row's Edit button and switch, and the
|
|
860
934
|
rule dialog's condition lines. */
|
|
861
935
|
.fx-rule__controls { align-items: center; gap: 10px; }
|
package/server/ui/invoicing.js
CHANGED
|
@@ -9,14 +9,14 @@ import { alertStrip } from '/assets/ui/alerts.js';
|
|
|
9
9
|
import { draftNote, sendModeNote } from '/assets/ui/invoice-notes.js';
|
|
10
10
|
import { jobChangeBanner } from '/assets/ui/job-change.js';
|
|
11
11
|
|
|
12
|
-
const STATUS_LABEL = { draft: 'Draft', issued: '
|
|
12
|
+
const STATUS_LABEL = { draft: 'Draft', issued: 'Finalised', void: 'Void' };
|
|
13
13
|
const STATUS_TONE = { draft: 'info', issued: 'success', void: 'mute' };
|
|
14
14
|
// Drafts (to check), Problem and Ready are views (server/core/sending.js):
|
|
15
15
|
// every draft is in one of them. A person marks a checked draft ready; a
|
|
16
16
|
// saved change keeps it there. An issued invoice that failed to send is a
|
|
17
17
|
// problem.
|
|
18
18
|
// Ready first: it opens by default, as the drafts most often wait there to send.
|
|
19
|
-
const TABS = [['ready', 'Ready'], ['to_check', 'Drafts'], ['problem', 'Problem'], ['issued', '
|
|
19
|
+
const TABS = [['ready', 'Ready'], ['to_check', 'Drafts'], ['problem', 'Problem'], ['issued', 'Finalised'], ['void', 'Void']];
|
|
20
20
|
const VIEW_LABEL = { problem: 'Problem', ready: 'Ready', sending: 'Sending', to_check: 'Draft' };
|
|
21
21
|
const VIEW_TONE = { problem: 'danger', ready: 'success', sending: 'info', to_check: 'info' };
|
|
22
22
|
const EMPTY = {
|
|
@@ -46,6 +46,35 @@ export function xeroTag(ui, status) {
|
|
|
46
46
|
: h('span', { class: 'fx-status', 'data-tone': XERO_TONE[status.state] ?? 'mute' }, status.label);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// The Linked column: one icon for each connected system (linksTo), with a
|
|
50
|
+
// dot for the state of this invoice's link there. An icon with a link opens
|
|
51
|
+
// the record in that system. Accounting icons show on numbered invoices only.
|
|
52
|
+
const LINK_TONE = {
|
|
53
|
+
not_sent: 'warn', sending: 'warn', not_copied: 'warn', draft: 'success',
|
|
54
|
+
approved: 'success', part_paid: 'success', paid: 'success', voided: 'mute', deleted: 'mute',
|
|
55
|
+
};
|
|
56
|
+
export function linkIcon(ui, system, { tone = null, title, url = null } = {}) {
|
|
57
|
+
const { h } = ui;
|
|
58
|
+
const kids = [h('img', { src: `/assets/ui/logos/${system.id}.svg`, alt: '' }), tone ? h('i', { class: 'fx-link__dot', 'data-tone': tone }) : null];
|
|
59
|
+
const props = { class: `fx-link${tone ? '' : ' fx-link--none'}`, title, 'aria-label': title };
|
|
60
|
+
return url
|
|
61
|
+
? h('a', { ...props, href: url, target: '_blank', rel: 'noopener', onclick: (event) => event.stopPropagation() }, ...kids)
|
|
62
|
+
: h('span', props, ...kids);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function workIcon(ui, system, job) {
|
|
66
|
+
if (job?.sourceSystem !== system.id) return linkIcon(ui, system, { title: `Not linked to ${system.name}` });
|
|
67
|
+
const ref = job.caseRef ?? job.sourceJobRef;
|
|
68
|
+
return linkIcon(ui, system, { tone: 'success', title: `${system.name} ${ref}${job.link?.url ? `. Open in ${system.name}.` : ''}`, url: job.link?.url ?? null });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function accountingIcon(ui, system, status) {
|
|
72
|
+
const tone = status.finding ? 'danger' : LINK_TONE[status.state] ?? 'mute';
|
|
73
|
+
const number = status.xero?.number ? ` ${status.xero.number}` : '';
|
|
74
|
+
const title = `${system.name}${number}: ${status.finding ? status.message : status.label}${status.xero?.url ? ` Open in ${system.name}.` : ''}`;
|
|
75
|
+
return linkIcon(ui, system, { tone, title, url: status.xero?.url ?? null });
|
|
76
|
+
}
|
|
77
|
+
|
|
49
78
|
const showField = (field, value) => (['subtotal', 'tax', 'total'].includes(field) && Number.isFinite(value) ? formatCents(value) : String(value ?? '—'));
|
|
50
79
|
|
|
51
80
|
// Xero's status for some invoices, or an empty map while Xero is off.
|
|
@@ -153,13 +182,13 @@ export async function renderInvoiceList(main, ui) {
|
|
|
153
182
|
}
|
|
154
183
|
if (context.canIssue && body.counts.ready > 0) {
|
|
155
184
|
buttons.push(action(to ? `Send all ${body.counts.ready} ready to ${to}` : `Finalise all ${body.counts.ready} ready`, 'send-ready', () => ({ issueDate: localDate() }),
|
|
156
|
-
(res) => [`${res.issued}
|
|
185
|
+
(res) => [`${res.issued} finalised.${to ? ` They are on the way to ${to}.` : ''}${res.remaining ? ` ${res.remaining} more are ready: press again.` : ''}`, 'success']));
|
|
157
186
|
}
|
|
158
187
|
if (buttons.length) parts.push(h('div', { class: 'fx-sendbar__actions' }, buttons));
|
|
159
188
|
} else if (state.status === 'problem') {
|
|
160
189
|
const failed = body.invoices.filter((i) => i.status === 'issued').length;
|
|
161
190
|
if (context.canIssue && to && failed > 0) {
|
|
162
|
-
parts.push(h('p', { class: 'fx-dim' }, `${plural(failed, '
|
|
191
|
+
parts.push(h('p', { class: 'fx-dim' }, `${plural(failed, 'finalised invoice', 'finalised invoices')} did not reach ${to}. Fix the cause, then send again.`));
|
|
163
192
|
parts.push(action('Send again', 'send-again', () => ({}), (res) => [`${res.queued} on the way to ${to} again.`, 'success']));
|
|
164
193
|
}
|
|
165
194
|
}
|
|
@@ -232,8 +261,12 @@ export async function renderInvoiceList(main, ui) {
|
|
|
232
261
|
const showProblem = state.status === 'problem';
|
|
233
262
|
const created = ['to_check', 'problem', 'ready'].includes(state.status);
|
|
234
263
|
// An invoice gets its number when it is sent or issued, so only the
|
|
235
|
-
//
|
|
264
|
+
// Finalised and Void tabs show a Number column; the others open on Client.
|
|
236
265
|
const numbered = !created;
|
|
266
|
+
// With an integration on, every tab has a Linked column (linkIcon).
|
|
267
|
+
const systems = body.linksTo ?? [];
|
|
268
|
+
const linked = systems.length > 0;
|
|
269
|
+
const accountingSystem = systems.find((s) => s.kind === 'accounting') ?? null;
|
|
237
270
|
const boxes = [];
|
|
238
271
|
const tickBox = (invoice) => {
|
|
239
272
|
const box = h('input', {
|
|
@@ -258,7 +291,8 @@ export async function renderInvoiceList(main, ui) {
|
|
|
258
291
|
h('thead', {}, h('tr', {},
|
|
259
292
|
ticks ? h('th', { class: 'fx-tick-cell' }, all) : null,
|
|
260
293
|
numbered ? h('th', {}, 'Number') : null, h('th', {}, 'Client'), h('th', {}, showProblem ? 'Problem' : 'Reference'),
|
|
261
|
-
h('th', {}, created ? 'Created' : '
|
|
294
|
+
h('th', {}, created ? 'Created' : 'Finalised'), h('th', { class: 'fx-num' }, 'Total'), h('th', {}, 'Status'),
|
|
295
|
+
linked ? h('th', {}, 'Linked') : null)),
|
|
262
296
|
h('tbody', {}, body.invoices.map((invoice) => {
|
|
263
297
|
const href = `/invoices/${invoice.id}`;
|
|
264
298
|
const first = invoice.problems?.[0];
|
|
@@ -276,17 +310,25 @@ export async function renderInvoiceList(main, ui) {
|
|
|
276
310
|
const cell = h('td', { class: 'fx-status-cell' }, view
|
|
277
311
|
? h('span', { class: 'fx-status', 'data-tone': VIEW_TONE[view] }, VIEW_LABEL[view])
|
|
278
312
|
: h('span', { class: 'fx-status', 'data-tone': STATUS_TONE[invoice.status] }, STATUS_LABEL[invoice.status]));
|
|
313
|
+
if (!linked) statusCells.set(invoice.id, cell);
|
|
314
|
+
return cell;
|
|
315
|
+
})(),
|
|
316
|
+
linked ? (() => {
|
|
317
|
+
const cell = h('td', { class: 'fx-links' }, systems.filter((s) => s.kind === 'work').map((s) => workIcon(ui, s, invoice.job)));
|
|
279
318
|
statusCells.set(invoice.id, cell);
|
|
280
319
|
return cell;
|
|
281
|
-
})());
|
|
320
|
+
})() : null);
|
|
282
321
|
}))));
|
|
283
|
-
//
|
|
284
|
-
|
|
322
|
+
// Finalised and void invoices also show Xero's status, from Flow's copy:
|
|
323
|
+
// in the Linked column every one gets an icon, so one not in Xero
|
|
324
|
+
// stands out.
|
|
325
|
+
if (numbered) {
|
|
285
326
|
const shownBody = body;
|
|
286
327
|
const statuses = await xeroStatuses(ui, context.organisations?.[0]?.id, body.invoices.map((i) => i.id));
|
|
287
328
|
if (shownBody !== body) return;
|
|
288
329
|
for (const [invoiceId, status] of statuses) {
|
|
289
|
-
if (
|
|
330
|
+
if (linked) { if (accountingSystem) statusCells.get(invoiceId)?.append(accountingIcon(ui, accountingSystem, status)); }
|
|
331
|
+
else if (status.state !== 'not_sent' || status.finding) statusCells.get(invoiceId)?.append(' ', xeroTag(ui, status));
|
|
290
332
|
}
|
|
291
333
|
}
|
|
292
334
|
}
|
|
@@ -307,7 +349,7 @@ function saveNav(nav) {
|
|
|
307
349
|
|
|
308
350
|
// The bar above one invoice: Back to the list as it was, and the previous and
|
|
309
351
|
// next invoice of that list. An invoice opened from elsewhere (the job board,
|
|
310
|
-
// search, a link) steps through its own tab: Drafts, Ready, Problem,
|
|
352
|
+
// search, a link) steps through its own tab: Drafts, Ready, Problem, Finalised
|
|
311
353
|
// or Void, read once here.
|
|
312
354
|
function navBar(ui, invoice) {
|
|
313
355
|
const { h, go, flow } = ui;
|
|
@@ -826,7 +868,7 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
826
868
|
const voidBody = () => {
|
|
827
869
|
const live = xeroStatus?.xero && !['VOIDED', 'DELETED'].includes(xeroStatus.xero.status);
|
|
828
870
|
return h('div', {},
|
|
829
|
-
h('p', {}, 'The number stays used. A void invoice cannot be
|
|
871
|
+
h('p', {}, 'The number stays used. A void invoice cannot be finalised again.'),
|
|
830
872
|
live ? h('p', { class: 'fx-problem' }, `Xero still holds it as ${xeroStatus.label.replace(/ in Xero$/, '').toLowerCase()}${xeroStatus.xero.number ? ` (${xeroStatus.xero.number})` : ''}. Flow does not change Xero: void it in Xero too.`) : null);
|
|
831
873
|
};
|
|
832
874
|
const actions = invoice.status === 'issued' && context.canIssue ? [
|
|
@@ -843,7 +885,7 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
843
885
|
h('div', { class: 'fx-row' },
|
|
844
886
|
h('h1', { class: 'fx-title' }, invoice.number),
|
|
845
887
|
h('span', { class: 'fx-status', 'data-tone': STATUS_TONE[invoice.status] }, STATUS_LABEL[invoice.status])),
|
|
846
|
-
h('p', { class: 'fx-ph__sub' }, `${invoice.contact?.name ?? ''} ·
|
|
888
|
+
h('p', { class: 'fx-ph__sub' }, `${invoice.contact?.name ?? ''} · finalised ${showDate(invoice.issueDate)}`)),
|
|
847
889
|
h('div', { class: 'fx-ph__actions' }, actions, previewButton(ui, invoice.id), send)),
|
|
848
890
|
h('div', { class: 'fx-invoice__grid' },
|
|
849
891
|
h('div', { class: 'fx-invoice__main' },
|
|
@@ -851,7 +893,7 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
851
893
|
h('dl', { class: 'fx-doc__meta' },
|
|
852
894
|
h('div', {}, h('dt', { class: 'fx-label' }, 'Bill to'), h('dd', {}, invoice.contact?.name ?? '—', invoice.contact?.email ? h('span', { class: 'fx-dim' }, invoice.contact.email) : null)),
|
|
853
895
|
h('div', {}, h('dt', { class: 'fx-label' }, 'Reference'), h('dd', {}, invoice.reference ?? '—')),
|
|
854
|
-
h('div', {}, h('dt', { class: 'fx-label' }, '
|
|
896
|
+
h('div', {}, h('dt', { class: 'fx-label' }, 'Finalised'), h('dd', {}, showDate(invoice.issueDate))),
|
|
855
897
|
h('div', {}, h('dt', { class: 'fx-label' }, 'Due'), h('dd', {}, showDate(invoice.dueDate)))),
|
|
856
898
|
h('table', { class: 'fx-lines__table fx-lines__table--read' },
|
|
857
899
|
h('thead', {}, h('tr', {}, h('th', {}, 'Description'), h('th', { class: 'fx-num' }, 'Qty'), h('th', { class: 'fx-num' }, 'Unit price'), h('th', {}, 'Tax'), h('th', { class: 'fx-num' }, 'Amount'))),
|
|
@@ -868,7 +910,7 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
868
910
|
invoice.versions?.length ? h('section', { class: 'fx-card fx-side-card' },
|
|
869
911
|
h('h3', { class: 'fx-label' }, 'History'),
|
|
870
912
|
h('ol', { class: 'fx-timeline' }, invoice.versions.map((v) => h('li', {},
|
|
871
|
-
h('span', { class: 'fx-strong' }, v.version === 1 ? '
|
|
913
|
+
h('span', { class: 'fx-strong' }, v.version === 1 ? 'Finalised' : `Revised (version ${v.version})`),
|
|
872
914
|
h('span', { class: 'fx-dim' }, new Date(v.issuedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })))))) : null)),
|
|
873
915
|
];
|
|
874
916
|
}
|
package/server/ui/routes.js
CHANGED
|
@@ -11,6 +11,7 @@ import { themeStylesheet } from '../theme/themes.js';
|
|
|
11
11
|
// /assets/theme/<file>.js the theme engine itself, as browser modules
|
|
12
12
|
// /assets/ui/<file> Flow's components (flow.css) and page scripts
|
|
13
13
|
// /assets/fonts/<pkg>/… the families' faces (server/theme/font-families.js)
|
|
14
|
+
// /assets/icons/<name>.svg one Lucide icon (lucide-static, ISC), for any page
|
|
14
15
|
// Only the files named here are served. They change only with a release, so
|
|
15
16
|
// browsers may keep them for an hour.
|
|
16
17
|
|
|
@@ -21,6 +22,8 @@ const logoDir = join(here, 'logos');
|
|
|
21
22
|
const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
|
|
22
23
|
const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
|
|
23
24
|
const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'custom-fields.js', 'jobs.js', 'job-board.js', 'job-view.js', 'monitoring.js', 'search.js', 'alerts.js', 'flow-mark.svg']);
|
|
25
|
+
const iconDir = join(dirname(require.resolve('lucide-static/package.json')), 'icons');
|
|
26
|
+
const ICON_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/;
|
|
24
27
|
const MAX_AGE = '1h';
|
|
25
28
|
|
|
26
29
|
// Send one named file from dir. Passing dir as root limits the dotfile check
|
|
@@ -49,6 +52,13 @@ export function createUiAssetsRouter({ rateLimitPerMinute = 600 } = {}) {
|
|
|
49
52
|
// Each integration's logo, the maker's own mark (Integrations).
|
|
50
53
|
router.get('/assets/ui/logos/:file', sendFrom(logoDir, LOGO_FILES));
|
|
51
54
|
|
|
55
|
+
// The whole Lucide set: a page names any icon, and only the icons a page
|
|
56
|
+
// uses are fetched. A name outside the set falls through to the 404.
|
|
57
|
+
router.get('/assets/icons/:file', (req, res, next) => {
|
|
58
|
+
if (!ICON_FILE.test(req.params.file)) return next();
|
|
59
|
+
sendNamed(res, next, iconDir, req.params.file);
|
|
60
|
+
});
|
|
61
|
+
|
|
52
62
|
for (const name of FONT_PACKAGES) {
|
|
53
63
|
const dir = dirname(require.resolve(`@fontsource-variable/${name}/LICENSE`));
|
|
54
64
|
router.get(`/assets/fonts/${name}/index.css`, (_req, res, next) => sendNamed(res, next, dir, 'index.css'));
|