Added excel import
This commit is contained in:
Generated
+166
-1
@@ -11,7 +11,8 @@
|
|||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1"
|
"react-dom": "18.3.1",
|
||||||
|
"read-excel-file": "^9.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "18.3.12",
|
"@types/react": "18.3.12",
|
||||||
@@ -1212,6 +1213,15 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xmldom/xmldom": {
|
||||||
|
"version": "0.9.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
|
||||||
|
"integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ansi-regex": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
@@ -1249,6 +1259,12 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bluebird": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.2",
|
"version": "4.28.2",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
||||||
@@ -1349,6 +1365,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/core-util-is": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -1389,6 +1411,15 @@
|
|||||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/duplexer2": {
|
||||||
|
"version": "0.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
|
||||||
|
"integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"readable-stream": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.331",
|
"version": "1.5.331",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz",
|
||||||
@@ -1451,6 +1482,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/find-up": {
|
"node_modules/find-up": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
@@ -1464,6 +1501,20 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fs-extra": {
|
||||||
|
"version": "11.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz",
|
||||||
|
"integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.2.0",
|
||||||
|
"jsonfile": "^6.0.1",
|
||||||
|
"universalify": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1498,6 +1549,18 @@
|
|||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/graceful-fs": {
|
||||||
|
"version": "4.2.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/is-fullwidth-code-point": {
|
"node_modules/is-fullwidth-code-point": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
@@ -1507,6 +1570,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/isarray": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -1539,6 +1608,18 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonfile": {
|
||||||
|
"version": "6.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
||||||
|
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"universalify": "^2.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"graceful-fs": "^4.1.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
@@ -1599,6 +1680,12 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-int64": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.37",
|
"version": "2.0.37",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
|
||||||
@@ -1696,6 +1783,12 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/process-nextick-args": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/qrcode": {
|
"node_modules/qrcode": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
@@ -1748,6 +1841,35 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/read-excel-file": {
|
||||||
|
"version": "9.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/read-excel-file/-/read-excel-file-9.0.9.tgz",
|
||||||
|
"integrity": "sha512-FWwC3IypIQDVPTtO4pz0Sq6An7lQI17pXqCusaTX8yi3p9CCRtXx/SI3BtcPSTaLhwcwr9mI+KXSa/dWMmnvjQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@xmldom/xmldom": "^0.9.9",
|
||||||
|
"fflate": "^0.8.2",
|
||||||
|
"unzipper": "^0.12.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@@ -1808,6 +1930,12 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
@@ -1843,6 +1971,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
@@ -1889,6 +2026,28 @@
|
|||||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/universalify": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unzipper": {
|
||||||
|
"version": "0.12.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz",
|
||||||
|
"integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bluebird": "~3.7.2",
|
||||||
|
"duplexer2": "~0.1.4",
|
||||||
|
"fs-extra": "^11.2.0",
|
||||||
|
"graceful-fs": "^4.2.2",
|
||||||
|
"node-int64": "^0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
@@ -1920,6 +2079,12 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/util-deprecate": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.10",
|
"version": "5.4.10",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz",
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1"
|
"react-dom": "18.3.1",
|
||||||
|
"read-excel-file": "^9.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "18.3.12",
|
"@types/react": "18.3.12",
|
||||||
|
|||||||
+451
-1
@@ -199,6 +199,32 @@ type BirdFormState = {
|
|||||||
publicProfileEnabled: boolean;
|
publicProfileEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BirdImportWeight = {
|
||||||
|
weightGrams: number;
|
||||||
|
recordedOn: string;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BirdImportProfile = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
tagId: string;
|
||||||
|
species: string;
|
||||||
|
favoriteSnack: string;
|
||||||
|
motivators: string;
|
||||||
|
demotivators: string;
|
||||||
|
gender: BirdGender;
|
||||||
|
dateOfBirth: string;
|
||||||
|
gotchaDay: string;
|
||||||
|
chartColor: string;
|
||||||
|
weights: BirdImportWeight[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type BirdImportPreview = {
|
||||||
|
profiles: BirdImportProfile[];
|
||||||
|
errors: string[];
|
||||||
|
};
|
||||||
|
|
||||||
type PublicBirdProfile = {
|
type PublicBirdProfile = {
|
||||||
id: string;
|
id: string;
|
||||||
workspaceId: number;
|
workspaceId: number;
|
||||||
@@ -327,7 +353,7 @@ type PhotoDragState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type AppPage = 'overview' | 'flock' | 'settings' | 'admin';
|
type AppPage = 'overview' | 'flock' | 'settings' | 'admin';
|
||||||
type SettingsSection = 'collaborators' | 'integration-tokens' | 'new-workspace' | 'flock-member' | 'transfer';
|
type SettingsSection = 'collaborators' | 'integration-tokens' | 'new-workspace' | 'flock-member' | 'bird-import' | 'transfer';
|
||||||
|
|
||||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api';
|
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api';
|
||||||
const sessionTokenStorageKey = 'flockpal_auth_token';
|
const sessionTokenStorageKey = 'flockpal_auth_token';
|
||||||
@@ -373,6 +399,179 @@ const QrCodeWithLogo = ({ value, label }: { value: string; label: string }) => {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const importHeaderAliases = {
|
||||||
|
name: ['bird name', 'name'],
|
||||||
|
tagId: ['band id', 'tag id', 'band'],
|
||||||
|
species: ['species'],
|
||||||
|
favoriteSnack: ['favorite snack', 'favorite treat', 'treat'],
|
||||||
|
motivators: ['motivators'],
|
||||||
|
demotivators: ['demotivators', 'demotivates'],
|
||||||
|
gender: ['gender'],
|
||||||
|
dateOfBirth: ['hatch day', 'hatch date', 'date of birth', 'dob'],
|
||||||
|
gotchaDay: ['gotcha day', 'gotcha date'],
|
||||||
|
chartColor: ['chart color', 'color'],
|
||||||
|
weightGrams: ['weight grams', 'weight g', 'weight'],
|
||||||
|
weightDate: ['weight date', 'recorded on', 'weight recorded on'],
|
||||||
|
weightNotes: ['weight notes', 'weight note'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const normalizeImportHeader = (value: string) => value.trim().toLowerCase().replace(/[_-]+/g, ' ').replace(/\s+/g, ' ');
|
||||||
|
|
||||||
|
const readImportCell = (row: Record<string, unknown>, aliases: readonly string[]) => {
|
||||||
|
const matchingEntry = Object.entries(row).find(([header]) => aliases.includes(normalizeImportHeader(header)));
|
||||||
|
return matchingEntry?.[1] ?? '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const toImportText = (value: unknown) => (value === null || value === undefined ? '' : String(value).trim());
|
||||||
|
|
||||||
|
const formatImportDate = (value: Date) => {
|
||||||
|
if (Number.isNaN(value.getTime())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const month = `${value.getMonth() + 1}`.padStart(2, '0');
|
||||||
|
const day = `${value.getDate()}`.padStart(2, '0');
|
||||||
|
return `${value.getFullYear()}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toImportDate = (value: unknown) => {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return formatImportDate(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return formatImportDate(new Date(1899, 11, 30 + Math.floor(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = toImportText(value);
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatImportDate(new Date(text));
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseImportGender = (value: unknown): BirdGender | null => {
|
||||||
|
const gender = toImportText(value).toLowerCase();
|
||||||
|
|
||||||
|
if (!gender || gender === 'unknown') {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gender === 'male' || gender === 'female') {
|
||||||
|
return gender;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBirdImportKey = (name: string, tagId: string) => (tagId ? `band:${tagId.toLowerCase()}` : `name:${name.toLowerCase()}`);
|
||||||
|
|
||||||
|
const mergeImportText = (current: string, next: string) => current || next;
|
||||||
|
|
||||||
|
const parseBirdImportRows = (rows: Record<string, unknown>[]): BirdImportPreview => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const profiles = new Map<string, BirdImportProfile>();
|
||||||
|
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
const rowNumber = index + 2;
|
||||||
|
const name = toImportText(readImportCell(row, importHeaderAliases.name));
|
||||||
|
const tagId = toImportText(readImportCell(row, importHeaderAliases.tagId));
|
||||||
|
const gender = parseImportGender(readImportCell(row, importHeaderAliases.gender));
|
||||||
|
const dateOfBirthValue = readImportCell(row, importHeaderAliases.dateOfBirth);
|
||||||
|
const gotchaDayValue = readImportCell(row, importHeaderAliases.gotchaDay);
|
||||||
|
const dateOfBirth = toImportDate(dateOfBirthValue);
|
||||||
|
const gotchaDay = toImportDate(gotchaDayValue);
|
||||||
|
const weightText = toImportText(readImportCell(row, importHeaderAliases.weightGrams));
|
||||||
|
const weightDateValue = readImportCell(row, importHeaderAliases.weightDate);
|
||||||
|
const weightDate = toImportDate(weightDateValue);
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
errors.push(`Row ${rowNumber}: Bird Name is required.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gender) {
|
||||||
|
errors.push(`Row ${rowNumber}: Gender must be male, female, unknown, or blank.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateOfBirthValue && !dateOfBirth) {
|
||||||
|
errors.push(`Row ${rowNumber}: Hatch Day is not a valid date.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gotchaDayValue && !gotchaDay) {
|
||||||
|
errors.push(`Row ${rowNumber}: Gotcha Day is not a valid date.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = getBirdImportKey(name, tagId);
|
||||||
|
const current =
|
||||||
|
profiles.get(key) ??
|
||||||
|
({
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
tagId,
|
||||||
|
species: '',
|
||||||
|
favoriteSnack: '',
|
||||||
|
motivators: '',
|
||||||
|
demotivators: '',
|
||||||
|
gender,
|
||||||
|
dateOfBirth,
|
||||||
|
gotchaDay,
|
||||||
|
chartColor: '',
|
||||||
|
weights: [],
|
||||||
|
} satisfies BirdImportProfile);
|
||||||
|
|
||||||
|
current.species = mergeImportText(current.species, toImportText(readImportCell(row, importHeaderAliases.species)));
|
||||||
|
current.favoriteSnack = mergeImportText(current.favoriteSnack, toImportText(readImportCell(row, importHeaderAliases.favoriteSnack)));
|
||||||
|
current.motivators = mergeImportText(current.motivators, toImportText(readImportCell(row, importHeaderAliases.motivators)));
|
||||||
|
current.demotivators = mergeImportText(current.demotivators, toImportText(readImportCell(row, importHeaderAliases.demotivators)));
|
||||||
|
current.chartColor = mergeImportText(current.chartColor, toImportText(readImportCell(row, importHeaderAliases.chartColor)));
|
||||||
|
current.dateOfBirth = mergeImportText(current.dateOfBirth, dateOfBirth);
|
||||||
|
current.gotchaDay = mergeImportText(current.gotchaDay, gotchaDay);
|
||||||
|
current.gender = current.gender === 'unknown' ? gender : current.gender;
|
||||||
|
|
||||||
|
if (weightText) {
|
||||||
|
const weightGrams = Number(weightText);
|
||||||
|
|
||||||
|
if (!Number.isFinite(weightGrams) || weightGrams <= 0) {
|
||||||
|
errors.push(`Row ${rowNumber}: Weight Grams must be a positive number.`);
|
||||||
|
} else if (!weightDate) {
|
||||||
|
errors.push(`Row ${rowNumber}: Weight Date is required for a weight entry.`);
|
||||||
|
} else {
|
||||||
|
current.weights.push({
|
||||||
|
weightGrams,
|
||||||
|
recordedOn: weightDate,
|
||||||
|
notes: toImportText(readImportCell(row, importHeaderAliases.weightNotes)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (weightDateValue) {
|
||||||
|
errors.push(`Row ${rowNumber}: Weight Grams is required when Weight Date is set.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
profiles.set(key, current);
|
||||||
|
});
|
||||||
|
|
||||||
|
profiles.forEach((profile) => {
|
||||||
|
if (!profile.species) {
|
||||||
|
errors.push(`${profile.name}: Species is required on at least one row for this bird.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.chartColor && !/^#[0-9a-fA-F]{6}$/.test(profile.chartColor)) {
|
||||||
|
errors.push(`${profile.name}: Chart Color must be a hex color like #cb3a35.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
profiles: [...profiles.values()],
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
};
|
||||||
const emptyBirdForm: BirdFormState = {
|
const emptyBirdForm: BirdFormState = {
|
||||||
name: '',
|
name: '',
|
||||||
tagId: '',
|
tagId: '',
|
||||||
@@ -1203,6 +1402,10 @@ function App() {
|
|||||||
const [workspaceCreateForm, setWorkspaceCreateForm] = useState<WorkspaceCreateFormState>(emptyWorkspaceCreateForm);
|
const [workspaceCreateForm, setWorkspaceCreateForm] = useState<WorkspaceCreateFormState>(emptyWorkspaceCreateForm);
|
||||||
const [integrationTokenForm, setIntegrationTokenForm] = useState<IntegrationTokenFormState>(emptyIntegrationTokenForm);
|
const [integrationTokenForm, setIntegrationTokenForm] = useState<IntegrationTokenFormState>(emptyIntegrationTokenForm);
|
||||||
const [birdForm, setBirdForm] = useState<BirdFormState>(emptyBirdForm);
|
const [birdForm, setBirdForm] = useState<BirdFormState>(emptyBirdForm);
|
||||||
|
const [birdImportPreview, setBirdImportPreview] = useState<BirdImportPreview | null>(null);
|
||||||
|
const [birdImportFileName, setBirdImportFileName] = useState('');
|
||||||
|
const [birdImportNotice, setBirdImportNotice] = useState('');
|
||||||
|
const [importingBirds, setImportingBirds] = useState(false);
|
||||||
const [memorializeBirdForm, setMemorializeBirdForm] = useState<MemorializeBirdFormState>(emptyMemorializeBirdForm);
|
const [memorializeBirdForm, setMemorializeBirdForm] = useState<MemorializeBirdFormState>(emptyMemorializeBirdForm);
|
||||||
const [birdPhotoName, setBirdPhotoName] = useState('');
|
const [birdPhotoName, setBirdPhotoName] = useState('');
|
||||||
const [photoCrop, setPhotoCrop] = useState<PhotoCropState | null>(null);
|
const [photoCrop, setPhotoCrop] = useState<PhotoCropState | null>(null);
|
||||||
@@ -2558,6 +2761,154 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBirdImportFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('');
|
||||||
|
setBirdImportNotice('');
|
||||||
|
setBirdImportFileName(file.name);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { readSheet } = await import('read-excel-file/browser');
|
||||||
|
const worksheetRows = await readSheet(file);
|
||||||
|
const [headerRow, ...dataRows] = worksheetRows;
|
||||||
|
|
||||||
|
if (!headerRow?.length) {
|
||||||
|
throw new Error('The first worksheet does not have a header row.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = headerRow.map((header) => toImportText(header));
|
||||||
|
const rows = dataRows
|
||||||
|
.filter((cells) => cells.some((cell) => toImportText(cell)))
|
||||||
|
.map((cells) =>
|
||||||
|
Object.fromEntries(headers.map((header, columnIndex) => [header, cells[columnIndex] ?? ''])) as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
throw new Error('The first worksheet does not have any bird rows.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setBirdImportPreview(parseBirdImportRows(rows));
|
||||||
|
} catch (importError) {
|
||||||
|
setBirdImportPreview(null);
|
||||||
|
setError(importError instanceof Error ? importError.message : 'Unable to read the bird spreadsheet.');
|
||||||
|
} finally {
|
||||||
|
event.currentTarget.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBirdImportSubmit = async () => {
|
||||||
|
if (!birdImportPreview || birdImportPreview.errors.length || importingBirds) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('');
|
||||||
|
setBirdImportNotice('');
|
||||||
|
setImportingBirds(true);
|
||||||
|
|
||||||
|
let importedBirdCount = 0;
|
||||||
|
let importedWeightCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const profile of birdImportPreview.profiles) {
|
||||||
|
const birdResponse = await apiFetch('/birds', authToken, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: profile.name,
|
||||||
|
tagId: profile.tagId,
|
||||||
|
species: profile.species,
|
||||||
|
motivators: profile.motivators,
|
||||||
|
demotivators: profile.demotivators,
|
||||||
|
favoriteSnack: profile.favoriteSnack,
|
||||||
|
gender: profile.gender,
|
||||||
|
dateOfBirth: profile.dateOfBirth,
|
||||||
|
gotchaDay: profile.gotchaDay,
|
||||||
|
chartColor: profile.chartColor || '#cb3a35',
|
||||||
|
photoDataUrl: '',
|
||||||
|
notifyOnDob: false,
|
||||||
|
notifyOnGotchaDay: false,
|
||||||
|
publicProfileEnabled: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!birdResponse.ok) {
|
||||||
|
throw new Error(await readErrorMessage(birdResponse, `Unable to import ${profile.name}.`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const birdData = await readJsonSafely<{ bird?: Bird }>(birdResponse);
|
||||||
|
if (!birdData?.bird) {
|
||||||
|
throw new Error(`Unable to import ${profile.name}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let importedBird = birdData.bird;
|
||||||
|
const importedWeights: WeightRecord[] = [];
|
||||||
|
importedBirdCount += 1;
|
||||||
|
setBirds((current) => sortBirdsByName([...current, importedBird]));
|
||||||
|
|
||||||
|
for (const weight of profile.weights) {
|
||||||
|
const weightResponse = await apiFetch(`/birds/${importedBird.id}/weights`, authToken, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
weightGrams: weight.weightGrams,
|
||||||
|
recordedOn: weight.recordedOn,
|
||||||
|
notes: weight.notes,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!weightResponse.ok) {
|
||||||
|
throw new Error(await readErrorMessage(weightResponse, `Unable to import a weight for ${profile.name}.`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const weightData = await readJsonSafely<{ weight?: WeightRecord }>(weightResponse);
|
||||||
|
if (!weightData?.weight) {
|
||||||
|
throw new Error(`Unable to import a weight for ${profile.name}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
importedWeights.push(weightData.weight);
|
||||||
|
importedWeightCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestWeight = importedWeights.reduce<WeightRecord | null>(
|
||||||
|
(latest, weight) => (!latest || weight.recordedOn >= latest.recordedOn ? weight : latest),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (latestWeight) {
|
||||||
|
importedBird = {
|
||||||
|
...importedBird,
|
||||||
|
latestWeightGrams: latestWeight.weightGrams,
|
||||||
|
latestRecordedOn: latestWeight.recordedOn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setBirds((current) => sortBirdsByName(current.map((bird) => (bird.id === importedBird.id ? importedBird : bird))));
|
||||||
|
setAllBirdWeights((current) => ({ ...current, [importedBird.id]: importedWeights }));
|
||||||
|
}
|
||||||
|
|
||||||
|
setBirdImportPreview(null);
|
||||||
|
setBirdImportFileName('');
|
||||||
|
setBirdImportNotice(
|
||||||
|
`Imported ${importedBirdCount} bird${importedBirdCount === 1 ? '' : 's'} and ${importedWeightCount} weight entr${
|
||||||
|
importedWeightCount === 1 ? 'y' : 'ies'
|
||||||
|
}.`,
|
||||||
|
);
|
||||||
|
} catch (importError) {
|
||||||
|
setError(
|
||||||
|
`${importError instanceof Error ? importError.message : 'Unable to import bird spreadsheet.'} Imported ${importedBirdCount} bird${
|
||||||
|
importedBirdCount === 1 ? '' : 's'
|
||||||
|
} before the import stopped.`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setImportingBirds(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleBirdSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
const handleBirdSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
@@ -5986,6 +6337,105 @@ function App() {
|
|||||||
) : null}
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<article className="panel form-panel settings-card-bird-import">
|
||||||
|
<div className="panel-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Import</p>
|
||||||
|
<h2>Bulk Import</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedSettingsSection((current) => (current === 'bird-import' ? null : 'bird-import'))
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
aria-expanded={expandedSettingsSection === 'bird-import'}
|
||||||
|
>
|
||||||
|
{expandedSettingsSection === 'bird-import' ? 'Close' : 'Open'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{expandedSettingsSection === 'bird-import' ? (
|
||||||
|
<>
|
||||||
|
<p className="muted">
|
||||||
|
Import the first worksheet from an Excel file. Use one row per weight entry and repeat the Bird Name for rows that belong to the
|
||||||
|
same profile.
|
||||||
|
</p>
|
||||||
|
<div className="import-column-guide">
|
||||||
|
<span>Required: Bird Name, Species</span>
|
||||||
|
<span>
|
||||||
|
Optional: Band ID, Gender, Hatch Day, Gotcha Day, Favorite Snack, Motivators, Demotivators, Chart Color, Weight Grams, Weight
|
||||||
|
Date, Weight Notes
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<label className="file-picker">
|
||||||
|
Excel file
|
||||||
|
<input type="file" accept=".xlsx" onChange={handleBirdImportFileChange} disabled={importingBirds} />
|
||||||
|
</label>
|
||||||
|
{birdImportNotice ? <p className="success-banner">{birdImportNotice}</p> : null}
|
||||||
|
{birdImportPreview ? (
|
||||||
|
<>
|
||||||
|
<div className="summary-grid">
|
||||||
|
<article className="summary-card">
|
||||||
|
<strong>
|
||||||
|
{birdImportPreview.profiles.length} profile{birdImportPreview.profiles.length === 1 ? '' : 's'}
|
||||||
|
</strong>
|
||||||
|
<span>{birdImportFileName}</span>
|
||||||
|
</article>
|
||||||
|
<article className="summary-card">
|
||||||
|
<strong>{birdImportPreview.profiles.reduce((count, profile) => count + profile.weights.length, 0)} weight entries</strong>
|
||||||
|
<span>Rows with Weight Grams and Weight Date</span>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
{birdImportPreview.errors.length ? (
|
||||||
|
<article className="summary-card summary-alert-card" role="alert">
|
||||||
|
<strong>Fix the spreadsheet before importing</strong>
|
||||||
|
<div className="summary-list">
|
||||||
|
{birdImportPreview.errors.map((importIssue) => (
|
||||||
|
<span key={importIssue}>{importIssue}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
) : (
|
||||||
|
<div className="recent-list import-preview-list">
|
||||||
|
{birdImportPreview.profiles.map((profile) => (
|
||||||
|
<article key={profile.key} className="vet-visit-card">
|
||||||
|
<strong>{profile.name}</strong>
|
||||||
|
<span>
|
||||||
|
{profile.species} • {profile.tagId ? `Band ${profile.tagId}` : 'No Band ID'} • {profile.weights.length} weight entr
|
||||||
|
{profile.weights.length === 1 ? 'y' : 'ies'}
|
||||||
|
</span>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="button-row">
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
onClick={handleBirdImportSubmit}
|
||||||
|
type="button"
|
||||||
|
disabled={importingBirds || birdImportPreview.errors.length > 0 || birdImportPreview.profiles.length === 0}
|
||||||
|
>
|
||||||
|
{importingBirds ? 'Importing...' : 'Import spreadsheet'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => {
|
||||||
|
setBirdImportPreview(null);
|
||||||
|
setBirdImportFileName('');
|
||||||
|
setBirdImportNotice('');
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
disabled={importingBirds}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
|
||||||
<article className="panel form-panel settings-card-transfer">
|
<article className="panel form-panel settings-card-transfer">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+20
-1
@@ -622,10 +622,14 @@ textarea {
|
|||||||
order: 4;
|
order: 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-card-transfer {
|
.settings-card-bird-import {
|
||||||
order: 5;
|
order: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-card-transfer {
|
||||||
|
order: 6;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-card-flock-profile {
|
.settings-card-flock-profile {
|
||||||
order: 1;
|
order: 1;
|
||||||
}
|
}
|
||||||
@@ -668,6 +672,21 @@ textarea {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.import-column-guide {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.8rem 0.9rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 254, 250, 0.72);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-preview-list {
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-danger-card {
|
.settings-danger-card {
|
||||||
border-color: rgba(203, 58, 53, 0.22);
|
border-color: rgba(203, 58, 53, 0.22);
|
||||||
background: linear-gradient(180deg, rgba(255, 250, 246, 0.86), rgba(255, 241, 238, 0.72));
|
background: linear-gradient(180deg, rgba(255, 250, 246, 0.86), rgba(255, 241, 238, 0.72));
|
||||||
|
|||||||
Reference in New Issue
Block a user