feat: add fuzzy autocomplete and NL quick-add for invoice line items
- Description field now shows a fuzzy-matched dropdown of past line items (description, hours, rate) as you type via Fuse.js — zero server cost - Selecting a suggestion pre-fills description, hours, and rate in one click - NL quick-add bar lets you type e.g. "3hrs web design @120" + Enter to append a fully-parsed line item without clicking through fields - New tRPC query `getLineItemHistory` returns deduplicated past line items for the current user, ordered by recency - New `parseLineItem` utility handles hours/rate extraction via regex Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,6 @@
|
||||
"": {
|
||||
"name": "beenvoice",
|
||||
"dependencies": {
|
||||
"@better-auth/sso": "^1.4.12",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -50,6 +49,7 @@
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"file-saver": "^2.0.5",
|
||||
"framer-motion": "^12.23.26",
|
||||
"fuse.js": "^7.3.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "^16.2.4",
|
||||
"pg": "8.13.1",
|
||||
@@ -101,8 +101,6 @@
|
||||
"packages": {
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
"@authenio/xml-encryption": ["@authenio/xml-encryption@2.0.2", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "escape-html": "^1.0.3", "xpath": "0.0.32" } }, "sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||
@@ -149,8 +147,6 @@
|
||||
|
||||
"@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-XHks01ntK20orqK/jICq8wmEbJ/zT6dct49Fk8zTQKN9QNGDc+Ix5+7z/Kvui0DXGFf790GfvRozquzaLtXa8Q=="],
|
||||
|
||||
"@better-auth/sso": ["@better-auth/sso@1.6.9", "", { "dependencies": { "fast-xml-parser": "^5.5.7", "jose": "^6.1.3", "samlify": "~2.10.2", "tldts": "^6.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "better-auth": "^1.6.9", "better-call": "1.3.5" } }, "sha512-sIpnW1144jQes1pBeqL4oxtNjKri0S1rff5tZTn/J9IwN22EGARyFyInYdOQk/jllbZJBimT/CFhyDZlwASgdw=="],
|
||||
|
||||
"@better-auth/telemetry": ["@better-auth/telemetry@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21" } }, "sha512-0u5zkhSCAQFoN3DHvUkLHOF6MBbVTDAa6mU8mhPwiysdz1x21vMzhzfaAKN/ZGWaQ09v91/F+2qu42G/bhUV4A=="],
|
||||
|
||||
"@better-auth/utils": ["@better-auth/utils@0.4.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA=="],
|
||||
@@ -351,8 +347,6 @@
|
||||
|
||||
"@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
|
||||
|
||||
"@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
@@ -705,10 +699,6 @@
|
||||
|
||||
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
|
||||
|
||||
"@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="],
|
||||
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
|
||||
|
||||
"abs-svg-path": ["abs-svg-path@0.1.1", "", {}, "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
@@ -741,8 +731,6 @@
|
||||
|
||||
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
|
||||
|
||||
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
|
||||
|
||||
"ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="],
|
||||
|
||||
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
|
||||
@@ -791,8 +779,6 @@
|
||||
|
||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001791", "", {}, "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
@@ -931,8 +917,6 @@
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="],
|
||||
@@ -983,10 +967,6 @@
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fast-xml-builder": ["fast-xml-builder@1.1.5", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA=="],
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@5.7.2", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.5", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w=="],
|
||||
|
||||
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
@@ -1019,6 +999,8 @@
|
||||
|
||||
"functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
|
||||
|
||||
"fuse.js": ["fuse.js@7.3.0", "", {}, "sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w=="],
|
||||
|
||||
"gel": ["gel@2.2.0", "", { "dependencies": { "@petamoriken/float16": "^3.8.7", "debug": "^4.3.4", "env-paths": "^3.0.0", "semver": "^7.6.2", "shell-quote": "^1.8.1", "which": "^4.0.0" }, "bin": { "gel": "dist/cli.mjs" } }, "sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ=="],
|
||||
|
||||
"generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
|
||||
@@ -1255,12 +1237,8 @@
|
||||
|
||||
"node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="],
|
||||
|
||||
"node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="],
|
||||
|
||||
"node-rsa": ["node-rsa@1.1.1", "", { "dependencies": { "asn1": "^0.2.4" } }, "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw=="],
|
||||
|
||||
"normalize-svg-path": ["normalize-svg-path@1.1.0", "", { "dependencies": { "svg-arc-to-cubic-bezier": "^3.0.0" } }, "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
@@ -1299,8 +1277,6 @@
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
@@ -1443,10 +1419,6 @@
|
||||
|
||||
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"samlify": ["samlify@2.10.2", "", { "dependencies": { "@authenio/xml-encryption": "^2.0.2", "@xmldom/xmldom": "^0.8.6", "camelcase": "^6.2.0", "node-forge": "^1.3.0", "node-rsa": "^1.1.1", "pako": "^1.0.10", "uuid": "^8.3.2", "xml": "^1.0.1", "xml-crypto": "^6.1.2", "xml-escape": "^1.1.0", "xpath": "^0.0.32" } }, "sha512-y5s1cHwclqwP8h7K2Wj9SfP1q+1S9+jrs5OAegYTLAiuFi7nDvuKqbiXLmUTvYPMpzHcX94wTY2+D604jgTKvA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
|
||||
@@ -1511,8 +1483,6 @@
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
|
||||
"strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="],
|
||||
|
||||
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||
|
||||
"superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="],
|
||||
@@ -1537,10 +1507,6 @@
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="],
|
||||
|
||||
"tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||
@@ -1587,8 +1553,6 @@
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
|
||||
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
|
||||
|
||||
"vite-compatible-readable-stream": ["vite-compatible-readable-stream@3.6.1", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ=="],
|
||||
@@ -1607,14 +1571,6 @@
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="],
|
||||
|
||||
"xml-crypto": ["xml-crypto@6.1.2", "", { "dependencies": { "@xmldom/is-dom-node": "^1.0.1", "@xmldom/xmldom": "^0.8.10", "xpath": "^0.0.33" } }, "sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w=="],
|
||||
|
||||
"xml-escape": ["xml-escape@1.1.0", "", {}, "sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg=="],
|
||||
|
||||
"xpath": ["xpath@0.0.32", "", {}, "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw=="],
|
||||
|
||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
@@ -1635,8 +1591,6 @@
|
||||
|
||||
"@better-auth/core/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"@better-auth/sso/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
@@ -1737,8 +1691,6 @@
|
||||
|
||||
"unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
|
||||
|
||||
"xml-crypto/xpath": ["xpath@0.0.33", "", {}, "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"file-saver": "^2.0.5",
|
||||
"framer-motion": "^12.23.26",
|
||||
"fuse.js": "^7.3.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "^16.2.4",
|
||||
"pg": "8.13.1",
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
} from "~/components/ui/dialog";
|
||||
import { STATUS_OPTIONS } from "./invoice/types";
|
||||
import type { InvoiceFormData, InvoiceItem } from "./invoice/types";
|
||||
import type { ParsedLineItem } from "~/lib/parse-line-item";
|
||||
|
||||
import { CountUp } from "~/components/ui/count-up";
|
||||
|
||||
@@ -293,6 +294,22 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
],
|
||||
}));
|
||||
};
|
||||
const addItemWithValues = (parsed: ParsedLineItem) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
items: [
|
||||
...prev.items,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
date: new Date(),
|
||||
description: parsed.description,
|
||||
hours: parsed.hours ?? 1,
|
||||
rate: parsed.rate ?? prev.defaultHourlyRate ?? 0,
|
||||
amount: (parsed.hours ?? 1) * (parsed.rate ?? prev.defaultHourlyRate ?? 0),
|
||||
},
|
||||
],
|
||||
}));
|
||||
};
|
||||
const removeItem = (idx: number) => {
|
||||
if (formData.items.length > 1)
|
||||
setFormData((prev) => ({
|
||||
@@ -787,6 +804,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
onAddItem={addItem}
|
||||
onRemoveItem={removeItem}
|
||||
onUpdateItem={updateItem}
|
||||
onAddItemWithValues={addItemWithValues}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Plus, Trash2, Zap } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
@@ -9,6 +10,11 @@ import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { parseLineItem, type ParsedLineItem } from "~/lib/parse-line-item";
|
||||
import {
|
||||
useLineItemSuggestions,
|
||||
type LineItemSuggestion,
|
||||
} from "~/hooks/use-line-item-suggestions";
|
||||
|
||||
interface InvoiceItem {
|
||||
id: string;
|
||||
@@ -28,6 +34,7 @@ interface InvoiceLineItemsProps {
|
||||
field: string,
|
||||
value: string | number | Date,
|
||||
) => void;
|
||||
onAddItemWithValues?: (parsed: ParsedLineItem) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -41,10 +48,101 @@ interface LineItemRowProps {
|
||||
field: string,
|
||||
value: string | number | Date,
|
||||
) => void;
|
||||
suggestions: LineItemSuggestion[];
|
||||
onSelectSuggestion: (index: number, suggestion: LineItemSuggestion) => void;
|
||||
onDescriptionChange: (index: number, value: string) => void;
|
||||
}
|
||||
|
||||
interface DescriptionAutocompleteProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSelect: (suggestion: LineItemSuggestion) => void;
|
||||
suggestions: LineItemSuggestion[];
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function DescriptionAutocomplete({
|
||||
value,
|
||||
onChange,
|
||||
onSelect,
|
||||
suggestions,
|
||||
placeholder,
|
||||
className,
|
||||
}: DescriptionAutocompleteProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const showDropdown = open && suggestions.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (!showDropdown) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.min(i + 1, suggestions.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.max(i - 1, -1));
|
||||
} else if (e.key === "Enter" && activeIndex >= 0) {
|
||||
e.preventDefault();
|
||||
const s = suggestions[activeIndex];
|
||||
if (s) { onSelect(s); setOpen(false); }
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => { onChange(e.target.value); setOpen(true); setActiveIndex(-1); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
/>
|
||||
{showDropdown && (
|
||||
<div className="bg-popover text-popover-foreground border-border absolute top-full left-0 z-50 mt-1 w-full overflow-hidden rounded-md border shadow-md">
|
||||
{suggestions.map((s, i) => (
|
||||
<button
|
||||
key={s.description}
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(s);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"hover:bg-accent hover:text-accent-foreground flex w-full items-center justify-between px-3 py-2 text-left text-sm",
|
||||
i === activeIndex && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate font-medium">{s.description}</span>
|
||||
<span className="text-muted-foreground ml-3 shrink-0 font-mono text-xs">
|
||||
{s.hours}h · ${s.rate}/hr
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
||||
({ item, index, canRemove, onRemove, onUpdate }, ref) => {
|
||||
({ item, index, canRemove, onRemove, onUpdate, suggestions, onSelectSuggestion, onDescriptionChange }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -60,9 +158,11 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
||||
inputClassName="h-9"
|
||||
/>
|
||||
|
||||
<Input
|
||||
<DescriptionAutocomplete
|
||||
value={item.description}
|
||||
onChange={(e) => onUpdate(index, "description", e.target.value)}
|
||||
onChange={(v) => onDescriptionChange(index, v)}
|
||||
onSelect={(s) => onSelectSuggestion(index, s)}
|
||||
suggestions={suggestions}
|
||||
placeholder="Describe the work performed..."
|
||||
className="h-9 w-full text-sm font-medium"
|
||||
/>
|
||||
@@ -114,6 +214,9 @@ function MobileLineItem({
|
||||
canRemove,
|
||||
onRemove,
|
||||
onUpdate,
|
||||
suggestions,
|
||||
onSelectSuggestion,
|
||||
onDescriptionChange,
|
||||
}: LineItemRowProps) {
|
||||
return (
|
||||
<motion.div
|
||||
@@ -129,9 +232,11 @@ function MobileLineItem({
|
||||
{/* Description */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-xs">Description</Label>
|
||||
<Input
|
||||
<DescriptionAutocomplete
|
||||
value={item.description}
|
||||
onChange={(e) => onUpdate(index, "description", e.target.value)}
|
||||
onChange={(v) => onDescriptionChange(index, v)}
|
||||
onSelect={(s) => onSelectSuggestion(index, s)}
|
||||
suggestions={suggestions}
|
||||
placeholder="Describe the work performed..."
|
||||
className="pl-3 text-sm"
|
||||
/>
|
||||
@@ -208,14 +313,61 @@ function MobileLineItem({
|
||||
);
|
||||
}
|
||||
|
||||
function NLQuickAdd({ onAdd }: { onAdd: (parsed: ParsedLineItem) => void }) {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Enter" && value.trim()) {
|
||||
e.preventDefault();
|
||||
onAdd(parseLineItem(value));
|
||||
setValue("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<Zap className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder='Quick add: "3hrs web design @120" — press Enter'
|
||||
className="h-8 border-dashed text-sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InvoiceLineItems({
|
||||
items,
|
||||
onAddItem,
|
||||
onRemoveItem,
|
||||
onUpdateItem,
|
||||
onAddItemWithValues,
|
||||
className,
|
||||
}: InvoiceLineItemsProps) {
|
||||
const canRemoveItems = items.length > 1;
|
||||
const { search } = useLineItemSuggestions();
|
||||
const [queriedIndex, setQueriedIndex] = useState<number | null>(null);
|
||||
const [suggestions, setSuggestions] = useState<LineItemSuggestion[]>([]);
|
||||
|
||||
function handleDescriptionChange(index: number, value: string) {
|
||||
onUpdateItem(index, "description", value);
|
||||
setQueriedIndex(index);
|
||||
setSuggestions(search(value));
|
||||
}
|
||||
|
||||
function handleSelectSuggestion(index: number, s: LineItemSuggestion) {
|
||||
onUpdateItem(index, "description", s.description);
|
||||
onUpdateItem(index, "hours", s.hours);
|
||||
onUpdateItem(index, "rate", s.rate);
|
||||
setSuggestions([]);
|
||||
setQueriedIndex(null);
|
||||
}
|
||||
|
||||
function getSuggestionsForIndex(index: number): LineItemSuggestion[] {
|
||||
return queriedIndex === index ? suggestions : [];
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
@@ -246,6 +398,9 @@ export function InvoiceLineItems({
|
||||
canRemove={canRemoveItems}
|
||||
onRemove={onRemoveItem}
|
||||
onUpdate={onUpdateItem}
|
||||
suggestions={getSuggestionsForIndex(index)}
|
||||
onSelectSuggestion={handleSelectSuggestion}
|
||||
onDescriptionChange={handleDescriptionChange}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
@@ -256,9 +411,15 @@ export function InvoiceLineItems({
|
||||
canRemove={canRemoveItems}
|
||||
onRemove={onRemoveItem}
|
||||
onUpdate={onUpdateItem}
|
||||
suggestions={getSuggestionsForIndex(index)}
|
||||
onSelectSuggestion={handleSelectSuggestion}
|
||||
onDescriptionChange={handleDescriptionChange}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{onAddItemWithValues && (
|
||||
<NLQuickAdd onAdd={onAddItemWithValues} />
|
||||
)}
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useMemo } from "react";
|
||||
import Fuse from "fuse.js";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export interface LineItemSuggestion {
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
}
|
||||
|
||||
export function useLineItemSuggestions() {
|
||||
const { data: history = [] } = api.invoices.getLineItemHistory.useQuery(undefined, {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const fuse = useMemo(
|
||||
() =>
|
||||
new Fuse(history, {
|
||||
keys: ["description"],
|
||||
threshold: 0.4,
|
||||
minMatchCharLength: 2,
|
||||
}),
|
||||
[history],
|
||||
);
|
||||
|
||||
function search(query: string): LineItemSuggestion[] {
|
||||
if (!query || query.length < 2) return history.slice(0, 6);
|
||||
return fuse.search(query, { limit: 6 }).map((r) => r.item);
|
||||
}
|
||||
|
||||
return { search, hasHistory: history.length > 0 };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface ParsedLineItem {
|
||||
description: string;
|
||||
hours: number | null;
|
||||
rate: number | null;
|
||||
}
|
||||
|
||||
export function parseLineItem(input: string): ParsedLineItem {
|
||||
let text = input.trim();
|
||||
let hours: number | null = null;
|
||||
let rate: number | null = null;
|
||||
|
||||
// Extract hours: "3h", "3hr", "3hrs", "3 hours", "3.5hours"
|
||||
const hoursMatch = /(\d+\.?\d*)\s*h(?:ours?|rs?)\b/i.exec(text);
|
||||
if (hoursMatch?.[0] && hoursMatch[1]) {
|
||||
hours = parseFloat(hoursMatch[1]);
|
||||
text = text.replace(hoursMatch[0], " ").trim();
|
||||
}
|
||||
|
||||
// Extract rate: "@120", "@$120", "at 120", "at $120", "$120/hr", "$120ph"
|
||||
const rateMatch = /(?:@\s*\$?|at\s+\$?)(\d+\.?\d*)|(\$\d+\.?\d*)(?:\/h(?:rs?)?|ph)?\b/i.exec(text);
|
||||
if (rateMatch?.[0]) {
|
||||
const rawRate = rateMatch[1] ?? rateMatch[2] ?? "";
|
||||
rate = parseFloat(rawRate.replace("$", ""));
|
||||
text = text.replace(rateMatch[0], " ").trim();
|
||||
}
|
||||
|
||||
const description = text.replace(/\s+/g, " ").replace(/^[\s,]+|[\s,]+$/g, "").trim();
|
||||
|
||||
return { description: description || input.trim(), hours, rate };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { desc, eq, inArray } from "drizzle-orm";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import {
|
||||
invoices,
|
||||
@@ -131,6 +131,40 @@ export const invoicesRouter = createTRPCRouter({
|
||||
}
|
||||
}),
|
||||
|
||||
getLineItemHistory: protectedProcedure.query(async ({ ctx }) => {
|
||||
const userInvoices = await ctx.db
|
||||
.select({ id: invoices.id })
|
||||
.from(invoices)
|
||||
.where(eq(invoices.createdById, ctx.session.user.id));
|
||||
|
||||
if (userInvoices.length === 0) return [];
|
||||
|
||||
const invoiceIds = userInvoices.map((i) => i.id);
|
||||
const rows = await ctx.db
|
||||
.select({
|
||||
description: invoiceItems.description,
|
||||
hours: invoiceItems.hours,
|
||||
rate: invoiceItems.rate,
|
||||
createdAt: invoiceItems.createdAt,
|
||||
})
|
||||
.from(invoiceItems)
|
||||
.where(inArray(invoiceItems.invoiceId, invoiceIds))
|
||||
.orderBy(desc(invoiceItems.createdAt))
|
||||
.limit(500);
|
||||
|
||||
// Deduplicate by description, keeping most recent occurrence
|
||||
const seen = new Set<string>();
|
||||
return rows
|
||||
.filter((r) => {
|
||||
const key = r.description.toLowerCase();
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.slice(0, 200)
|
||||
.map(({ description, hours, rate }) => ({ description, hours, rate }));
|
||||
}),
|
||||
|
||||
getCurrentOpen: protectedProcedure.query(async ({ ctx }) => {
|
||||
try {
|
||||
// Get the most recent draft invoice
|
||||
|
||||
Reference in New Issue
Block a user