Compare commits

...

32 Commits

Author SHA1 Message Date
tcardoza a3ac0feddf Added prevailing wage calculator and custom math modifiers
CI / build (push) Has been skipped
CI / deploy (push) Successful in 50s
2026-03-20 15:11:32 -05:00
tcardoza 59c6211bb9 calculator improvements 2026-03-20 12:22:36 -05:00
tcardoza cc0a1ad15d Added Manager Info
CI / build (push) Has been skipped
CI / deploy (push) Successful in 56s
2026-03-19 16:50:07 -05:00
tcardoza 72135a11d2 made scope exclusions for csv update 2026-03-19 14:56:43 -05:00
tcardoza c2f384a105 Update from CSV added 2026-03-19 14:48:15 -05:00
tcardoza f2dcff7b71 Load in improvements
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m4s
2026-03-18 18:51:26 -05:00
tcardoza f9a19eb4c4 Auth added and UI improvements
CI / build (push) Has been skipped
CI / deploy (push) Successful in 49s
2026-03-18 18:40:56 -05:00
tcardoza 74e859ef71 standard pricing with suppliers fixes
CI / build (push) Has been skipped
CI / deploy (push) Successful in 45s
2026-03-18 18:04:39 -05:00
tcardoza 6780d80d10 Supplier based pricing added 2026-03-18 18:00:35 -05:00
tcardoza 41803f4efa Unassigned items filter 2026-03-18 17:29:54 -05:00
tcardoza c5b750bd08 Calculator UI Fix 2026-03-18 17:27:45 -05:00
tcardoza aac836501e Calculator keystroke fixes 2026-03-18 17:21:50 -05:00
tcardoza a1ca82e539 Standarad Pricing Feature Added
CI / build (push) Has been skipped
CI / deploy (push) Successful in 49s
2026-03-18 17:18:33 -05:00
tcardoza fb7b138371 management and delivery improvements
CI / build (push) Has been skipped
CI / deploy (push) Successful in 44s
2026-03-18 11:42:21 -05:00
tcardoza 716a06ee28 Man hours uses subtotal
CI / build (push) Has been skipped
CI / deploy (push) Successful in 47s
2026-03-18 10:44:13 -05:00
tcardoza a9e0bafef0 next ci attempt
CI / build (push) Has been skipped
CI / deploy (push) Successful in 59s
2026-03-16 18:41:03 -05:00
tcardoza 7a93b75910 ci test
CI / build (push) Has been skipped
CI / deploy (push) Successful in 51s
2026-03-16 17:19:28 -05:00
tcardoza 6fe897a91f port fix
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m2s
2026-03-16 17:13:15 -05:00
tcardoza 971016013e ci further
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m11s
2026-03-16 17:08:05 -05:00
tcardoza 5857844af0 test inital publication
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m6s
2026-03-16 16:47:30 -05:00
tcardoza 8bd91ab002 Added CI
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m0s
2026-03-16 16:40:51 -05:00
tcardoza bd65217ddc template improvements for scope choice and filtering 2026-03-16 15:31:52 -05:00
tcardoza 229b4b1486 modules updates 2026-03-16 14:22:01 -05:00
tcardoza 865c4a954c basic module setup 2026-03-16 13:38:22 -05:00
tcardoza f4ad67e097 sidebar improvements 2026-03-13 19:01:56 -05:00
tcardoza 6a1af4b723 Layout fixed 2026-03-13 18:53:59 -05:00
tcardoza b52564d44d mid layout change 2026-03-13 18:41:44 -05:00
tcardoza c72da2fad5 mostly fixed layouts 2026-03-13 18:31:21 -05:00
tcardoza dfc4cccb56 Layout Fixes 2026-03-13 16:49:17 -05:00
tcardoza 989baa9be7 Tables Good 2026-03-13 16:28:43 -05:00
tcardoza 4b2585a59f history working 2026-03-13 15:04:37 -05:00
tcardoza e637e0903d Estimate History management added 2026-03-13 14:41:46 -05:00
46 changed files with 5643 additions and 884 deletions
+94
View File
@@ -0,0 +1,94 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
env:
DEPLOY_HOST: "10.10.1.82"
APP_PORT: "4100"
DEPLOY_PATH: "/var/www/EstiMaker"
jobs:
build:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
container:
image: oven/bun:1
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build
run: bun run build
deploy:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
container:
image: node:20-bookworm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install curl and ca-certificates
run: apt-get update -qq && apt-get install -y curl ca-certificates
- name: Install Bun
run: |
curl -fsSL https://bun.sh/install | bash
echo "$HOME/.bun/bin" >> $GITHUB_PATH
echo "PATH=$HOME/.bun/bin:$PATH" >> $GITHUB_ENV
- name: Install deploy tools
run: apt-get update -qq && apt-get install -y rsync openssh-client sshpass
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build
run: bun run build
- name: Prepare deploy artifact
run: |
ls -la
test -d dist || (echo "Missing dist/ (Vite output). Check Build step." && exit 1)
mkdir -p deploy
cp -r dist package.json deploy/
(test -f bun.lock && cp bun.lock deploy/) || (test -f bun.lockb && cp bun.lockb deploy/) || true
- name: Setup SSH known hosts
run: |
mkdir -p ~/.ssh
ssh-keyscan -p ${{ secrets.DEPLOY_SSH_PORT || '22' }} -H ${{ secrets.DEPLOY_HOST || env.DEPLOY_HOST }} >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Deploy via rsync
env:
SSH_PASSWORD: ${{ secrets.DEPLOY_PASSWORD }}
run: |
sshpass -p "$SSH_PASSWORD" rsync -avz --delete \
-e "ssh -o StrictHostKeyChecking=accept-new -p ${{ secrets.DEPLOY_SSH_PORT || '22' }}" \
deploy/ \
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST || env.DEPLOY_HOST }}:${{ secrets.DEPLOY_PATH || env.DEPLOY_PATH }}/
- name: Install production deps on server
env:
SSH_PASSWORD: ${{ secrets.DEPLOY_PASSWORD }}
run: |
sshpass -p "$SSH_PASSWORD" ssh -o StrictHostKeyChecking=accept-new -p ${{ secrets.DEPLOY_SSH_PORT || '22' }} \
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST || env.DEPLOY_HOST }} \
'bash -lc "cd ${{ secrets.DEPLOY_PATH || env.DEPLOY_PATH }} && bun install --production"'
- name: Restart app on server
env:
SSH_PASSWORD: ${{ secrets.DEPLOY_PASSWORD }}
run: |
sshpass -p "$SSH_PASSWORD" ssh -o StrictHostKeyChecking=accept-new -p ${{ secrets.DEPLOY_SSH_PORT || '22' }} \
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST || env.DEPLOY_HOST }} \
'bash -lc "cd ${{ secrets.DEPLOY_PATH || env.DEPLOY_PATH }} && export PORT=${{ env.APP_PORT }} && (pm2 restart EstiMaker 2>/dev/null || pm2 start \"bun run serve\" --name EstiMaker --update-env)"'
+1 -1
View File
@@ -31,7 +31,7 @@ Builds the app for production to the `dist` folder.
It correctly bundles Solid in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.
The build is minified and the filenames include the hashes...
+171
View File
@@ -9,6 +9,7 @@
"@tailwindcss/typography": "^0.5.19",
"lucide-solid": "^0.577.0",
"pocketbase": "^0.26.8",
"serve": "^14.2.6",
"solid-element": "^1.9.1",
"solid-js": "^1.9.11",
},
@@ -219,32 +220,84 @@
"@types/node": ["@types/node@25.4.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw=="],
"@zeit/schemas": ["@zeit/schemas@2.36.0", "", {}, "sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"arch": ["arch@2.2.0", "", {}, "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ=="],
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"autoprefixer": ["autoprefixer@10.4.27", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001774", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA=="],
"babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.3", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-5HOwwt0BYiv/zxl7j8Pf2bGL6rDXfV6nUhLs8ygBX+EFJXzBPHM/euj9j/6deMZ6wa52Wb2PBaAV5U/jKwIY1w=="],
"babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" } }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.18", "", { "bin": "dist/cli.js" }, "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA=="],
"boxen": ["boxen@7.0.0", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^7.0.0", "chalk": "^5.0.1", "cli-boxes": "^3.0.0", "string-width": "^5.1.2", "type-fest": "^2.13.0", "widest-line": "^4.0.1", "wrap-ansi": "^8.0.1" } }, "sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": "cli.js" }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"camelcase": ["camelcase@7.0.1", "", {}, "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw=="],
"caniuse-lite": ["caniuse-lite@1.0.30001777", "", {}, "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ=="],
"chalk": ["chalk@5.0.1", "", {}, "sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w=="],
"chalk-template": ["chalk-template@0.4.0", "", { "dependencies": { "chalk": "^4.1.2" } }, "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg=="],
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
"clipboardy": ["clipboardy@3.0.0", "", { "dependencies": { "arch": "^2.2.0", "execa": "^5.1.1", "is-wsl": "^2.2.0" } }, "sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"component-register": ["component-register@0.8.8", "", {}, "sha512-djhwcxjY+X9dacaYUEOkOm7tda8uOEDiMDigWysu3xv54M8o6XDlsjR1qt5Y8QLGiKg51fqXFIR2HUTmt9ys0Q=="],
"compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="],
"compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"content-disposition": ["content-disposition@0.5.2", "", {}, "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.278", "", {}, "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw=="],
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="],
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
@@ -253,6 +306,12 @@
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
@@ -261,18 +320,40 @@
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-port-reachable": ["is-port-reachable@4.0.0", "", {}, "sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig=="],
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="],
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jiti": ["jiti@1.21.7", "", { "bin": "bin/jiti.js" }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": "bin/jsesc" }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
@@ -307,14 +388,40 @@
"merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@2.1.18", "", { "dependencies": { "mime-db": "~1.33.0" } }, "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
"on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="],
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"path-is-inside": ["path-is-inside@1.0.2", "", {}, "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-to-regexp": ["path-to-regexp@3.3.0", "", {}, "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
@@ -327,14 +434,36 @@
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"range-parser": ["range-parser@1.2.0", "", {}, "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A=="],
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
"registry-auth-token": ["registry-auth-token@3.3.2", "", { "dependencies": { "rc": "^1.1.6", "safe-buffer": "^5.0.1" } }, "sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ=="],
"registry-url": ["registry-url@3.1.0", "", { "dependencies": { "rc": "^1.0.1" } }, "sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"rollup": ["rollup@4.56.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.56.0", "@rollup/rollup-android-arm64": "4.56.0", "@rollup/rollup-darwin-arm64": "4.56.0", "@rollup/rollup-darwin-x64": "4.56.0", "@rollup/rollup-freebsd-arm64": "4.56.0", "@rollup/rollup-freebsd-x64": "4.56.0", "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", "@rollup/rollup-linux-arm-musleabihf": "4.56.0", "@rollup/rollup-linux-arm64-gnu": "4.56.0", "@rollup/rollup-linux-arm64-musl": "4.56.0", "@rollup/rollup-linux-loong64-gnu": "4.56.0", "@rollup/rollup-linux-loong64-musl": "4.56.0", "@rollup/rollup-linux-ppc64-gnu": "4.56.0", "@rollup/rollup-linux-ppc64-musl": "4.56.0", "@rollup/rollup-linux-riscv64-gnu": "4.56.0", "@rollup/rollup-linux-riscv64-musl": "4.56.0", "@rollup/rollup-linux-s390x-gnu": "4.56.0", "@rollup/rollup-linux-x64-gnu": "4.56.0", "@rollup/rollup-linux-x64-musl": "4.56.0", "@rollup/rollup-openbsd-x64": "4.56.0", "@rollup/rollup-openharmony-arm64": "4.56.0", "@rollup/rollup-win32-arm64-msvc": "4.56.0", "@rollup/rollup-win32-ia32-msvc": "4.56.0", "@rollup/rollup-win32-x64-gnu": "4.56.0", "@rollup/rollup-win32-x64-msvc": "4.56.0", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="],
"seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="],
"serve": ["serve@14.2.6", "", { "dependencies": { "@zeit/schemas": "2.36.0", "ajv": "8.18.0", "arg": "5.0.2", "boxen": "7.0.0", "chalk": "5.0.1", "chalk-template": "0.4.0", "clipboardy": "3.0.0", "compression": "1.8.1", "is-port-reachable": "4.0.0", "serve-handler": "6.1.7", "update-check": "1.5.4" }, "bin": { "serve": "build/main.js" } }, "sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q=="],
"serve-handler": ["serve-handler@6.1.7", "", { "dependencies": { "bytes": "3.0.0", "content-disposition": "0.5.2", "mime-types": "2.1.18", "minimatch": "3.1.5", "path-is-inside": "1.0.2", "path-to-regexp": "3.3.0", "range-parser": "1.2.0" } }, "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"solid-element": ["solid-element@1.9.1", "", { "dependencies": { "component-register": "^0.8.7" }, "peerDependencies": { "solid-js": "^1.9.3" } }, "sha512-baJy6Qz27oAUgkPlqOf3Y+7RsBiuVQrS51Nrh1ddDbrqrNPvJbIvehpUsTzLNFb2ZHIoHuNnDg330go/ZKcRdg=="],
"solid-js": ["solid-js@1.9.11", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q=="],
@@ -343,26 +472,48 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"update-check": ["update-check@1.5.4", "", { "dependencies": { "registry-auth-token": "3.3.2", "registry-url": "3.1.0" } }, "sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": "bin/vite.js" }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"vite-plugin-solid": ["vite-plugin-solid@2.11.10", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw=="],
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" } }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"widest-line": ["widest-line@4.0.1", "", { "dependencies": { "string-width": "^5.0.1" } }, "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig=="],
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
@@ -379,10 +530,30 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
"browserslist/caniuse-lite": ["caniuse-lite@1.0.30001766", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="],
"chalk-template/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"mime-types/mime-db": ["mime-db@1.33.0", "", {}, "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ=="],
"serve-handler/bytes": ["bytes@3.0.0", "", {}, "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw=="],
"vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"chalk-template/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
}
}
+43
View File
@@ -0,0 +1,43 @@
# New App Checklist (Vite + Bun + PM2)
## 1) Local Repo Changes
- Add `serve` script:
- `"serve": "serve -s dist -l tcp://0.0.0.0:$PORT"`
- Add dependency:
- `serve` (run `bun add serve`)
## 2) CI Workflow
- Use the template file in `ci-template.yaml`.
- Replace placeholders:
- `<APP_NAME>`
- `<APP_PORT>`
- `<DEPLOY_PATH>`
## 3) Server One-Time Setup
- Create deploy directory:
- `mkdir -p <DEPLOY_PATH>`
- First PM2 start:
- `cd <DEPLOY_PATH>`
- `PORT=<APP_PORT> pm2 start "bun run serve" --name <APP_NAME>`
- `pm2 save`
## 4) Deploy + Restart (CI will do this)
- Files copied to server:
- `dist/`, `package.json`, `bun.lock` (or `bun.lockb`)
- On server:
- `bun install --production`
- `PORT=<APP_PORT> pm2 restart <APP_NAME> --update-env`
## 5) Verification
- PM2 process:
- `pm2 show <APP_NAME>`
- Port:
- `pm2 env <ID> | grep PORT`
- Bind:
- `pm2 logs <APP_NAME> --lines 20`
- Expect: `Accepting connections at http://0.0.0.0:<APP_PORT>`
- Files:
- `ls -la <DEPLOY_PATH>`
- `ls -la <DEPLOY_PATH>/dist | head -20`
- HTTP:
- `curl -I http://<SERVER_IP>:<APP_PORT>`
+4 -1
View File
@@ -5,6 +5,9 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet" />
<title>item-extractor-solid</title>
</head>
@@ -13,4 +16,4 @@
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
</html>
+4 -2
View File
@@ -6,13 +6,15 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"serve": "serve -s dist -l tcp://0.0.0.0:$PORT"
},
"dependencies": {
"@solidjs/router": "^0.15.4",
"@tailwindcss/typography": "^0.5.19",
"lucide-solid": "^0.577.0",
"pocketbase": "^0.26.8",
"serve": "^14.2.6",
"solid-element": "^1.9.1",
"solid-js": "^1.9.11"
},
@@ -26,4 +28,4 @@
"vite": "^7.3.1",
"vite-plugin-solid": "^2.11.10"
}
}
}
+38 -2
View File
@@ -1,20 +1,40 @@
import type { Component } from 'solid-js';
import { A } from '@solidjs/router';
import { A, useLocation, useNavigate } from '@solidjs/router';
import { Show } from 'solid-js';
import { authStore } from './store/authStore';
import { appStore } from './store/appStore';
interface AppContainerProps {
children?: any;
}
const AppContainer: Component<AppContainerProps> = (props) => {
const location = useLocation();
const navigate = useNavigate();
const handleLogout = () => {
authStore.logout();
navigate('/login');
};
return (
<div class="min-h-screen bg-gray-50/50">
{/* Top Navigation Bar */}
<Show when={location.pathname !== '/login'}>
<header class="bg-white border-b border-gray-200 sticky top-0 z-10 no-print">
<div class="max-w-[1800px] w-full mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center gap-8">
<div class="flex-shrink-0 flex items-center">
<span class="font-bold text-xl text-gray-900 tracking-tight">EstiMaker</span>
<button
onClick={() => {
appStore.resetEstimate();
navigate('/');
}}
class="font-bold text-xl text-gray-900 tracking-tight hover:text-blue-600 transition-colors"
>
EstiMaker
</button>
</div>
<nav class="flex space-x-4">
<A
@@ -32,11 +52,27 @@ const AppContainer: Component<AppContainerProps> = (props) => {
>
Template Creator
</A>
<A
href="/standard-pricing"
class="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded-md transition-colors"
activeClass="text-blue-600 bg-blue-50 hover:bg-blue-50 hover:text-blue-700"
>
Standard Pricing
</A>
</nav>
</div>
<div class="flex items-center gap-4">
<button
onClick={handleLogout}
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 shadow-sm transition-colors"
>
Sign out
</button>
</div>
</div>
</div>
</header>
</Show>
{/* Main Content Area */}
<main class="max-w-[1800px] w-full mx-auto p-4 sm:p-6 lg:p-8">
+102 -32
View File
@@ -7,7 +7,7 @@ import IgnoredLinesList from './components/IgnoredLinesList';
import Toast from './components/Toast';
import { extractItemsFromCSV } from './utils/csv-parser';
import type { ExtractedItem } from './utils/csv-parser';
import { FileSpreadsheet, Download } from 'lucide-solid';
import { FileSpreadsheet, Download, Plus, FilePlus, ArrowLeft } from 'lucide-solid';
import { appStore } from './store/appStore';
import CloudLoadModal from './components/CloudLoadModal';
@@ -23,11 +23,12 @@ const ItemExtractor: Component<ItemExtractorProps> = () => {
const ignoredLines = appStore.ignoredLines;
const setIgnoredLines = appStore.setIgnoredLines;
const estimateItems = appStore.estimateItems;
const setEstimateName = appStore.setEstimateName;
const scopes = appStore.scopes;
const [showToast] = createSignal(false);
const [toastMessage] = createSignal('');
const [showLoadModal, setShowLoadModal] = createSignal(false);
const [mode, setMode] = createSignal<'initial' | 'new-selection'>('initial');
const handleFileSelect = (csvText: string) => {
const { items: extracted, ignored } = extractItemsFromCSV(csvText);
@@ -35,42 +36,116 @@ const ItemExtractor: Component<ItemExtractorProps> = () => {
setIgnoredLines(ignored);
};
const handleNewBlankEstimate = () => {
appStore.resetEstimate();
// Add a default empty scope to trigger the builder view
appStore.setScopes([{
id: Math.random().toString(36).substr(2, 9),
name: 'Main Scope',
managementFee: 0,
managementFeeType: 'percent',
managementFeeQuantity: 0,
managementFeeRate: 0,
deliveryFee: 0,
deliveryFeeType: 'percent',
deliveryFeeQuantity: 0,
deliveryFeeRate: 0,
useManagementLogic: 'simple',
useDeliveryLogic: 'simple'
}]);
};
return (
<div class="flex flex-col items-center py-10 px-4 font-sans border border-gray-200 rounded-xl bg-gray-50/50 backdrop-blur-sm print:bg-transparent print:border-none print:p-0">
{/* Header - Hidden after upload or load */}
<Show when={items().length === 0 && estimateItems.length === 0}>
<Show when={items().length === 0 && estimateItems.length === 0 && scopes.length === 0}>
<div class="max-w-4xl w-full bg-white rounded-xl shadow-sm p-8 mb-8 border border-gray-100 no-print">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-3xl font-bold text-gray-800 mb-2">Item Parser & Extractor</h1>
<p class="text-gray-600">Upload your CSV to extract "Item" fields and split quantities.</p>
</div>
<div class="text-blue-600 text-4xl">
<FileSpreadsheet class="w-12 h-12" />
</div>
</div>
<FileUpload onFileSelect={handleFileSelect} />
<div class="mt-8 flex flex-col items-center justify-center">
<div class="flex items-center w-full max-w-sm mb-6">
<div class="flex-1 h-px bg-gray-200"></div>
<span class="px-4 text-[10px] font-black text-gray-400 uppercase tracking-[0.2em]">OR</span>
<div class="flex-1 h-px bg-gray-200"></div>
<Show when={mode() === 'initial'}>
<div class="flex flex-col items-center justify-center space-y-8 py-12">
<div class="text-center mb-4">
<h1 class="text-4xl font-black text-gray-900 mb-3 tracking-tight">EstiMaker</h1>
<p class="text-gray-500 text-lg">Start a new project or continue where you left off.</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 w-full max-w-2xl">
<button
onClick={() => setMode('new-selection')}
class="flex flex-col items-center gap-4 p-8 bg-white border-2 border-dashed border-gray-200 rounded-3xl hover:border-blue-400 hover:bg-blue-50/30 transition-all group"
>
<div class="w-16 h-16 rounded-2xl bg-blue-50 flex items-center justify-center text-blue-600 group-hover:scale-110 transition-transform">
<Plus class="w-8 h-8" />
</div>
<div class="text-center">
<h3 class="text-xl font-bold text-gray-800">New Estimate</h3>
<p class="text-gray-500 text-sm mt-1">Create from CSV or scratch</p>
</div>
</button>
<button
onClick={() => setShowLoadModal(true)}
class="flex flex-col items-center gap-4 p-8 bg-white border-2 border-dashed border-gray-200 rounded-3xl hover:border-green-400 hover:bg-green-50/30 transition-all group"
>
<div class="w-16 h-16 rounded-2xl bg-green-50 flex items-center justify-center text-green-600 group-hover:scale-110 transition-transform">
<Download class="w-8 h-8" />
</div>
<div class="text-center">
<h3 class="text-xl font-bold text-gray-800">Load Estimate</h3>
<p class="text-gray-500 text-sm mt-1">Open from cloud storage</p>
</div>
</button>
</div>
</div>
<button
onClick={() => setShowLoadModal(true)}
class="flex items-center justify-center gap-3 w-full max-w-sm px-6 py-4 bg-white text-gray-700 rounded-2xl font-bold shadow-sm border border-gray-200 hover:bg-gray-50 hover:border-gray-300 hover:shadow transition-all"
>
<Download class="w-5 h-5 text-blue-600" /> Load Saved Estimate
</button>
</div>
</Show>
<Show when={mode() === 'new-selection'}>
<div class="relative">
<button
onClick={() => setMode('initial')}
class="absolute -top-4 -left-4 p-2 text-gray-400 hover:text-gray-600 transition-colors"
>
<ArrowLeft class="w-6 h-6" />
</button>
<div class="flex items-center justify-between mb-8 pt-4">
<div>
<h1 class="text-3xl font-bold text-gray-800 mb-2">Create New Estimate</h1>
<p class="text-gray-600">Choose how you want to start your estimate.</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div class="p-6 bg-blue-50/50 rounded-2xl border border-blue-100">
<div class="flex items-center gap-3 mb-4">
<FileSpreadsheet class="w-6 h-6 text-blue-600" />
<h3 class="font-bold text-gray-900">Item Extractor</h3>
</div>
<p class="text-sm text-gray-600 mb-6">Upload a CSV file to automatically extract items and quantities.</p>
<FileUpload onFileSelect={handleFileSelect} />
</div>
<button
onClick={handleNewBlankEstimate}
class="flex flex-col items-start p-6 bg-gray-50 rounded-2xl border border-gray-200 hover:border-gray-300 hover:bg-white transition-all text-left group"
>
<div class="flex items-center gap-3 mb-4">
<FilePlus class="w-6 h-6 text-gray-600" />
<h3 class="font-bold text-gray-900">Blank Estimate</h3>
</div>
<p class="text-sm text-gray-600 mb-6">Start with a completely empty slate and build manually.</p>
<div class="mt-auto w-full py-3 bg-white border border-gray-200 rounded-xl text-center font-bold text-gray-700 group-hover:bg-gray-50 transition-colors">
Start Blank
</div>
</button>
</div>
</div>
</Show>
</div>
</Show>
{/* Results */}
<Show
when={items().length > 0 || estimateItems.length > 0}
when={items().length > 0 || estimateItems.length > 0 || scopes.length > 0}
fallback={
<div class="text-gray-400 text-center mt-12 opacity-50">
<p>Results will appear here</p>
@@ -80,11 +155,6 @@ const ItemExtractor: Component<ItemExtractorProps> = () => {
<div class="w-full">
<EstimateBuilder
initialItems={items()}
onReset={() => {
setItems([]);
setIgnoredLines([]);
setEstimateName('');
}}
/>
</div>
</Show>
+121 -38
View File
@@ -1,10 +1,9 @@
import type { Component } from 'solid-js';
import { createSignal, Show, For, createEffect } from 'solid-js';
import { createSignal, Show, For, createEffect, createMemo, type Component } from 'solid-js';
import { Portal } from 'solid-js/web';
import { X, FileBox, CheckCircle2 } from 'lucide-solid';
import { X, FileBox, CheckCircle2, Search } from 'lucide-solid';
import { pb, COLLECTIONS } from '../utils/db';
import { appStore } from '../store/appStore';
import type { EstimateItem, TemplateItem } from '../types';
import type { EstimateItem, TemplateItem, Modifier } from '../types';
interface ApplyTemplateModalProps {
show: boolean;
@@ -17,9 +16,36 @@ const ApplyTemplateModal: Component<ApplyTemplateModalProps> = (props) => {
const [templates, setTemplates] = createSignal<any[]>([]);
const [isLoading, setIsLoading] = createSignal(false);
const [searchQuery, setSearchQuery] = createSignal('');
const [targetSubScopeId, setTargetSubScopeId] = createSignal<string>('scope'); // 'scope' means no sub-scope selected
const [isApplying, setIsApplying] = createSignal(false);
const filteredAndGroupedTemplates = createMemo(() => {
const query = searchQuery().toLowerCase();
let filtered = templates().filter(t =>
t.name.toLowerCase().includes(query) ||
(t.defaultSubScopeName || '').toLowerCase().includes(query)
);
// Sort alphabetically by name
filtered.sort((a, b) => a.name.localeCompare(b.name));
// Group by defaultSubScopeName
const groups: Record<string, any[]> = {};
filtered.forEach(t => {
const groupName = t.defaultSubScopeName || 'Uncategorized';
if (!groups[groupName]) groups[groupName] = [];
groups[groupName].push(t);
});
// Convert to array of groups for sorting groups
return Object.entries(groups).sort(([a], [b]) => {
if (a === 'Uncategorized') return 1;
if (b === 'Uncategorized') return -1;
return a.localeCompare(b);
});
});
createEffect(() => {
if (props.show) {
loadTemplates();
@@ -44,13 +70,21 @@ const ApplyTemplateModal: Component<ApplyTemplateModalProps> = (props) => {
const applyTemplate = (record: any) => {
const templateItems: TemplateItem[] = record.items || [];
if (templateItems.length === 0) {
alert('This template has no items.');
if (templateItems.length === 0 && (record.modifiers || []).length === 0) {
alert('This template has no items or modifiers.');
return;
}
setIsApplying(true);
// Find default sub-scope if target is 'scope' and template has a defaultSubScopeName
let finalSubScopeId = targetSubScopeId() !== 'scope' ? targetSubScopeId() : undefined;
if (!finalSubScopeId && record.defaultSubScopeName) {
const match = subScopes().find(ss => ss.name.toLowerCase() === record.defaultSubScopeName.toLowerCase());
if (match) finalSubScopeId = match.id;
}
const newItems: EstimateItem[] = templateItems.map(item => ({
id: crypto.randomUUID(),
description: item.description,
@@ -61,12 +95,27 @@ const ApplyTemplateModal: Component<ApplyTemplateModalProps> = (props) => {
contingency: item.contingency,
contingencyType: item.contingencyType,
scopeId: props.scopeId || undefined,
subScopeId: targetSubScopeId() !== 'scope' ? targetSubScopeId() : undefined
subScopeId: finalSubScopeId
}));
// Append to existing items
appStore.setEstimateItems(prev => [...prev, ...newItems]);
// Copy modifiers if target is (or resolved to) a sub-scope
if (finalSubScopeId) {
const templateModifiers: Modifier[] = record.modifiers || [];
if (templateModifiers.length > 0) {
appStore.setSubScopes(
ss => ss.id === finalSubScopeId,
'modifiers',
(existing = []) => [
...existing,
...templateModifiers.map(m => ({ ...m, id: crypto.randomUUID() }))
]
);
}
}
setIsApplying(false);
props.onClose();
};
@@ -92,50 +141,78 @@ const ApplyTemplateModal: Component<ApplyTemplateModalProps> = (props) => {
</div>
<div class="flex-1 flex flex-col p-6 gap-6 overflow-hidden">
{/* Target Selection */}
<div class="space-y-3 shrink-0">
<label class="text-xs font-black text-muted-foreground uppercase tracking-wider">Target Destination</label>
<select
value={targetSubScopeId()}
onChange={(e) => setTargetSubScopeId(e.currentTarget.value)}
class="w-full bg-muted/20 border border-border/60 p-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium appearance-none"
>
<option value="scope">Scope Level (Default)</option>
<For each={subScopes()}>
{(ss) => (
<option value={ss.id}>Sub-scope: {ss.name}</option>
)}
</For>
</select>
{/* Target Selection & Search */}
<div class="flex gap-4 shrink-0">
<div class="flex-1 space-y-3">
<label class="text-xs font-black text-muted-foreground uppercase tracking-wider">Target Destination</label>
<select
value={targetSubScopeId()}
onChange={(e) => setTargetSubScopeId(e.currentTarget.value)}
class="w-full bg-muted/20 border border-border/60 p-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium appearance-none"
>
<option value="scope">Scope Level (Default)</option>
<For each={subScopes()}>
{(ss) => (
<option value={ss.id}>Sub-scope: {ss.name}</option>
)}
</For>
</select>
</div>
<div class="flex-1 space-y-3">
<label class="text-xs font-black text-muted-foreground uppercase tracking-wider">Search Templates</label>
<div class="relative">
<Search class="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by name or target..."
value={searchQuery()}
onInput={(e) => setSearchQuery(e.currentTarget.value)}
class="w-full bg-muted/20 border border-border/60 pl-11 pr-4 py-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium"
/>
</div>
</div>
</div>
{/* Templates List */}
<div class="flex-1 flex flex-col min-h-0 space-y-3">
<label class="text-xs font-black text-muted-foreground uppercase tracking-wider">Select Template</label>
<div class="flex-1 overflow-y-auto space-y-3 bg-muted/10 border border-border/50 rounded-2xl p-4">
<div class="flex-1 overflow-y-auto space-y-6 bg-muted/10 border border-border/50 rounded-2xl p-4">
<Show
when={!isLoading()}
fallback={<div class="py-12 text-center text-muted-foreground text-sm font-medium">Loading templates...</div>}
>
<For each={templates()}>
{(record) => (
<div class="group flex items-center justify-between p-4 rounded-xl border border-border/60 bg-card hover:border-primary/50 hover:shadow-premium transition-all">
<div>
<h4 class="font-bold text-foreground">{record.name}</h4>
<p class="text-[10px] text-muted-foreground font-medium uppercase tracking-wider mt-1">
{record.items?.length || 0} Items {new Date(record.created).toLocaleDateString()}
</p>
<For each={filteredAndGroupedTemplates()}>
{([groupName, groupTemplates]) => (
<div class="space-y-3">
<div class="flex items-center gap-2 px-1">
<span class="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">{groupName}</span>
<div class="flex-1 h-px bg-border/40"></div>
</div>
<div class="grid gap-3">
<For each={groupTemplates}>
{(record) => (
<div class="group flex items-center justify-between p-4 rounded-xl border border-border/60 bg-card hover:border-primary/50 hover:shadow-premium transition-all">
<div>
<h4 class="font-bold text-foreground">{record.name}</h4>
<p class="text-[10px] text-muted-foreground font-medium uppercase tracking-wider mt-1">
{record.items?.length || 0} Items {new Date(record.created).toLocaleDateString()}
</p>
</div>
<button
disabled={isApplying()}
onClick={() => applyTemplate(record)}
class="flex items-center gap-2 px-4 py-2 bg-primary/10 text-primary border border-primary/20 rounded-lg text-xs font-bold shadow-sm hover:bg-primary hover:text-primary-foreground hover:border-primary transition-all"
>
<CheckCircle2 class="w-4 h-4" /> Apply
</button>
</div>
)}
</For>
</div>
<button
disabled={isApplying()}
onClick={() => applyTemplate(record)}
class="flex items-center gap-2 px-4 py-2 bg-primary/10 text-primary border border-primary/20 rounded-lg text-xs font-bold shadow-sm hover:bg-primary hover:text-primary-foreground hover:border-primary transition-all"
>
<CheckCircle2 class="w-4 h-4" /> Apply
</button>
</div>
)}
</For>
<Show when={templates().length === 0}>
<div class="py-12 flex flex-col items-center justify-center text-center text-muted-foreground/60 border-2 border-dashed border-border/60 rounded-xl bg-background/50 h-full">
<FileBox class="w-8 h-8 mb-3 opacity-50" />
@@ -143,6 +220,12 @@ const ApplyTemplateModal: Component<ApplyTemplateModalProps> = (props) => {
<p class="text-xs mt-1">Create one in the Template Creator first.</p>
</div>
</Show>
<Show when={templates().length > 0 && filteredAndGroupedTemplates().length === 0}>
<div class="py-12 text-center text-muted-foreground/50 italic text-sm">
No templates match your search.
</div>
</Show>
</Show>
</div>
</div>
+144 -23
View File
@@ -7,6 +7,8 @@ interface Entry {
type: 'number' | 'field' | 'marker' | 'result';
value?: number;
label?: string;
markerName?: string;
markerTime?: string;
}
import { Portal } from 'solid-js/web';
@@ -17,6 +19,9 @@ const CalculatorPopover: Component = () => {
const [operator, setOperator] = createSignal<'+' | '-' | '*' | '/' | null>(null);
const [currentInput, setCurrentInput] = createSignal('');
const [isHovered, setIsHovered] = createSignal(false);
const [copyToast, setCopyToast] = createSignal(false);
const [editingMarkerId, setEditingMarkerId] = createSignal<string | null>(null);
const [markerDraft, setMarkerDraft] = createSignal('');
// For simplicity in this version, we'll just track a running total
const [runningTotal, setRunningTotal] = createSignal(0);
@@ -90,7 +95,24 @@ const CalculatorPopover: Component = () => {
};
const createMarker = () => {
setEntries(prev => [...prev, { id: crypto.randomUUID(), type: 'marker', label: `Marker ${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` }]);
const markerTime = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
setEntries(prev => [...prev, { id: crypto.randomUUID(), type: 'marker', markerName: 'Marker', markerTime }]);
};
const startEditingMarker = (entry: Entry) => {
if (entry.type !== 'marker') return;
setEditingMarkerId(entry.id);
setMarkerDraft(entry.markerName || 'Marker');
};
const saveMarkerName = (id: string) => {
const nextName = markerDraft().trim() || 'Marker';
setEntries(prev => prev.map(entry =>
entry.id === id && entry.type === 'marker'
? { ...entry, markerName: nextName }
: entry
));
setEditingMarkerId(null);
};
const clearInput = () => {
@@ -110,10 +132,48 @@ const CalculatorPopover: Component = () => {
return num.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 4 });
};
const getCopyValue = () => {
if (currentInput() !== '') {
return currentInput().replace(/,/g, '');
}
return String(runningTotal()).replace(/,/g, '');
};
const copyDisplayedValue = async () => {
try {
await navigator.clipboard.writeText(getCopyValue());
setCopyToast(true);
window.setTimeout(() => setCopyToast(false), 2000);
} catch (err) {
console.error('Failed to copy calculator value', err);
}
};
const copyEntryValue = async (entry: Entry) => {
if (entry.type === 'marker' || entry.value === undefined) return;
try {
await navigator.clipboard.writeText(String(entry.value).replace(/,/g, ''));
setCopyToast(true);
window.setTimeout(() => setCopyToast(false), 2000);
} catch (err) {
console.error('Failed to copy calculator history value', err);
}
};
// Keyboard support
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen()) return;
// Don't intercept if an input is focused
const activeElement = document.activeElement;
const isInputFocused = activeElement instanceof HTMLInputElement ||
activeElement instanceof HTMLTextAreaElement ||
activeElement?.hasAttribute('contenteditable');
if (isInputFocused) return;
if (e.key >= '0' && e.key <= '9') {
handleNumberClick(e.key);
} else if (e.key === '.') {
@@ -137,12 +197,31 @@ const CalculatorPopover: Component = () => {
}
};
const handlePaste = (e: ClipboardEvent) => {
if (!isOpen()) return;
// Don't intercept if an input is focused
const activeElement = document.activeElement;
const isInputFocused = activeElement instanceof HTMLInputElement ||
activeElement instanceof HTMLTextAreaElement ||
activeElement?.hasAttribute('contenteditable');
if (isInputFocused) return;
const pastedText = e.clipboardData?.getData('text/plain');
if (pastedText && !isNaN(parseFloat(pastedText))) {
addValue(parseFloat(pastedText), 'Pasted Value');
}
};
onMount(() => {
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('paste', handlePaste);
});
onCleanup(() => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('paste', handlePaste);
});
return (
@@ -150,7 +229,7 @@ const CalculatorPopover: Component = () => {
<div class="fixed bottom-6 right-6 z-[100] no-print">
<Show when={isOpen()}>
<div
class={`absolute bottom-24 right-0 w-[400px] bg-card rounded-[3rem] shadow-elevated border border-border overflow-hidden flex flex-col transition-all animate-in slide-in-from-bottom-8 duration-500 ease-out ${isHovered() ? 'ring-4 ring-primary/20 bg-primary/5' : ''}`}
class={`absolute bottom-24 right-0 w-[400px] max-h-[calc(100vh-12rem)] bg-card rounded-[3rem] shadow-elevated border border-border overflow-hidden flex flex-col transition-all animate-in slide-in-from-bottom-8 duration-500 ease-out ${isHovered() ? 'ring-4 ring-primary/20 bg-primary/5' : ''}`}
onDragOver={(e) => { e.preventDefault(); setIsHovered(true); }}
onDragLeave={() => setIsHovered(false)}
onDrop={handleDrop}
@@ -174,31 +253,63 @@ const CalculatorPopover: Component = () => {
</div>
{/* Display */}
<div class="p-8 bg-foreground text-background min-h-[220px] flex flex-col justify-end items-end relative overflow-hidden group/display">
<div class="absolute top-6 left-8 text-[10px] font-black text-background/30 uppercase tracking-[0.3em]">Calculation History</div>
<div class="p-8 bg-muted text-foreground min-h-[220px] flex-1 flex flex-col justify-end items-end relative overflow-hidden group/display border-b border-border">
<div class="absolute top-6 left-8 text-[10px] font-black text-muted-foreground/30 uppercase tracking-[0.3em]">Calculation History</div>
<div class="flex flex-col items-end gap-2 mb-4 max-h-56 overflow-y-auto w-full custom-scrollbar pr-2 scroll-smooth">
<div class="flex-1 flex flex-col items-end gap-2 mb-4 min-h-0 overflow-y-auto w-full custom-scrollbar pr-2 scroll-smooth pt-8">
<For each={entries()}>
{(entry) => (
<Show when={entry.type === 'marker'} fallback={
<div class={`flex items-center gap-3 group/entry transition-all ${entry.type === 'result' ? 'mt-3 pt-3 border-t border-background/10 w-full justify-end' : ''}`}>
<div class={`flex items-center gap-3 group/entry transition-all ${entry.type === 'result' ? 'mt-3 pt-3 border-t border-border w-full justify-end' : ''}`}>
<Show when={entry.label}>
<span class="text-[9px] font-black text-background/40 uppercase tracking-wider truncate max-w-[200px] group-hover/entry:text-background/60 transition-colors">{entry.label}</span>
<span class="text-[9px] font-black text-muted-foreground/40 uppercase tracking-wider truncate max-w-[200px] group-hover/entry:text-muted-foreground/60 transition-colors">{entry.label}</span>
</Show>
<div class={`px-3 py-1 rounded-xl text-xs font-black shadow-sm transition-transform group-hover/entry:scale-105 ${entry.type === 'field' ? 'bg-primary text-primary-foreground shadow-primary/20' :
entry.type === 'result' ? 'bg-primary/20 text-primary border border-primary/30' : 'bg-background/10 text-background/80'
}`}>
<button
type="button"
onClick={() => copyEntryValue(entry)}
title="Copy value"
class={`px-3 py-1 rounded-xl text-xs font-black shadow-sm transition-transform group-hover/entry:scale-105 ${entry.type === 'field' ? 'bg-primary text-primary-foreground shadow-primary/20' :
entry.type === 'result' ? 'bg-primary/20 text-primary border border-primary/30' : 'bg-background text-foreground/80 border border-border/60'
}`}
>
{entry.type === 'result' && <span class="mr-2 opacity-60">=</span>}
{formatNumber(entry.value || 0)}
</div>
</button>
</div>
}>
<div class="w-full flex items-center gap-4 my-4 opacity-50">
<div class="h-px flex-1 bg-gradient-to-r from-transparent to-background/20"></div>
<span class="text-[9px] font-black text-background/60 uppercase tracking-[0.2em] whitespace-nowrap bg-background/5 px-3 py-1 rounded-full border border-background/10 italic">
{entry.label}
</span>
<div class="h-px flex-1 bg-gradient-to-l from-transparent to-background/20"></div>
<div class="h-px flex-1 bg-gradient-to-r from-transparent to-border"></div>
<Show
when={editingMarkerId() === entry.id}
fallback={
<button
type="button"
onClick={() => startEditingMarker(entry)}
title="Rename marker"
class="text-[9px] font-black text-muted-foreground/60 uppercase tracking-[0.2em] whitespace-nowrap bg-muted px-3 py-1 rounded-full border border-border italic"
>
{(entry.markerName || 'Marker')} {entry.markerTime}
</button>
}
>
<input
type="text"
value={markerDraft()}
onInput={(e) => setMarkerDraft(e.currentTarget.value)}
onBlur={() => saveMarkerName(entry.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
saveMarkerName(entry.id);
} else if (e.key === 'Escape') {
setEditingMarkerId(null);
}
}}
class="text-[9px] font-black text-muted-foreground/80 uppercase tracking-[0.2em] whitespace-nowrap bg-background px-3 py-1 rounded-full border border-primary/30 italic text-center outline-none"
aria-label="Marker name"
autofocus
/>
</Show>
<div class="h-px flex-1 bg-gradient-to-l from-transparent to-border"></div>
</div>
</Show>
)}
@@ -208,12 +319,20 @@ const CalculatorPopover: Component = () => {
</Show>
</div>
<div class="text-4xl xl:text-5xl font-black tracking-tighter mt-2 flex items-baseline gap-3 group-hover/display:scale-105 transition-transform duration-500 origin-right">
<button
type="button"
onClick={copyDisplayedValue}
title="Copy value"
class="text-4xl xl:text-5xl font-black tracking-tighter mt-2 flex items-baseline gap-3 group-hover/display:scale-105 transition-transform duration-500 origin-right text-right"
>
<Show when={currentInput() === '' && entries().length > 0 && entries()[entries().length - 1].type === 'result'}>
<span class="text-primary text-base font-bold uppercase tracking-widest opacity-60">Total</span>
</Show>
<span class="text-primary text-2xl font-medium">$</span>
{currentInput() || formatNumber(runningTotal())}
</button>
<div class="mt-3 text-[10px] font-black uppercase tracking-[0.25em] text-primary/60">
{copyToast() ? 'Copied' : 'Click any number to copy'}
</div>
</div>
@@ -251,17 +370,19 @@ const CalculatorPopover: Component = () => {
</div>
</Show>
<button
onClick={() => setIsOpen(!isOpen())}
class={`p-5 rounded-full shadow-elevated transition-all duration-500 group relative border-2 ${isOpen() ? 'bg-foreground text-background border-foreground rotate-90 scale-110' : 'bg-primary text-primary-foreground border-primary shadow-primary/20 hover:scale-110 hover:shadow-2xl'}`}
>
{isOpen() ? <X class="w-6 h-6" /> : <Calculator class="w-6 h-6" />}
<div class="group relative flex items-center">
<Show when={!isOpen()}>
<span class="absolute right-full mr-6 px-4 py-2 bg-foreground text-background text-[10px] font-black uppercase tracking-widest rounded-xl opacity-0 group-hover:opacity-100 transition-all translate-x-4 group-hover:translate-x-0 pointer-events-none shadow-2xl flex items-center gap-2">
<Calculator class="w-3 h-3 text-primary" /> Calculator
</span>
</Show>
</button>
<button
onClick={() => setIsOpen(!isOpen())}
class={`p-5 rounded-full shadow-elevated transition-all duration-500 relative border-2 ${isOpen() ? 'bg-foreground text-background border-foreground rotate-90 scale-110' : 'bg-primary text-primary-foreground border-primary shadow-primary/20 hover:scale-110 hover:shadow-2xl'}`}
>
{isOpen() ? <X class="w-6 h-6" /> : <Calculator class="w-6 h-6" />}
</button>
</div>
</div>
</Portal>
+37 -21
View File
@@ -4,6 +4,7 @@ import { Portal } from 'solid-js/web';
import { X } from 'lucide-solid';
import { pb, COLLECTIONS } from '../utils/db';
import { appStore } from '../store/appStore';
import { normalizeScopes } from '../utils/scope-utils';
interface CloudLoadModalProps {
show: boolean;
@@ -13,6 +14,7 @@ interface CloudLoadModalProps {
const CloudLoadModal: Component<CloudLoadModalProps> = (props) => {
const [cloudEstimates, setCloudEstimates] = createSignal<any[]>([]);
const [isCloudLoading, setIsCloudLoading] = createSignal(false);
const [loadingRecordId, setLoadingRecordId] = createSignal<string | null>(null);
createEffect(() => {
if (props.show) {
@@ -23,8 +25,10 @@ const CloudLoadModal: Component<CloudLoadModalProps> = (props) => {
const loadCloudEstimatesList = async () => {
setIsCloudLoading(true);
try {
// Optimized: Only fetch metadata fields to keep the list response small and fast
const records = await pb.collection(COLLECTIONS.ESTIMATES).getFullList({
sort: '-created'
sort: '-created',
fields: 'id,name,created,updated'
});
setCloudEstimates(records);
} catch (err) {
@@ -35,25 +39,36 @@ const CloudLoadModal: Component<CloudLoadModalProps> = (props) => {
}
};
const loadFromCloud = (record: any) => {
const data = record.items; // items holds the full JSON blob
if (!data) return;
const loadFromCloud = async (recordMetadata: any) => {
setLoadingRecordId(recordMetadata.id);
try {
// Fetch the full record with all fields (items, history) only when requested
const record = await pb.collection(COLLECTIONS.ESTIMATES).getOne(recordMetadata.id);
const data = record.items;
if (!data) return;
if (data.scopes) appStore.setScopes(data.scopes);
if (data.subScopes) appStore.setSubScopes(data.subScopes);
if (data.items) appStore.setEstimateItems(data.items);
if (data.clientInfo) appStore.setClientInfo(data.clientInfo);
if (data.jobInfo) appStore.setJobInfo(data.jobInfo);
if (data.generalEstimateNotes !== undefined) appStore.setGeneralEstimateNotes(data.generalEstimateNotes);
if (data.generalPreNotes !== undefined) appStore.setGeneralPreNotes(data.generalPreNotes);
appStore.setEstimateName(record.name);
appStore.setEstimateId(record.id);
// Also clear out the extracted CSV items so we don't accidentally switch back to the upload screen or cause conflicts
appStore.setItems([]);
props.onClose();
if (data.scopes) appStore.setScopes(normalizeScopes(data.scopes));
if (data.subScopes) appStore.setSubScopes(data.subScopes);
if (data.items) appStore.setEstimateItems(data.items);
if (data.clientInfo) appStore.setClientInfo(data.clientInfo);
if (data.jobInfo) appStore.setJobInfo(data.jobInfo);
if (data.generalEstimateNotes !== undefined) appStore.setGeneralEstimateNotes(data.generalEstimateNotes);
if (data.generalPreNotes !== undefined) appStore.setGeneralPreNotes(data.generalPreNotes);
appStore.setEstimateName(record.name);
appStore.setEstimateId(record.id);
appStore.setHistory(record.history || []);
appStore.setLatestSnapshot(record.items);
appStore.setIsLatestVersion(true);
appStore.setItems([]);
props.onClose();
} catch (err) {
console.error(err);
alert('Failed to load full estimate data.');
} finally {
setLoadingRecordId(null);
}
};
return (
@@ -83,10 +98,11 @@ const CloudLoadModal: Component<CloudLoadModalProps> = (props) => {
</p>
</div>
<button
disabled={loadingRecordId() !== null}
onClick={() => loadFromCloud(record)}
class="px-4 py-2 bg-background border border-border rounded-xl text-xs font-bold shadow-sm hover:text-primary hover:border-primary/50 transition-all opacity-0 group-hover:opacity-100"
class="px-4 py-2 bg-background border border-border rounded-xl text-xs font-bold shadow-sm hover:text-primary hover:border-primary/50 transition-all opacity-0 group-hover:opacity-100 disabled:opacity-50"
>
Load
{loadingRecordId() === record.id ? 'Fetching...' : 'Load'}
</button>
</div>
)}
+323 -9
View File
@@ -1,11 +1,12 @@
import type { Component } from 'solid-js';
import { createSignal, createMemo, onMount, Show } from 'solid-js';
import { createSignal, createMemo, onMount, Show, For } from 'solid-js';
import { FileText, ChevronDown, CheckCircle2, X } from 'lucide-solid';
import { Portal } from 'solid-js/web';
import { appStore } from '../store/appStore';
import type { Scope, SubScope, EstimateItem } from '../types';
import type { ExtractedItem } from '../utils/csv-parser';
import { extractItemsFromCSV } from '../utils/csv-parser';
import { GENERAL_NOTES_DEFAULT, GENERAL_PRE_NOTES_DEFAULT } from '../company-info';
import PrintEstimate from './PrintEstimate';
@@ -14,6 +15,8 @@ import CalculatorPopover from './CalculatorPopover';
import CloudLoadModal from './CloudLoadModal';
import ExportTableModal from './ExportTableModal';
import ApplyTemplateModal from './ApplyTemplateModal';
import HistoryModal from './estimate-builder/HistoryModal';
import { NewEstimateModal } from './estimate-builder/NewEstimateModal';
// Import extracted components & hooks
import { useEstimateCalculations } from './estimate-builder/useEstimateCalculations';
@@ -32,10 +35,11 @@ import { ScopesList } from './estimate-builder/ScopesList';
interface EstimateBuilderProps {
initialItems: ExtractedItem[];
onReset: () => void;
}
const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
type PrintMode = 'finalized' | 'manager';
// 1. Initialize State
const { scopes, setScopes, subScopes, setSubScopes, estimateItems: items, setEstimateItems: setItems, clientInfo, jobInfo, generalEstimateNotes, setGeneralEstimateNotes, generalPreNotes, setGeneralPreNotes } = appStore;
@@ -46,8 +50,15 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
// Cloud Modals
const [showSaveModal, setShowSaveModal] = createSignal(false);
const [showLoadModal, setShowLoadModal] = createSignal(false);
const [showHistoryModal, setShowHistoryModal] = createSignal(false);
const [showNewEstimateModal, setShowNewEstimateModal] = createSignal(false);
const [showExportTableModal, setShowExportTableModal] = createSignal(false);
const [showCsvImportModal, setShowCsvImportModal] = createSignal(false);
const [applyTemplateScopeId, setApplyTemplateScopeId] = createSignal<string | null>(null);
const [pendingCsvImportText, setPendingCsvImportText] = createSignal<string | null>(null);
const [excludedScopeIds, setExcludedScopeIds] = createSignal<string[]>([]);
const [excludedSubScopeIds, setExcludedSubScopeIds] = createSignal<string[]>([]);
const [printMode, setPrintMode] = createSignal<PrintMode>('finalized');
// 2. Initialize custom hooks/primitives
const { selectedIds, setSelectedIds, lastSelectedId, setLastSelectedId, toggleSelection, clearSelection, copyColumnSum } = useSelectionManager();
@@ -58,11 +69,12 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
};
const { hoveredScopeId, setHoveredScopeId, handleDragStart, handleDrop } = useDragAndDrop(selectedIds, setSelectedIds, updateItem);
const { exportEstimate, importEstimate, saveToCloud, isCloudLoading } = useEstimateSync(setShowSaveModal);
const { exportEstimate, importEstimate, saveToCloud, saveChangesToCloud, saveAsNewestToCloud, checkoutVersion, isCloudLoading } = useEstimateSync(setShowSaveModal);
onMount(() => {
if (!generalEstimateNotes()) setGeneralEstimateNotes(GENERAL_NOTES_DEFAULT);
if (!generalPreNotes()) setGeneralPreNotes(GENERAL_PRE_NOTES_DEFAULT);
appStore.loadSuppliers();
if (items.length === 0 && props.initialItems.length > 0) {
const transformed: EstimateItem[] = props.initialItems.map(item => ({
@@ -83,7 +95,19 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
const anyDescriptionIsLong = createMemo(() => items.some(item => (item.description || '').length > 120));
const finalizeBid = () => {
window.print();
runPrint('finalized');
};
const exportManagerInfo = () => {
runPrint('manager');
};
const runPrint = (mode: PrintMode) => {
setPrintMode(mode);
window.requestAnimationFrame(() => {
window.print();
window.setTimeout(() => setPrintMode('finalized'), 0);
});
};
const addScope = () => {
@@ -93,8 +117,12 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
name: 'New Scope',
managementFee: 0,
managementFeeType: 'percent',
managementFeeQuantity: 1,
managementFeeRate: 0,
deliveryFee: 0,
deliveryFeeType: 'percent',
deliveryFeeQuantity: 1,
deliveryFeeRate: 0,
useManagementLogic: 'percent',
useDeliveryLogic: 'percent',
estimatorNotes: '',
@@ -103,6 +131,7 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
setScopes(prev => [...prev, newScope]);
addSubScopeWithName(scopeId, 'Materials');
addSubScopeWithName(scopeId, 'Labor');
return scopeId;
};
const addSubScopeWithName = (scopeId: string, name: string) => {
@@ -112,6 +141,7 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
scopeId
};
setSubScopes(prev => [...prev, newSubScope]);
return newSubScope.id;
};
const addSubScope = (scopeId: string) => {
@@ -174,10 +204,156 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
const [copyToast, setCopyToast] = createSignal<string | null>(null);
const normalizeDescription = (value: string) => value.trim().toLowerCase().replace(/\s+/g, ' ');
const parseImportedQuantity = (value: string) => {
const parsed = Number.parseFloat(value.replace(/,/g, '').trim());
return Number.isFinite(parsed) ? parsed : 0;
};
const mergeCsvQuantities = (csvText: string, options?: { excludedScopeIds?: string[]; excludedSubScopeIds?: string[] }) => {
const { items: extracted, ignored } = extractItemsFromCSV(csvText);
appStore.setIgnoredLines(ignored);
if (extracted.length === 0) {
setCopyToast('No CSV items were found to import.');
window.setTimeout(() => setCopyToast(null), 2500);
return;
}
const importedByDescription = new Map<string, { description: string; quantity: number }>();
for (const item of extracted) {
const key = normalizeDescription(item.description);
if (!key) continue;
const quantity = parseImportedQuantity(item.quantity);
const existing = importedByDescription.get(key);
if (existing) {
existing.quantity += quantity;
} else {
importedByDescription.set(key, {
description: item.description.trim(),
quantity
});
}
}
let updatedExistingCount = 0;
const matchedDescriptions = new Set<string>();
const excludedScopeIdSet = new Set(options?.excludedScopeIds ?? []);
const excludedSubScopeIdSet = new Set(options?.excludedSubScopeIds ?? []);
const nextItems = items.map(item => {
const isExcludedByScope = item.scopeId ? excludedScopeIdSet.has(item.scopeId) : false;
const isExcludedBySubScope = item.subScopeId ? excludedSubScopeIdSet.has(item.subScopeId) : false;
if (isExcludedByScope || isExcludedBySubScope) return item;
const key = normalizeDescription(item.description);
const imported = importedByDescription.get(key);
if (!imported) return item;
matchedDescriptions.add(key);
updatedExistingCount += 1;
return { ...item, quantity: imported.quantity };
});
const newUnassignedItems: EstimateItem[] = [];
for (const [key, imported] of importedByDescription.entries()) {
if (matchedDescriptions.has(key)) continue;
newUnassignedItems.push({
id: crypto.randomUUID(),
description: imported.description,
quantity: imported.quantity,
unitPrice: 0,
markup: 0,
markupType: 'percent',
contingency: 0,
contingencyType: 'percent'
});
}
setItems([...nextItems, ...newUnassignedItems]);
const ignoredCount = ignored.length;
const excludedCount = excludedScopeIdSet.size + excludedSubScopeIdSet.size;
const toastParts = [
`Updated ${updatedExistingCount} item${updatedExistingCount === 1 ? '' : 's'}`,
`added ${newUnassignedItems.length} unassigned`,
excludedCount > 0 ? `excluded ${excludedCount} selection${excludedCount === 1 ? '' : 's'}` : null,
ignoredCount > 0 ? `ignored ${ignoredCount} row${ignoredCount === 1 ? '' : 's'}` : null
].filter(Boolean);
setCopyToast(toastParts.join(' • '));
window.setTimeout(() => setCopyToast(null), 3000);
};
const toggleScopeExclusion = (scopeId: string) => {
const isExcluded = excludedScopeIds().includes(scopeId);
if (isExcluded) {
setExcludedScopeIds(prev => prev.filter(id => id !== scopeId));
return;
}
setExcludedScopeIds(prev => [...prev, scopeId]);
const subScopeIdsInScope = subScopes.filter(ss => ss.scopeId === scopeId).map(ss => ss.id);
if (subScopeIdsInScope.length > 0) {
setExcludedSubScopeIds(prev => prev.filter(id => !subScopeIdsInScope.includes(id)));
}
};
const toggleSubScopeExclusion = (subScopeId: string) => {
const isExcluded = excludedSubScopeIds().includes(subScopeId);
if (isExcluded) {
setExcludedSubScopeIds(prev => prev.filter(id => id !== subScopeId));
return;
}
setExcludedSubScopeIds(prev => [...prev, subScopeId]);
};
const closeCsvImportModal = () => {
setShowCsvImportModal(false);
setPendingCsvImportText(null);
setExcludedScopeIds([]);
setExcludedSubScopeIds([]);
};
const confirmCsvImport = () => {
const csvText = pendingCsvImportText();
if (!csvText) return;
mergeCsvQuantities(csvText, {
excludedScopeIds: excludedScopeIds(),
excludedSubScopeIds: excludedSubScopeIds()
});
closeCsvImportModal();
};
const handleImportCsvQuantities = (e: Event) => {
const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const text = event.target?.result;
if (typeof text === 'string') {
setPendingCsvImportText(text);
setExcludedScopeIds([]);
setExcludedSubScopeIds([]);
setShowCsvImportModal(true);
}
};
reader.readAsText(file);
input.value = '';
};
return (
<div class="w-full flex flex-col min-h-screen bg-white shadow-2xl relative print:shadow-none print:border-none print:rounded-none">
<PrintEstimate
scopes={scopes}
subScopes={subScopes}
items={items}
grandTotals={grandTotals}
clientInfo={clientInfo}
@@ -186,6 +362,7 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
generalPreNotes={generalPreNotes()}
formatNumber={formatNumber}
hideColumns={anyDescriptionIsLong()}
variant={printMode()}
/>
<div class="no-print flex flex-col min-h-dvh">
@@ -193,17 +370,29 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
isSidebarCollapsed={isSidebarCollapsed()}
onToggleSidebar={() => setIsSidebarCollapsed(!isSidebarCollapsed())}
onAddScope={addScope}
onReset={props.onReset}
onShowNewEstimateModal={() => setShowNewEstimateModal(true)}
onImportCsvQuantities={handleImportCsvQuantities}
onImportEstimate={importEstimate}
onShowExportTableModal={() => setShowExportTableModal(true)}
onExportEstimate={exportEstimate}
onShowLoadModal={() => setShowLoadModal(true)}
onShowSaveModal={() => setShowSaveModal(true)}
onShowHistoryModal={() => setShowHistoryModal(true)}
onSaveChanges={() => appStore.isLatestVersion() ? saveChangesToCloud() : saveAsNewestToCloud()}
/>
<div class="flex flex-1 relative items-start">
<BuilderSidebar
<div
style={{
display: 'grid',
'grid-template-columns': isSidebarCollapsed() ? '0px 1fr' : '18rem 1fr',
transition: 'grid-template-columns 500ms ease',
'align-items': 'start',
flex: '1',
}}
>
<BuilderSidebar
isCollapsed={isSidebarCollapsed()}
onToggleSidebar={() => setIsSidebarCollapsed(!isSidebarCollapsed())}
unassignedItems={unassignedItems()}
grandTotals={grandTotals()}
hoveredScopeId={hoveredScopeId()}
@@ -212,7 +401,7 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
onAddScope={addScope}
/>
<main onClick={clearSelection} class="flex-1 min-w-0 p-4 md:p-8 bg-zinc-50/50 space-y-8 min-h-screen">
<main onClick={clearSelection} class="min-w-0 p-4 md:p-8 bg-zinc-50/50 space-y-8 min-h-screen">
<ProjectDetailsForm />
<div class="bg-muted/30 border border-border rounded-3xl p-6 mb-8">
@@ -292,7 +481,7 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
<ValidationStatusBar validationStats={validationStats()} />
<ExecutiveSummary totals={grandTotals()} onFinalizeBid={finalizeBid} />
<ExecutiveSummary totals={grandTotals()} onFinalizeBid={finalizeBid} onExportManagerInfo={exportManagerInfo} />
</main>
</div>
@@ -353,6 +542,131 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
<CloudLoadModal show={showLoadModal()} onClose={() => setShowLoadModal(false)} />
<ExportTableModal show={showExportTableModal()} onClose={() => setShowExportTableModal(false)} />
<ApplyTemplateModal show={!!applyTemplateScopeId()} scopeId={applyTemplateScopeId()} onClose={() => setApplyTemplateScopeId(null)} />
<HistoryModal show={showHistoryModal()} onClose={() => setShowHistoryModal(false)} onCheckout={checkoutVersion} />
<Show when={showCsvImportModal()}>
<Portal>
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div class="bg-card w-full max-w-2xl rounded-3xl shadow-premium border border-border animate-in zoom-in-95 duration-200 overflow-hidden">
<div class="p-6 border-b border-border/50 flex items-center justify-between bg-muted/20">
<div>
<h3 class="text-xl font-black">Import CSV Quantities</h3>
<p class="text-[10px] text-muted-foreground font-black uppercase tracking-widest mt-1">Choose scopes or sub-scopes to leave unchanged</p>
</div>
<button onClick={closeCsvImportModal} class="p-2 hover:bg-muted rounded-xl transition-colors">
<X class="w-5 h-5 text-muted-foreground" />
</button>
</div>
<div class="p-6 space-y-5 max-h-[70vh] overflow-y-auto">
<p class="text-sm text-muted-foreground">
Matching items in excluded sections will keep their current quantities. Unassigned items are always eligible for updates.
</p>
<Show
when={scopes.length > 0}
fallback={
<div class="rounded-2xl border border-border bg-muted/20 px-4 py-5 text-sm text-muted-foreground">
No scopes exist yet. Import will only update unassigned items and add unmatched rows to the unassigned list.
</div>
}
>
<div class="space-y-4">
<For each={scopes}>
{(scope) => {
const scopeSubScopes = () => subScopes.filter(ss => ss.scopeId === scope.id);
const isScopeExcluded = () => excludedScopeIds().includes(scope.id);
return (
<div class="rounded-2xl border border-border bg-background overflow-hidden">
<label class="flex items-start gap-3 px-4 py-4 border-b border-border/50">
<input
type="checkbox"
checked={isScopeExcluded()}
onChange={() => toggleScopeExclusion(scope.id)}
class="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary"
/>
<div class="min-w-0">
<div class="font-bold text-foreground">{scope.name}</div>
<div class="text-xs text-muted-foreground">Exclude this entire scope from quantity replacement.</div>
</div>
</label>
<Show when={scopeSubScopes().length > 0}>
<div class={`px-4 py-3 space-y-2 ${isScopeExcluded() ? 'opacity-50' : ''}`}>
<div class="text-[10px] font-black uppercase tracking-widest text-muted-foreground">Sub-scopes</div>
<For each={scopeSubScopes()}>
{(subScope) => (
<label class="flex items-start gap-3 rounded-xl px-3 py-2 hover:bg-muted/30 transition-colors">
<input
type="checkbox"
checked={excludedSubScopeIds().includes(subScope.id)}
disabled={isScopeExcluded()}
onChange={() => toggleSubScopeExclusion(subScope.id)}
class="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary disabled:cursor-not-allowed"
/>
<div class="min-w-0">
<div class="font-medium text-foreground">{subScope.name}</div>
<div class="text-xs text-muted-foreground">Exclude only this sub-scope.</div>
</div>
</label>
)}
</For>
</div>
</Show>
</div>
);
}}
</For>
</div>
</Show>
</div>
<div class="p-6 bg-muted/20 border-t border-border/50 flex justify-end gap-3">
<button
onClick={closeCsvImportModal}
class="px-5 py-2.5 rounded-xl font-bold text-sm hover:bg-background transition-colors"
>
Cancel
</button>
<button
onClick={confirmCsvImport}
class="px-5 py-2.5 bg-primary text-primary-foreground rounded-xl font-bold text-sm shadow-premium hover:shadow-premium-hover transition-all"
>
Import Quantities
</button>
</div>
</div>
</div>
</Portal>
</Show>
<NewEstimateModal
show={showNewEstimateModal()}
onClose={() => setShowNewEstimateModal(false)}
onResetCSV={() => {
appStore.resetEstimate();
setShowNewEstimateModal(false);
}}
onManualEstimate={() => {
appStore.resetEstimate();
setItems([{
id: crypto.randomUUID(),
description: 'Item',
quantity: 1,
unitPrice: 0,
markup: 0,
markupType: 'percent',
contingency: 0,
contingencyType: 'percent'
}]);
setShowNewEstimateModal(false);
}}
/>
<datalist id="supplier-suggestions">
<For each={appStore.suppliers()}>
{(sup: string) => <option value={sup} />}
</For>
</datalist>
</div>
);
};
+8 -11
View File
@@ -3,6 +3,7 @@ import { createSignal, Show, For, onMount } from 'solid-js';
import { Portal } from 'solid-js/web';
import { X, Table, Copy, Download, CheckCircle2 } from 'lucide-solid';
import { appStore } from '../store/appStore';
import { getEffectiveItemPricing } from '../utils/pricing';
interface ExportTableModalProps {
show: boolean;
@@ -93,22 +94,18 @@ const ExportTableModal: Component<ExportTableModalProps> = (props) => {
filteredItems.forEach(item => {
const scope = scopes().find(s => s.id === item.scopeId);
const subScope = subScopes().find(ss => ss.id === item.subScopeId);
const base = item.quantity * item.unitPrice;
const markupAmt = item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
const contingencyAmt = item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
const total = base + markupAmt + contingencyAmt;
const pricing = getEffectiveItemPricing(item, subScope?.modifiers || []);
dataRows.push({
scope: scope?.name || 'Unassigned',
subscope: subScope?.name || 'Scope Level',
description: item.description,
quantity: item.quantity,
unitPrice: item.unitPrice,
subtotal: base,
markup: markupAmt,
contingency: contingencyAmt,
total: total
quantity: pricing.effectiveQuantity,
unitPrice: pricing.effectiveUnitPrice,
subtotal: pricing.effectiveSubtotal,
markup: pricing.markupAmount,
contingency: pricing.contingencyAmount,
total: pricing.total
});
});
+454 -202
View File
@@ -1,8 +1,11 @@
import type { Component } from 'solid-js';
import { createMemo, createEffect, createSignal, Show } from 'solid-js';
import { createMemo, createEffect, createSignal, Show, For, onCleanup } from 'solid-js';
import { Portal } from 'solid-js/web';
import { GripVertical, Trash2, Copy } from 'lucide-solid';
import NumericInput from './ui/NumericInput';
import type { EstimateItem } from '../types';
import type { EstimateItem, Modifier, StandardPrice } from '../types';
import { pb, COLLECTIONS } from '../utils/db';
import { getEffectiveItemPricing } from '../utils/pricing';
interface ItemRowProps {
item: EstimateItem;
@@ -15,24 +18,167 @@ interface ItemRowProps {
onToggleSelection: (id: string, isMulti: boolean, isShift: boolean) => void;
onDuplicateItem: (id: string) => void;
hideColumns?: boolean;
activeSupplier?: string;
subScopeModifiers?: Modifier[];
}
const ItemRow: Component<ItemRowProps> = (props) => {
let descriptionRef: HTMLTextAreaElement | undefined;
const baseTotal = createMemo(() => props.item.quantity * props.item.unitPrice);
const pricing = createMemo(() => getEffectiveItemPricing(props.item, props.subScopeModifiers || []));
const subtotal = createMemo(() => pricing().effectiveSubtotal);
const total = createMemo(() => pricing().total);
const total = createMemo(() => {
const base = baseTotal();
const markup = props.item.markupType === 'percent'
? base * (props.item.markup / 100)
: props.item.markup;
const contingency = props.item.contingencyType === 'percent'
? base * (props.item.contingency / 100)
: props.item.contingency;
return base + markup + contingency;
const [isSearchingPrice, setIsSearchingPrice] = createSignal(false);
const [searchResults, setSearchResults] = createSignal<StandardPrice[]>([]);
const [selectedIndex, setSelectedIndex] = createSignal(0);
const handlePriceSearch = async (searchString: string) => {
setIsSearchingPrice(true);
if (searchString.trim() === '') {
setSearchResults([]);
setSelectedIndex(0);
return;
}
try {
const filterParts = [`(name ~ "${searchString}" || category ~ "${searchString}")`];
if (props.activeSupplier) {
// Assuming exact match or contains? Let's use exact match for supplier
filterParts.push(`supplier = "${props.activeSupplier}"`);
}
const filterQuery = filterParts.join(' && ');
const records = await pb.collection(COLLECTIONS.STANDARD_PRICES).getList(1, 10, {
filter: filterQuery,
sort: 'category,name'
});
setSearchResults(records.items as unknown as StandardPrice[]);
setSelectedIndex(0);
} catch (error) {
console.error('Error searching standard prices:', error);
setSearchResults([]);
setSelectedIndex(0);
}
};
let desktopInputRef: HTMLInputElement | undefined;
let mobileInputRef: HTMLInputElement | undefined;
const handleSelectStandardPrice = (price: StandardPrice) => {
setIsSearchingPrice(false);
setSearchResults([]);
let updates: Partial<EstimateItem> = { unitPrice: price.price };
if (!props.item.description) {
updates.description = price.name;
}
props.onUpdate(props.item.id, updates);
desktopInputRef?.blur();
mobileInputRef?.blur();
};
const handlePriceKeyDown = (e: KeyboardEvent) => {
if (!isSearchingPrice() || searchResults().length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((s) => Math.min(s + 1, searchResults().length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((s) => Math.max(s - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
const selected = searchResults()[selectedIndex()];
if (selected) {
handleSelectStandardPrice(selected);
}
} else if (e.key === 'Escape') {
e.preventDefault();
setIsSearchingPrice(false);
setSearchResults([]);
desktopInputRef?.blur();
mobileInputRef?.blur();
}
};
let anchorRef: HTMLDivElement | undefined;
let mobileAnchorRef: HTMLDivElement | undefined;
const [dropdownRect, setDropdownRect] = createSignal({ top: 0, left: 0, width: 256 });
const updateDropdownPosition = () => {
if (!isSearchingPrice()) return;
// Determine which ref is currently visible/active based on window width
const ref = window.innerWidth < 768 ? mobileAnchorRef : anchorRef;
if (ref) {
const rect = ref.getBoundingClientRect();
setDropdownRect({
top: rect.bottom + window.scrollY + 4,
left: rect.left + window.scrollX,
width: Math.max(256, rect.width) // Make it at least w-64
});
}
};
createEffect(() => {
if (isSearchingPrice()) {
updateDropdownPosition();
window.addEventListener('scroll', updateDropdownPosition, true);
window.addEventListener('resize', updateDropdownPosition);
} else {
window.removeEventListener('scroll', updateDropdownPosition, true);
window.removeEventListener('resize', updateDropdownPosition);
}
});
onCleanup(() => {
window.removeEventListener('scroll', updateDropdownPosition, true);
window.removeEventListener('resize', updateDropdownPosition);
});
const PriceDropdown = () => (
<Show when={isSearchingPrice()}>
<Portal>
<div
class="absolute bg-white border border-gray-200 shadow-xl rounded-xl z-[9999] overflow-hidden text-left pointer-events-auto"
style={{
top: `${dropdownRect().top}px`,
left: `${dropdownRect().left}px`,
width: `${dropdownRect().width}px`
}}
>
<Show when={searchResults().length === 0}>
<div class="p-3 text-[11px] text-gray-500 text-center font-medium">Type to search prices...</div>
</Show>
<For each={searchResults()}>
{(result, index) => (
<div
class={`p-2 border-b border-gray-50 last:border-none cursor-pointer transition-colors ${selectedIndex() === index() ? 'bg-primary/20' : 'hover:bg-primary/10'}`}
onMouseEnter={() => setSelectedIndex(index())}
onMouseDown={(e) => {
// Prevent blur from firing before click
e.preventDefault();
handleSelectStandardPrice(result);
}}
>
<div class="text-xs font-bold text-gray-900">{result.name}</div>
<div class="flex justify-between items-center mt-0.5">
<div class="flex items-center gap-2">
<span class="text-[10px] text-gray-500 uppercase font-black tracking-wider">{result.category}</span>
<Show when={result.supplier}>
<span class="text-[9px] px-1.5 py-0.5 rounded-md bg-muted text-muted-foreground uppercase font-black tracking-widest">{result.supplier}</span>
</Show>
</div>
<span class="text-[11px] font-black text-primary">${result.price.toFixed(2)}</span>
</div>
</div>
)}
</For>
</div>
</Portal>
</Show>
);
const [isEditing, setIsEditing] = createSignal(false);
const [localDescription, setLocalDescription] = createSignal(props.item.description);
@@ -54,103 +200,73 @@ const ItemRow: Component<ItemRowProps> = (props) => {
return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
/* ─────────────────────────────────────────────
MOBILE layout (<md): stacked card
DESKTOP layout (≥md): flat flex row, columns
match header widths exactly:
w-4 | flex-1 min-w-[200px] | w-20 | w-24 |
w-24 | w-32 | w-32 | w-28 | w-16
──────────────────────────────────────────── */
return (
<div
class={`group flex items-start gap-4 p-3 border transition-all animate-in fade-in slide-in-from-left-2
class={`group transition-all animate-in fade-in slide-in-from-left-2 w-full border
${props.isSelected
? 'bg-primary/5 border-primary shadow-premium z-10'
: 'bg-card border-border hover:shadow-premium-hover hover:border-border/80'
}
${isEditing() ? 'ring-1 ring-primary/20 shadow-premium border-primary/30' : 'rounded-2xl'}
}
${isEditing() ? 'ring-1 ring-primary/20 shadow-premium border-primary/30 rounded-2xl' : 'rounded-2xl'}
`}
>
<div
draggable={!isEditing() || props.anySelected}
onDragStart={(e) => props.onDragStart(e, props.item.id)}
onClick={(e) => {
e.stopPropagation();
props.onToggleSelection(props.item.id, e.metaKey || e.ctrlKey, e.shiftKey);
}}
class={`cursor-grab transition-colors shrink-0 w-4 mt-2 p-1 rounded hover:bg-muted ${props.isSelected ? 'text-primary' : 'text-muted-foreground/30 hover:text-muted-foreground/60'}`}
title="Drag to move or click to select"
>
<GripVertical class="w-4 h-4" />
</div>
{/* ── DESKTOP ROW ── */}
<div class="hidden md:flex items-center gap-4 px-3 py-2">
<div class="flex-1 relative min-w-[200px]">
<textarea
ref={descriptionRef}
value={localDescription()}
onFocus={() => {
if (!props.anySelected) setIsEditing(true);
}}
onBlur={handleBlur}
onInput={(e) => {
setLocalDescription(e.currentTarget.value);
// Fallback auto-resize for browsers without field-sizing support
if (!('fieldSizing' in document.documentElement.style)) {
e.currentTarget.style.height = 'auto';
e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
}
}}
disabled={props.anySelected}
rows="1"
class={`w-full bg-transparent border-b border-transparent hover:border-border/40 focus:border-primary outline-none py-1 transition-all resize-none block overflow-hidden align-top leading-normal text-base font-medium text-foreground/90
${props.anySelected ? 'cursor-default pointer-events-none' : ''}
`}
style={{
"field-sizing": "content",
"min-height": "1.5em"
}}
placeholder="Item description..."
/>
</div>
<div class="w-20 shrink-0 mt-1">
<NumericInput
dataFieldName="qty"
value={props.item.quantity}
onUpdate={(val) => props.onUpdate(props.item.id, { quantity: val })}
onDragStart={(e) => {
const val = props.item.quantity;
const data = {
type: 'item-field',
itemId: props.item.id,
fieldName: 'qty',
value: val,
label: `${props.item.description || 'Unnamed Item'}: Qty`
};
e.dataTransfer!.setData('application/json', JSON.stringify(data));
e.dataTransfer!.setData('text/plain', String(val));
e.dataTransfer!.effectAllowed = 'copy';
{/* Grip / Select — w-4 */}
<div
draggable={!isEditing() || props.anySelected}
onDragStart={(e) => props.onDragStart(e, props.item.id)}
onClick={(e) => {
e.stopPropagation();
props.onToggleSelection(props.item.id, e.metaKey || e.ctrlKey, e.shiftKey);
}}
draggable="true"
disabled={props.anySelected}
class="w-full bg-muted/30 border border-border/60 rounded-xl px-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 cursor-text font-bold text-foreground/80 transition-all"
placeholder="Qty"
/>
</div>
class={`cursor-grab shrink-0 w-4 p-1 rounded hover:bg-muted transition-colors ${props.isSelected ? 'text-primary' : 'text-muted-foreground/30 hover:text-muted-foreground/60'}`}
title="Drag to move or click to select"
>
<GripVertical class="w-4 h-4" />
</div>
<div class="w-24 shrink-0 mt-1">
<div class="relative">
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 text-[10px]">$</span>
{/* Description — flex-1 min-w-[200px] */}
<div class="flex-1 min-w-[200px] relative">
<textarea
ref={descriptionRef}
value={localDescription()}
onFocus={() => { if (!props.anySelected) setIsEditing(true); }}
onBlur={handleBlur}
onInput={(e) => {
setLocalDescription(e.currentTarget.value);
if (!('fieldSizing' in document.documentElement.style)) {
e.currentTarget.style.height = 'auto';
e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
}
}}
disabled={props.anySelected}
rows="1"
class={`w-full bg-transparent border-b border-transparent hover:border-border/40 focus:border-primary outline-none py-1 transition-all resize-none block overflow-hidden align-top leading-normal text-sm font-medium text-foreground/90
${props.anySelected ? 'cursor-default pointer-events-none' : ''}
`}
style={{ "field-sizing": "content", "min-height": "1.5em" }}
placeholder="Item description..."
/>
</div>
{/* Qty — w-20 */}
<div class="w-20 shrink-0">
<NumericInput
dataFieldName="price"
step="0.1"
value={props.item.unitPrice}
onUpdate={(val) => props.onUpdate(props.item.id, { unitPrice: val })}
dataFieldName="qty"
value={props.item.quantity}
onUpdate={(val) => props.onUpdate(props.item.id, { quantity: val })}
onDragStart={(e) => {
const val = props.item.unitPrice;
const data = {
type: 'item-field',
itemId: props.item.id,
fieldName: 'price',
value: val,
label: `${props.item.description || 'Unnamed Item'}: Price`
};
const val = pricing().effectiveQuantity;
const data = { type: 'item-field', itemId: props.item.id, fieldName: 'qty', value: val, label: `${props.item.description || 'Unnamed Item'}: Qty` };
e.dataTransfer!.setData('application/json', JSON.stringify(data));
e.dataTransfer!.setData('text/plain', String(val));
e.dataTransfer!.effectAllowed = 'copy';
@@ -158,124 +274,260 @@ const ItemRow: Component<ItemRowProps> = (props) => {
}}
draggable="true"
disabled={props.anySelected}
class="w-full bg-muted/30 border border-border/60 rounded-xl pl-5 pr-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 cursor-text font-bold text-foreground/80 transition-all"
placeholder="Price"
class="w-full bg-muted/30 border border-border/60 rounded-xl px-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 cursor-text font-bold text-foreground/80 transition-all"
placeholder="Qty"
/>
<Show when={pricing().hasDirectQuantityAdjustment}>
<div class="mt-1 text-right text-[9px] font-black uppercase tracking-[0.15em] text-primary">
Adj {formatNumber(pricing().effectiveQuantity)}
</div>
</Show>
</div>
</div>
<div
draggable="true"
onDragStart={(e) => {
const val = baseTotal();
const data = {
type: 'item-field',
itemId: props.item.id,
fieldName: 'subtotal',
value: val,
label: `${props.item.description || 'Unnamed Item'}: Subtotal`
};
e.dataTransfer!.setData('application/json', JSON.stringify(data));
e.dataTransfer!.setData('text/plain', String(val));
e.dataTransfer!.effectAllowed = 'copy';
e.stopPropagation();
}}
class={`w-24 shrink-0 text-right text-muted-foreground/60 text-[11px] mt-2 font-bold uppercase tracking-tighter cursor-grab hover:text-primary transition-colors`}
>
${formatNumber(baseTotal())}
</div>
{/* Price — w-24 */}
<div class="w-24 shrink-0 relative" ref={anchorRef}>
<div class="space-y-1">
<div class="relative">
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 text-[10px] pointer-events-none">$</span>
<NumericInput
inputRef={(el: any) => desktopInputRef = el}
dataFieldName="price"
step="0.1"
value={props.item.unitPrice}
onUpdate={(val) => props.onUpdate(props.item.id, { unitPrice: val })}
onDragStart={(e) => {
const val = pricing().effectiveUnitPrice;
const data = { type: 'item-field', itemId: props.item.id, fieldName: 'price', value: val, label: `${props.item.description || 'Unnamed Item'}: Price` };
e.dataTransfer!.setData('application/json', JSON.stringify(data));
e.dataTransfer!.setData('text/plain', String(val));
e.dataTransfer!.effectAllowed = 'copy';
e.stopPropagation();
}}
draggable="true"
disabled={props.anySelected}
class="w-full bg-muted/30 border border-border/60 rounded-xl pl-5 pr-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 cursor-text font-bold text-foreground/80 transition-all"
placeholder="Price"
allowTextSearch={true}
onTextSearch={handlePriceSearch}
onKeyDown={handlePriceKeyDown}
onTextSearchExit={() => setIsSearchingPrice(false)}
onBlur={() => setTimeout(() => setIsSearchingPrice(false), 150)}
/>
</div>
<Show when={pricing().hasDirectUnitPriceAdjustment}>
<div class="text-right text-[9px] font-black uppercase tracking-[0.15em] text-primary">
Adj ${formatNumber(pricing().effectiveUnitPrice)}
</div>
</Show>
</div>
<PriceDropdown />
</div>
<Show when={props.isSidebarCollapsed() || !props.hideColumns}>
<div class="w-32 shrink-0 flex items-center gap-1.5 mt-1">
<NumericInput
dataFieldName="markup"
value={props.item.markup}
onUpdate={(val) => props.onUpdate(props.item.id, { markup: val })}
disabled={props.anySelected}
class="w-full bg-muted/30 border border-border/60 rounded-xl px-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 font-bold text-primary transition-all"
placeholder="Markup"
/>
{/* Subtotal — w-24 */}
<div
draggable="true"
onDragStart={(e) => {
const val = subtotal();
const data = { type: 'item-field', itemId: props.item.id, fieldName: 'subtotal', value: val, label: `${props.item.description || 'Unnamed Item'}: Subtotal` };
e.dataTransfer!.setData('application/json', JSON.stringify(data));
e.dataTransfer!.setData('text/plain', String(val));
e.dataTransfer!.effectAllowed = 'copy';
e.stopPropagation();
}}
class="w-24 shrink-0 text-right text-muted-foreground/60 text-[11px] font-bold uppercase tracking-tighter cursor-grab hover:text-primary transition-colors"
>
<div>${formatNumber(pricing().baseSubtotal)}</div>
<Show when={pricing().hasAdjustedSubtotal}>
<div class="mt-1 text-[9px] font-black uppercase tracking-[0.15em] text-primary">
Adjusted
</div>
<div class="text-[10px] font-black text-primary">
${formatNumber(subtotal())}
</div>
</Show>
</div>
{/* Markup — w-32 (conditional) */}
<Show when={props.isSidebarCollapsed() || !props.hideColumns}>
<div class="w-32 shrink-0">
<div class="flex items-center gap-1.5">
<NumericInput
dataFieldName="markup"
value={props.item.markup}
onUpdate={(val) => props.onUpdate(props.item.id, { markup: val })}
disabled={props.anySelected}
class="flex-1 min-w-0 bg-muted/30 border border-border/60 rounded-xl px-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 font-bold text-primary transition-all"
placeholder="Markup"
/>
<button
onClick={(e) => { e.stopPropagation(); props.onUpdate(props.item.id, { markupType: props.item.markupType === 'percent' ? 'amount' : 'percent' }); }}
disabled={props.anySelected}
class="text-[9px] font-black px-1.5 py-1.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary/10 hover:text-primary min-w-[24px] disabled:opacity-50 transition-colors border border-border/40"
>
{props.item.markupType === 'percent' ? '%' : '$'}
</button>
</div>
</div>
{/* Contingency — w-32 */}
<div class="w-32 shrink-0">
<div class="flex items-center gap-1.5">
<NumericInput
dataFieldName="contingency"
value={props.item.contingency}
onUpdate={(val) => props.onUpdate(props.item.id, { contingency: val })}
disabled={props.anySelected}
class="flex-1 min-w-0 bg-muted/30 border border-border/60 rounded-xl px-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 font-bold text-foreground/80 transition-all"
placeholder="Contingency"
/>
<button
onClick={(e) => { e.stopPropagation(); props.onUpdate(props.item.id, { contingencyType: props.item.contingencyType === 'percent' ? 'amount' : 'percent' }); }}
disabled={props.anySelected}
class="text-[9px] font-black px-1.5 py-1.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary/10 hover:text-primary min-w-[24px] disabled:opacity-50 transition-colors border border-border/40"
>
{props.item.contingencyType === 'percent' ? '%' : '$'}
</button>
</div>
</div>
</Show>
{/* Total — w-28 */}
<div
draggable="true"
onDragStart={(e) => {
const val = total();
const data = { type: 'item-field', itemId: props.item.id, fieldName: 'total', value: val, label: `${props.item.description || 'Unnamed Item'}: Total` };
e.dataTransfer!.setData('application/json', JSON.stringify(data));
e.dataTransfer!.setData('text/plain', String(val));
e.dataTransfer!.effectAllowed = 'copy';
e.stopPropagation();
}}
class={`w-28 shrink-0 text-right font-black text-sm cursor-grab hover:text-primary transition-colors ${props.isSelected ? 'text-primary' : 'text-foreground'}`}
>
${formatNumber(total())}
</div>
{/* Actions — w-16 */}
<div class="w-16 shrink-0 flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation();
props.onUpdate(props.item.id, { markupType: props.item.markupType === 'percent' ? 'amount' : 'percent' });
}}
disabled={props.anySelected}
class="text-[9px] font-black px-1.5 py-1.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary/10 hover:text-primary min-w-[24px] disabled:opacity-50 transition-colors border border-border/40"
onClick={(e) => { e.stopPropagation(); props.onDuplicateItem(props.item.id); }}
class="text-muted-foreground/40 hover:text-primary transition-colors"
title="Duplicate Item"
>
{props.item.markupType === 'percent' ? '%' : '$'}
<div class="p-1 px-1.5 bg-muted rounded-lg border border-border/40 hover:bg-primary/10">
<Copy class="w-3 h-3" />
</div>
</button>
<button
onClick={(e) => { e.stopPropagation(); props.onDelete(props.item.id); }}
class="text-muted-foreground/40 hover:text-destructive transition-colors"
title="Delete Item"
>
<div class="p-1 px-1.5 bg-muted rounded-lg border border-border/40 hover:bg-destructive/10">
<Trash2 class="w-3 h-3" />
</div>
</button>
</div>
<div class="w-32 shrink-0 flex items-center gap-1.5 mt-1">
<NumericInput
dataFieldName="contingency"
value={props.item.contingency}
onUpdate={(val) => props.onUpdate(props.item.id, { contingency: val })}
disabled={props.anySelected}
class="w-full bg-muted/30 border border-border/60 rounded-xl px-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 font-bold text-foreground/80 transition-all"
placeholder="Contingency"
/>
<button
onClick={(e) => {
e.stopPropagation();
props.onUpdate(props.item.id, { contingencyType: props.item.contingencyType === 'percent' ? 'amount' : 'percent' });
}}
disabled={props.anySelected}
class="text-[9px] font-black px-1.5 py-1.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary/10 hover:text-primary min-w-[24px] disabled:opacity-50 transition-colors border border-border/40"
>
{props.item.contingencyType === 'percent' ? '%' : '$'}
</button>
</div>
</Show>
<div
draggable="true"
onDragStart={(e) => {
const val = total();
const data = {
type: 'item-field',
itemId: props.item.id,
fieldName: 'total',
value: val,
label: `${props.item.description || 'Unnamed Item'}: Total`
};
e.dataTransfer!.setData('application/json', JSON.stringify(data));
e.dataTransfer!.setData('text/plain', String(val));
e.dataTransfer!.effectAllowed = 'copy';
e.stopPropagation();
}}
class={`w-28 shrink-0 text-right font-black text-sm mt-2 cursor-grab hover:text-primary transition-colors ${props.isSelected ? 'text-primary' : 'text-foreground'}`}
>
${formatNumber(total())}
</div>
<div class="w-16 shrink-0 flex items-center justify-end gap-1.5 mt-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation();
props.onDuplicateItem(props.item.id);
}}
class="text-muted-foreground/40 hover:text-primary transition-colors"
title="Duplicate Item"
>
<div class="p-1 px-1.5 bg-muted rounded-lg border border-border/40 hover:bg-primary/10">
<Copy class="w-3 h-3" />
{/* ── MOBILE LAYOUT ── (stacked card, shown below md) */}
<div class="md:hidden flex flex-col gap-3 p-3">
<div class="flex items-start gap-3">
{/* Grip */}
<div
draggable={!isEditing() || props.anySelected}
onDragStart={(e) => props.onDragStart(e, props.item.id)}
onClick={(e) => {
e.stopPropagation();
props.onToggleSelection(props.item.id, e.metaKey || e.ctrlKey, e.shiftKey);
}}
class={`cursor-grab shrink-0 w-4 mt-2 p-1 rounded hover:bg-muted transition-colors ${props.isSelected ? 'text-primary' : 'text-muted-foreground/30 hover:text-muted-foreground/60'}`}
>
<GripVertical class="w-4 h-4" />
</div>
</button>
<button
onClick={(e) => {
e.stopPropagation();
props.onDelete(props.item.id);
}}
class="text-muted-foreground/40 hover:text-destructive transition-colors"
title="Delete Item"
>
<div class="p-1 px-1.5 bg-muted rounded-lg border border-border/40 hover:bg-destructive/10">
<Trash2 class="w-3 h-3" />
{/* Description */}
<div class="flex-1 relative">
<textarea
value={localDescription()}
onFocus={() => { if (!props.anySelected) setIsEditing(true); }}
onBlur={handleBlur}
onInput={(e) => {
setLocalDescription(e.currentTarget.value);
if (!('fieldSizing' in document.documentElement.style)) {
e.currentTarget.style.height = 'auto';
e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
}
}}
disabled={props.anySelected}
rows="1"
class="w-full bg-transparent border-b border-transparent hover:border-border/40 focus:border-primary outline-none py-1 transition-all resize-none block overflow-hidden align-top leading-normal text-base font-medium text-foreground/90"
style={{ "field-sizing": "content", "min-height": "1.5em" }}
placeholder="Item description..."
/>
</div>
</button>
{/* Mobile actions */}
<div class="flex items-center gap-1.5 mt-1">
<button onClick={(e) => { e.stopPropagation(); props.onDuplicateItem(props.item.id); }} class="text-muted-foreground/40 hover:text-primary transition-colors">
<div class="p-1 px-1.5 bg-muted rounded-lg border border-border/40 hover:bg-primary/10"><Copy class="w-3 h-3" /></div>
</button>
<button onClick={(e) => { e.stopPropagation(); props.onDelete(props.item.id); }} class="text-muted-foreground/40 hover:text-destructive transition-colors">
<div class="p-1 px-1.5 bg-muted rounded-lg border border-border/40 hover:bg-destructive/10"><Trash2 class="w-3 h-3" /></div>
</button>
</div>
</div>
{/* Numeric fields — wrapping grid on mobile */}
<div class="flex flex-wrap gap-3 pl-7">
<div class="w-20">
<div class="text-[9px] font-bold text-muted-foreground/60 uppercase mb-1 ml-1">Qty</div>
<NumericInput value={props.item.quantity} onUpdate={(val) => props.onUpdate(props.item.id, { quantity: val })} disabled={props.anySelected} class="w-full bg-muted/30 border border-border/60 rounded-xl px-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 font-bold text-foreground/80 transition-all" placeholder="Qty" />
<Show when={pricing().hasDirectQuantityAdjustment}>
<div class="mt-1 text-right text-[9px] font-black uppercase tracking-[0.15em] text-primary">
Adj {formatNumber(pricing().effectiveQuantity)}
</div>
</Show>
</div>
<div class="w-24">
<div class="text-[9px] font-bold text-muted-foreground/60 uppercase mb-1 ml-1">Price</div>
<div class="relative group/price" ref={mobileAnchorRef}>
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 text-[10px]">$</span>
<NumericInput inputRef={(el: any) => mobileInputRef = el} step="0.1" value={props.item.unitPrice} onUpdate={(val) => props.onUpdate(props.item.id, { unitPrice: val })} disabled={props.anySelected} class="w-full bg-muted/30 border border-border/60 rounded-xl pl-5 pr-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 font-bold text-foreground/80 transition-all" placeholder="Price" allowTextSearch={true} onTextSearch={handlePriceSearch} onKeyDown={handlePriceKeyDown} onTextSearchExit={() => setIsSearchingPrice(false)} onBlur={() => setTimeout(() => setIsSearchingPrice(false), 150)} />
<PriceDropdown />
</div>
<Show when={pricing().hasDirectUnitPriceAdjustment}>
<div class="mt-1 text-right text-[9px] font-black uppercase tracking-[0.15em] text-primary">
Adj ${formatNumber(pricing().effectiveUnitPrice)}
</div>
</Show>
</div>
<Show when={props.isSidebarCollapsed() || !props.hideColumns}>
<div class="w-32">
<div class="text-[9px] font-bold text-muted-foreground/60 uppercase mb-1 ml-1">Markup</div>
<div class="flex items-center gap-1.5">
<NumericInput value={props.item.markup} onUpdate={(val) => props.onUpdate(props.item.id, { markup: val })} disabled={props.anySelected} class="flex-1 min-w-0 bg-muted/30 border border-border/60 rounded-xl px-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 font-bold text-primary transition-all" placeholder="Markup" />
<button onClick={(e) => { e.stopPropagation(); props.onUpdate(props.item.id, { markupType: props.item.markupType === 'percent' ? 'amount' : 'percent' }); }} disabled={props.anySelected} class="text-[9px] font-black px-1.5 py-1.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary/10 hover:text-primary min-w-[24px] disabled:opacity-50 transition-colors border border-border/40">{props.item.markupType === 'percent' ? '%' : '$'}</button>
</div>
</div>
<div class="w-32">
<div class="text-[9px] font-bold text-muted-foreground/60 uppercase mb-1 ml-1">Contingency</div>
<div class="flex items-center gap-1.5">
<NumericInput value={props.item.contingency} onUpdate={(val) => props.onUpdate(props.item.id, { contingency: val })} disabled={props.anySelected} class="flex-1 min-w-0 bg-muted/30 border border-border/60 rounded-xl px-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 font-bold text-foreground/80 transition-all" placeholder="Contingency" />
<button onClick={(e) => { e.stopPropagation(); props.onUpdate(props.item.id, { contingencyType: props.item.contingencyType === 'percent' ? 'amount' : 'percent' }); }} disabled={props.anySelected} class="text-[9px] font-black px-1.5 py-1.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary/10 hover:text-primary min-w-[24px] disabled:opacity-50 transition-colors border border-border/40">{props.item.contingencyType === 'percent' ? '%' : '$'}</button>
</div>
</div>
</Show>
<div class="w-28">
<div class="text-[9px] font-bold text-muted-foreground/60 uppercase mb-1 ml-1">Total</div>
<div class={`font-black text-sm text-right ${props.isSelected ? 'text-primary' : 'text-foreground'}`}>${formatNumber(total())}</div>
</div>
</div>
<Show when={pricing().hasDirectSubtotalAdjustment || pricing().hasAdjustedSubtotal}>
<div class="pl-7 text-right text-[10px] font-black uppercase tracking-[0.15em] text-primary/70">
Adjusted
</div>
<div class="pl-7 text-right text-sm font-black text-primary">
${formatNumber(pricing().effectiveSubtotal)}
</div>
</Show>
</div>
</div>
);
+328
View File
@@ -0,0 +1,328 @@
import type { Component } from 'solid-js';
import { Show, createMemo } from 'solid-js';
import { Trash2, Calculator, EyeOff } from 'lucide-solid';
import NumericInput from './ui/NumericInput';
import type { Modifier } from '../types';
import { ModifierRegistry } from '../utils/modifier-registry';
import {
CUSTOM_CALC_DEFAULT_EXPRESSION,
getComputedModifierStatus,
isComputedFieldModifier,
isCustomCalculationModifier,
isPrevailingWageModifier,
TARGET_FIELD_LABELS
} from '../utils/pricing';
interface ModifierRowProps {
modifier: Modifier;
calculatedValue: number;
onUpdate: (id: string, updates: Partial<Modifier>) => void;
onDelete: (id: string) => void;
}
const ModifierRow: Component<ModifierRowProps> = (props) => {
const module = () => ModifierRegistry.getModule(props.modifier.moduleId, props.modifier);
const computedStatus = createMemo(() => getComputedModifierStatus(props.modifier));
const isPrevailing = () => isPrevailingWageModifier(props.modifier);
const isCustom = () => isCustomCalculationModifier(props.modifier);
const isComputed = () => isComputedFieldModifier(props.modifier);
const formatNumber = (num: number) => {
return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
if (isPrevailing()) {
return (
<div class="rounded-2xl border border-border/60 bg-background/70 p-4 space-y-3">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1 space-y-3">
<div class="flex items-center gap-3">
<input
type="text"
title={module().description}
value={props.modifier.name}
onInput={(e) => props.onUpdate(props.modifier.id, { name: e.currentTarget.value })}
class="bg-transparent border-none outline-none text-sm font-semibold text-foreground focus:ring-0 py-0.5 w-48"
placeholder="Modifier Name"
/>
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-[9px] font-black uppercase tracking-[0.2em] text-primary">
Unit Price
</span>
</div>
<div class="grid gap-3 md:grid-cols-[120px_120px_auto]">
<div>
<div class="mb-1 text-[9px] font-black uppercase tracking-[0.2em] text-muted-foreground">Base Rate</div>
<NumericInput
value={computedStatus().parameters.baseRate ?? 0}
onUpdate={(val) => props.onUpdate(props.modifier.id, {
targetField: 'unitPrice',
parameters: {
...computedStatus().parameters,
baseRate: val
}
})}
class="w-full rounded-xl border border-border/60 bg-muted/30 px-3 py-2 text-right text-xs font-bold text-foreground outline-none transition-all focus:border-primary focus:bg-background"
/>
</div>
<div>
<div class="mb-1 text-[9px] font-black uppercase tracking-[0.2em] text-muted-foreground">Prevailing</div>
<NumericInput
value={computedStatus().parameters.prevailingRate ?? 0}
onUpdate={(val) => props.onUpdate(props.modifier.id, {
targetField: 'unitPrice',
parameters: {
...computedStatus().parameters,
prevailingRate: val
}
})}
class="w-full rounded-xl border border-border/60 bg-muted/30 px-3 py-2 text-right text-xs font-bold text-foreground outline-none transition-all focus:border-primary focus:bg-background"
/>
</div>
<div class="flex items-end">
<div class="rounded-xl border border-border/60 bg-muted/20 px-3 py-2 text-[10px] font-black uppercase tracking-[0.15em] text-muted-foreground">
<Show when={!computedStatus().error && computedStatus().multiplier !== undefined} fallback={'Formula: price * multiplier'}>
Multiplier {computedStatus().multiplier?.toFixed(4)}x
</Show>
</div>
</div>
</div>
<Show when={computedStatus().error}>
<div class="min-h-[1rem] text-[10px] font-bold uppercase tracking-widest text-destructive">
{computedStatus().error}
</div>
</Show>
</div>
<div class="flex items-center gap-2">
<button
onClick={() => props.onUpdate(props.modifier.id, { includeInTotal: !props.modifier.includeInTotal })}
class={`p-1.5 rounded-lg transition-all ${props.modifier.includeInTotal ? 'text-primary' : 'text-muted-foreground/30'}`}
title={props.modifier.includeInTotal ? 'Included' : 'Excluded'}
>
<Show when={props.modifier.includeInTotal} fallback={<EyeOff class="w-3.5 h-3.5" />}>
<Calculator class="w-3.5 h-3.5" />
</Show>
</button>
<button
onClick={() => props.onDelete(props.modifier.id)}
class="p-1.5 text-muted-foreground/40 hover:text-destructive hover:bg-destructive/10 rounded-lg transition-all"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
);
}
if (isCustom()) {
return (
<div class="rounded-2xl border border-border/60 bg-background/70 p-4 space-y-3">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1 space-y-3">
<div class="flex items-center gap-3">
<input
type="text"
title={module().description}
value={props.modifier.name}
onInput={(e) => props.onUpdate(props.modifier.id, { name: e.currentTarget.value })}
class="bg-transparent border-none outline-none text-sm font-semibold text-foreground focus:ring-0 py-0.5 w-48"
placeholder="Modifier Name"
/>
<select
value={computedStatus().targetField}
onChange={(e) => props.onUpdate(props.modifier.id, { targetField: e.currentTarget.value as Modifier['targetField'] })}
class="rounded-lg border border-border bg-background px-2 py-1 text-[10px] font-black uppercase tracking-wider text-muted-foreground outline-none focus:border-primary"
>
<option value="unitPrice">{TARGET_FIELD_LABELS.unitPrice}</option>
<option value="quantity">{TARGET_FIELD_LABELS.quantity}</option>
<option value="subtotal">{TARGET_FIELD_LABELS.subtotal}</option>
</select>
</div>
<div>
<div class="mb-1 flex items-center justify-between text-[9px] font-black uppercase tracking-[0.2em] text-muted-foreground">
<span>Math</span>
<span class="text-primary">Inputs: price, quantity, subtotal</span>
</div>
<input
type="text"
value={computedStatus().expression}
onInput={(e) => props.onUpdate(props.modifier.id, { expression: e.currentTarget.value })}
class="w-full rounded-xl border border-border/60 bg-muted/30 px-3 py-2 text-xs font-mono text-foreground outline-none transition-all focus:border-primary focus:bg-background"
placeholder={CUSTOM_CALC_DEFAULT_EXPRESSION}
/>
</div>
<div class="flex items-center justify-between gap-4">
<div class="min-h-[1rem] text-[10px] font-bold uppercase tracking-widest">
<Show
when={!computedStatus().error}
fallback={<span class="text-destructive">{computedStatus().error}</span>}
>
<span class="text-primary/70">
Output updates {TARGET_FIELD_LABELS[computedStatus().targetField].toLowerCase()}
</span>
</Show>
</div>
<div class="text-right text-[10px] font-black uppercase tracking-[0.2em] text-muted-foreground">
Base inputs stay editable
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
onClick={() => props.onUpdate(props.modifier.id, { includeInTotal: !props.modifier.includeInTotal })}
class={`p-1.5 rounded-lg transition-all ${props.modifier.includeInTotal ? 'text-primary' : 'text-muted-foreground/30'}`}
title={props.modifier.includeInTotal ? 'Included' : 'Excluded'}
>
<Show when={props.modifier.includeInTotal} fallback={<EyeOff class="w-3.5 h-3.5" />}>
<Calculator class="w-3.5 h-3.5" />
</Show>
</button>
<button
onClick={() => props.onDelete(props.modifier.id)}
class="p-1.5 text-muted-foreground/40 hover:text-destructive hover:bg-destructive/10 rounded-lg transition-all"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
);
}
if (isComputed()) {
return (
<div class="rounded-2xl border border-border/60 bg-background/70 p-4 space-y-3">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1 space-y-2">
<div class="flex items-center gap-3">
<input
type="text"
title={module().description}
value={props.modifier.name}
onInput={(e) => props.onUpdate(props.modifier.id, { name: e.currentTarget.value })}
class="bg-transparent border-none outline-none text-sm font-semibold text-foreground focus:ring-0 py-0.5 w-48"
placeholder="Modifier Name"
/>
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-[9px] font-black uppercase tracking-[0.2em] text-primary">
Legacy Modifier
</span>
</div>
<div class="min-h-[1rem] text-[10px] font-bold uppercase tracking-widest">
<Show
when={!computedStatus().error}
fallback={<span class="text-destructive">{computedStatus().error}</span>}
>
<span class="text-primary/70">Existing computed modifier remains supported</span>
</Show>
</div>
</div>
<div class="flex items-center gap-2">
<button
onClick={() => props.onUpdate(props.modifier.id, { includeInTotal: !props.modifier.includeInTotal })}
class={`p-1.5 rounded-lg transition-all ${props.modifier.includeInTotal ? 'text-primary' : 'text-muted-foreground/30'}`}
title={props.modifier.includeInTotal ? 'Included' : 'Excluded'}
>
<Show when={props.modifier.includeInTotal} fallback={<EyeOff class="w-3.5 h-3.5" />}>
<Calculator class="w-3.5 h-3.5" />
</Show>
</button>
<button
onClick={() => props.onDelete(props.modifier.id)}
class="p-1.5 text-muted-foreground/40 hover:text-destructive hover:bg-destructive/10 rounded-lg transition-all"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
);
}
return (
<div class="group flex items-center justify-between py-2 px-1 hover:bg-primary/5 rounded-xl transition-all">
<div class="flex items-center gap-3">
<input
type="text"
title={module().description}
value={props.modifier.name}
onInput={(e) => props.onUpdate(props.modifier.id, { name: e.currentTarget.value })}
class="bg-transparent border-none outline-none text-sm font-medium text-muted-foreground focus:text-foreground focus:ring-0 py-0.5 w-48"
placeholder="Modifier Name"
/>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => props.onUpdate(props.modifier.id, { valueType: 'percent' })}
class={`p-1 rounded-md transition-colors text-[10px] font-bold ${props.modifier.valueType === 'percent' ? 'bg-primary/10 text-primary' : 'hover:bg-muted text-muted-foreground'}`}
>
%
</button>
<button
onClick={() => props.onUpdate(props.modifier.id, { valueType: 'amount' })}
class={`p-1 rounded-md transition-colors text-[10px] font-bold ${props.modifier.valueType === 'amount' ? 'bg-primary/10 text-primary' : 'hover:bg-muted text-muted-foreground'}`}
>
$
</button>
<button
onClick={() => {
const unit = props.modifier.unitLabel || 'hrs';
props.onUpdate(props.modifier.id, { valueType: 'unit', unitLabel: unit });
}}
class={`px-1.5 py-1 rounded-md transition-colors text-[9px] font-black tracking-tighter ${props.modifier.valueType === 'unit' ? 'bg-primary/10 text-primary' : 'hover:bg-muted text-muted-foreground'}`}
>
UNIT
</button>
</div>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<NumericInput
value={props.modifier.value}
onUpdate={(val) => props.onUpdate(props.modifier.id, { value: val })}
class="w-20 text-right text-sm border-b border-dashed border-border focus:border-primary outline-none bg-transparent text-primary font-medium"
placeholder={props.modifier.moduleId === 'man-hours' ? 'Factor' : '0.00'}
/>
<Show when={(props.modifier.valueType === 'unit' && props.modifier.unitLabel) || props.modifier.moduleId === 'man-hours'}>
<span class="text-[10px] font-bold text-primary/60">
{props.modifier.moduleId === 'man-hours' ? 'factor' : props.modifier.unitLabel}
</span>
</Show>
</div>
<div class="w-28 text-right flex items-center justify-end gap-1.5">
<span class={`text-sm font-medium transition-colors ${props.modifier.includeInTotal ? 'text-foreground' : 'text-muted-foreground/40'}`}>
{props.modifier.moduleId === 'man-hours' ? '' : '$'}{formatNumber(props.calculatedValue)}
{props.modifier.moduleId === 'man-hours' ? ' hrs' : ''}
</span>
<button
onClick={() => props.onUpdate(props.modifier.id, { includeInTotal: !props.modifier.includeInTotal })}
class={`p-1 rounded-md transition-all ${props.modifier.includeInTotal ? 'text-primary' : 'text-muted-foreground/30'}`}
title={props.modifier.includeInTotal ? 'Included' : 'Excluded'}
>
<Show when={props.modifier.includeInTotal} fallback={<EyeOff class="w-3.5 h-3.5" />}>
<Calculator class="w-3.5 h-3.5" />
</Show>
</button>
</div>
<button
onClick={() => props.onDelete(props.modifier.id)}
class="p-1 opacity-0 group-hover:opacity-100 text-muted-foreground/30 hover:text-destructive hover:bg-destructive/10 rounded-lg transition-all"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
</div>
);
};
export default ModifierRow;
+11 -7
View File
@@ -9,6 +9,7 @@ interface NoteEditorProps {
icon: any;
placeholder?: string;
highlight?: boolean;
headerActions?: any;
}
const NoteEditor: Component<NoteEditorProps> = (props) => {
@@ -56,13 +57,16 @@ const NoteEditor: Component<NoteEditorProps> = (props) => {
</div>
<span class={`text-[10px] font-black uppercase tracking-widest ${props.highlight ? 'text-destructive' : 'text-muted-foreground'}`}>{props.label}</span>
</div>
<button
onClick={() => setIsEditing(!isEditing())}
class={`flex items-center gap-1.5 px-3 py-1 rounded-lg text-[10px] font-bold transition-all border ${isEditing() ? 'bg-primary text-primary-foreground border-primary shadow-sm' : 'text-muted-foreground hover:bg-muted border-border/40'}`}
>
{isEditing() ? <Eye class="w-3 h-3 mr-1.5" /> : <Edit3 class="w-3 h-3 mr-1.5" />}
{isEditing() ? 'Preview' : 'Edit'}
</button>
<div class="flex items-center gap-2">
{props.headerActions}
<button
onClick={() => setIsEditing(!isEditing())}
class={`flex items-center gap-1.5 px-3 py-1 rounded-lg text-[10px] font-bold transition-all border ${isEditing() ? 'bg-primary text-primary-foreground border-primary shadow-sm' : 'text-muted-foreground hover:bg-muted border-border/40'}`}
>
{isEditing() ? <Eye class="w-3 h-3 mr-1.5" /> : <Edit3 class="w-3 h-3 mr-1.5" />}
{isEditing() ? 'Preview' : 'Edit'}
</button>
</div>
</div>
<div class="p-4 min-h-[100px]">
<Show
+248 -81
View File
@@ -1,17 +1,21 @@
import type { Component, Accessor } from 'solid-js';
import { For, Show } from 'solid-js';
import type { Scope, EstimateItem, JobInfo } from '../types';
import type { Scope, SubScope, EstimateItem, JobInfo } from '../types';
import { NOTICE_TO_OWNERS } from '../notice-to-owners';
import { COMPANY_NAME, COMPANY_ADDRESS, COMPANY_PHONE, COMPANY_EMAIL } from '../company-info';
import { ModifierRegistry } from '../utils/modifier-registry';
import { getEffectiveItemPricing, getItemsPricingTotals } from '../utils/pricing';
interface PrintEstimateProps {
scopes: Scope[];
subScopes: SubScope[];
items: EstimateItem[];
grandTotals: Accessor<{
subtotal: number;
markup: number;
contingency: number;
fees: number;
modifiersTotal: number;
grandTotal: number;
}>;
clientInfo: {
@@ -25,9 +29,13 @@ interface PrintEstimateProps {
generalEstimateNotes: string;
formatNumber: (num: number) => string;
hideColumns?: boolean;
variant?: 'finalized' | 'manager';
}
const PrintEstimate: Component<PrintEstimateProps> = (props) => {
const variant = () => props.variant ?? 'finalized';
const isManagerVariant = () => variant() === 'manager';
const getScopeTotal = (scopeId: string) => {
let scopeSubtotal = 0;
let scopeMarkup = 0;
@@ -37,10 +45,13 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
if (!scope) return 0;
props.items.filter((i: EstimateItem) => i.scopeId === scopeId).forEach((item: EstimateItem) => {
const base = item.quantity * item.unitPrice;
scopeSubtotal += base;
scopeMarkup += item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
scopeContingency += item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
const modifiers = item.subScopeId
? (props.subScopes.find(subScope => subScope.id === item.subScopeId)?.modifiers || [])
: [];
const pricing = getEffectiveItemPricing(item, modifiers);
scopeSubtotal += pricing.effectiveSubtotal;
scopeMarkup += pricing.markupAmount;
scopeContingency += pricing.contingencyAmount;
});
const mgmtFee = scope.useManagementLogic === 'percent'
@@ -51,9 +62,50 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
? scopeSubtotal * (scope.deliveryFee / 100)
: scope.deliveryFee;
return scopeSubtotal + scopeMarkup + scopeContingency + mgmtFee + deliveryFee;
let modifiersTotal = 0;
const scopeSubScopes = props.subScopes.filter(ss => ss.scopeId === scopeId);
scopeSubScopes.forEach(ss => {
const ssItems = props.items.filter(i => i.subScopeId === ss.id);
const ssTotalWithMarkup = getItemsPricingTotals(ssItems, ss.modifiers || []).total;
(ss.modifiers || []).forEach(m => {
if (!m.includeInTotal) return;
modifiersTotal += ModifierRegistry.calculate(m, ssTotalWithMarkup, ssItems);
});
});
return scopeSubtotal + scopeMarkup + scopeContingency + mgmtFee + deliveryFee + modifiersTotal;
};
const getScopeItems = (scopeId: string) => {
const scopeLevelItems = props.items.filter((item: EstimateItem) => item.scopeId === scopeId && !item.subScopeId);
const scopeSubScopes = props.subScopes
.filter((subScope) => subScope.scopeId === scopeId)
.map((subScope) => ({
subScope,
items: props.items.filter((item: EstimateItem) => item.subScopeId === subScope.id)
}))
.filter((entry) => entry.items.length > 0);
return {
scopeLevelItems,
scopeSubScopes
};
};
const getItemPricing = (item: EstimateItem) => {
const modifiers = item.subScopeId
? (props.subScopes.find(subScope => subScope.id === item.subScopeId)?.modifiers || [])
: [];
return getEffectiveItemPricing(item, modifiers);
};
const getItemSubtotal = (item: EstimateItem) => getItemPricing(item).effectiveSubtotal;
const getManagerScopeVisibleTotal = (scopeId: string) =>
props.items
.filter((item: EstimateItem) => item.scopeId === scopeId)
.reduce((sum, item) => sum + getItemSubtotal(item), 0);
return (
<div class="print-only p-6 bg-white font-sans max-w-[8.5in] mx-auto text-sm leading-normal text-foreground">
{/* Company Header */}
@@ -67,7 +119,9 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
</div>
</div>
<div class="text-right">
<h2 class="text-2xl font-black text-foreground uppercase tracking-tighter leading-none">Estimate</h2>
<h2 class="text-2xl font-black text-foreground uppercase tracking-tighter leading-none">
{isManagerVariant() ? 'Manager Info' : 'Estimate'}
</h2>
<p class="text-[10px] text-muted-foreground italic uppercase tracking-widest font-bold mb-1">*** NOT A BILL ***</p>
<p class="text-foreground font-bold text-xs">Date: {new Date().toLocaleDateString()}</p>
</div>
@@ -89,6 +143,10 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
<div>
<h3 class="text-[10px] font-bold text-muted-foreground uppercase tracking-wider border-b border-border pb-1 mb-2">Project Details</h3>
<div class="pl-1 space-y-1">
<div class="flex gap-2 text-xs">
<span class="font-bold text-muted-foreground/60 w-16 uppercase text-[10px] pt-0.5">Job No.</span>
<span class="font-bold text-foreground">{props.jobInfo.number || '---'}</span>
</div>
<div class="flex gap-2 text-xs">
<span class="font-bold text-muted-foreground/60 w-16 uppercase text-[10px] pt-0.5">Project</span>
<span class="font-bold text-foreground">{props.jobInfo.name || '---'}</span>
@@ -108,7 +166,7 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
</div>
{/* General Pre-Notes */}
<Show when={props.generalPreNotes}>
<Show when={props.generalPreNotes && !isManagerVariant()}>
<div class="mb-6 p-4 bg-muted/30 rounded-xl border border-border/40 break-inside-avoid shadow-sm outline outline-1 outline-border/20">
<div class="text-[11px] text-foreground/80 leading-relaxed font-medium italic">
{props.generalPreNotes}
@@ -117,47 +175,154 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
</Show>
{/* Scope Details Table */}
<div class="mb-8 border-t-2 border-foreground">
<div class="grid grid-cols-12 gap-2 py-2 bg-muted/40 px-3 text-[10px] font-bold text-muted-foreground uppercase tracking-wider border-b border-border/60">
<div class="col-span-2">Scope</div>
<div class="col-span-8 pl-2 border-l border-border/60">Description</div>
<div class="col-span-2 text-right">Subtotal</div>
</div>
<div class="divide-y divide-border/20">
<For each={props.scopes}>
{(scope) => (
<div class="grid grid-cols-12 gap-2 items-start py-4 px-3 hover:bg-muted/10 transition-colors break-inside-avoid">
<div class="col-span-2">
<h4 class="text-xs font-black text-foreground uppercase leading-snug">{scope.name}</h4>
</div>
<div class="col-span-8 pl-2 border-l border-dashed border-border/60 text-foreground/80 text-xs leading-relaxed">
<div class="prose prose-sm max-w-none prose-strong:text-foreground prose-em:text-foreground/70">
<For each={(scope.bidDescription || 'See detailed specifications.').split('\n')}>
{(line) => {
let processed = line;
processed = processed.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
processed = processed.replace(/(\*|_)(.*?)\1/g, '<em>$2</em>');
if (processed.trim().startsWith('- ')) {
return <li class="ml-3 list-disc pl-1 text-foreground/90" innerHTML={processed.substring(2)} />;
}
// Handle empty lines more gracefully in compact mode
if (!processed.trim()) return <div class="h-1"></div>;
return <p class="mb-0.5 last:mb-0 text-foreground/90" innerHTML={processed} />;
}}
</For>
<Show
when={isManagerVariant()}
fallback={
<div class="mb-8 border-t-2 border-foreground">
<div class="grid grid-cols-12 gap-2 py-2 bg-muted/40 px-3 text-[10px] font-bold text-muted-foreground uppercase tracking-wider border-b border-border/60">
<div class="col-span-2">Scope</div>
<div class="col-span-8 pl-2 border-l border-border/60">Description</div>
<div class="col-span-2 text-right">Subtotal</div>
</div>
<div class="divide-y divide-border/20">
<For each={props.scopes}>
{(scope) => (
<div class="grid grid-cols-12 gap-2 items-start py-4 px-3 hover:bg-muted/10 transition-colors break-inside-avoid">
<div class="col-span-2">
<h4 class="text-xs font-black text-foreground uppercase leading-snug">{scope.name}</h4>
</div>
<div class="col-span-8 pl-2 border-l border-dashed border-border/60 text-foreground/80 text-xs leading-relaxed">
<div class="prose prose-sm max-w-none prose-strong:text-foreground prose-em:text-foreground/70">
<For each={(scope.bidDescription || 'See detailed specifications.').split('\n')}>
{(line) => {
let processed = line;
processed = processed.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
processed = processed.replace(/(\*|_)(.*?)\1/g, '<em>$2</em>');
if (processed.trim().startsWith('- ')) {
return <li class="ml-3 list-disc pl-1 text-foreground/90" innerHTML={processed.substring(2)} />;
}
if (!processed.trim()) return <div class="h-1"></div>;
return <p class="mb-0.5 last:mb-0 text-foreground/90" innerHTML={processed} />;
}}
</For>
</div>
</div>
<div class="col-span-2 text-right font-black text-foreground text-sm">
${props.formatNumber(getScopeTotal(scope.id))}
</div>
</div>
</div>
<div class="col-span-2 text-right font-black text-foreground text-sm">
${props.formatNumber(getScopeTotal(scope.id))}
</div>
</div>
)}
)}
</For>
</div>
</div>
}
>
<div class="mb-8 space-y-6">
<For each={props.scopes}>
{(scope) => {
const scopeItems = () => getScopeItems(scope.id);
return (
<section class={`border border-border/60 rounded-2xl overflow-hidden ${isManagerVariant() ? '' : 'break-inside-avoid'}`}>
<div class="px-4 py-3 bg-muted/30 border-b border-border/60 flex items-start justify-between gap-4">
<div>
<h4 class="text-sm font-black text-foreground uppercase">{scope.name}</h4>
<p class="text-[10px] text-muted-foreground font-bold uppercase tracking-widest mt-1">Scope Total</p>
</div>
<div class="text-right">
<div class="text-lg font-black text-foreground">${props.formatNumber(getManagerScopeVisibleTotal(scope.id))}</div>
</div>
</div>
<div class="p-4 space-y-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="rounded-xl border border-border/50 p-3 bg-white">
<div class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest mb-2">Scope Description</div>
<div class="prose prose-sm max-w-none prose-strong:text-foreground prose-em:text-foreground/70 text-xs text-foreground/90">
<For each={(scope.bidDescription || 'See detailed specifications.').split('\n')}>
{(line) => {
let processed = line;
processed = processed.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
processed = processed.replace(/(\*|_)(.*?)\1/g, '<em>$2</em>');
if (processed.trim().startsWith('- ')) {
return <li class="ml-3 list-disc pl-1 text-foreground/90" innerHTML={processed.substring(2)} />;
}
if (!processed.trim()) return <div class="h-1"></div>;
return <p class="mb-0.5 last:mb-0 text-foreground/90" innerHTML={processed} />;
}}
</For>
</div>
</div>
<div class="rounded-xl border border-border/50 p-3 bg-white">
<div class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest mb-2">Estimator Notes</div>
<div class="text-xs text-foreground/90 whitespace-pre-wrap leading-relaxed">
{scope.estimatorNotes?.trim() || 'No estimator notes provided.'}
</div>
</div>
</div>
<div class="border border-border/50 rounded-xl overflow-hidden">
<div class="grid grid-cols-12 gap-3 px-3 py-2 bg-muted/20 text-[10px] font-bold text-muted-foreground uppercase tracking-wider border-b border-border/50">
<div class="col-span-2">Section</div>
<div class="col-span-6">Line Item</div>
<div class="col-span-1 text-right">Qty</div>
<div class="col-span-1 text-right">Unit</div>
<div class="col-span-2 text-right">Subtotal</div>
</div>
<Show when={scopeItems().scopeLevelItems.length > 0}>
<div class="border-b border-border/40">
<div class="px-3 py-2 bg-background text-[10px] font-black uppercase tracking-widest text-muted-foreground">Scope Level</div>
<For each={scopeItems().scopeLevelItems}>
{(item) => (
<div class="grid grid-cols-12 gap-3 px-3 py-2 text-xs border-t border-border/20 items-start">
<div class="col-span-2 font-bold text-foreground">Scope Level</div>
<div class="col-span-6 text-foreground/90">{item.description}</div>
<div class="col-span-1 text-right text-foreground">{getItemPricing(item).effectiveQuantity}</div>
<div class="col-span-1 text-right text-foreground">${props.formatNumber(getItemPricing(item).effectiveUnitPrice)}</div>
<div class="col-span-2 text-right font-bold text-foreground">${props.formatNumber(getItemSubtotal(item))}</div>
</div>
)}
</For>
</div>
</Show>
<For each={scopeItems().scopeSubScopes}>
{(entry) => (
<div class="border-b last:border-b-0 border-border/40">
<div class="px-3 py-2 bg-background text-[10px] font-black uppercase tracking-widest text-muted-foreground">
{entry.subScope.name}
</div>
<For each={entry.items}>
{(item) => (
<div class="grid grid-cols-12 gap-3 px-3 py-2 text-xs border-t border-border/20 items-start">
<div class="col-span-2 font-bold text-foreground">{entry.subScope.name}</div>
<div class="col-span-6 text-foreground/90">{item.description}</div>
<div class="col-span-1 text-right text-foreground">{getItemPricing(item).effectiveQuantity}</div>
<div class="col-span-1 text-right text-foreground">${props.formatNumber(getItemPricing(item).effectiveUnitPrice)}</div>
<div class="col-span-2 text-right font-bold text-foreground">${props.formatNumber(getItemSubtotal(item))}</div>
</div>
)}
</For>
</div>
)}
</For>
<Show when={scopeItems().scopeLevelItems.length === 0 && scopeItems().scopeSubScopes.length === 0}>
<div class="px-3 py-4 text-xs text-muted-foreground italic">No line items assigned to this scope.</div>
</Show>
</div>
</div>
</section>
);
}}
</For>
</div>
</div>
</Show>
{/* General Estimate Notes */}
<Show when={props.generalEstimateNotes}>
<Show when={props.generalEstimateNotes && !isManagerVariant()}>
<div class="mb-8">
<h5 class="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1 border-b border-border/40 pb-1">General Estimate Notes</h5>
<div class="text-[11px] text-foreground/80 leading-relaxed whitespace-pre-wrap font-medium">
@@ -167,51 +332,53 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
</Show>
{/* Final Section: Totals & Signature aligned */}
<div class="grid grid-cols-12 gap-8 items-end mb-8 break-inside-avoid">
<div class="col-span-7">
<div class="flex flex-col gap-2">
<div class="w-full flex items-end gap-2 pt-2">
<span class="text-lg font-serif text-muted-foreground/40 translate-y-1">X</span>
<div class="flex-1 border-b border-foreground h-6"></div>
<Show when={!isManagerVariant()}>
<div class="grid grid-cols-12 gap-8 items-end mb-8 break-inside-avoid">
<div class="col-span-7">
<div class="flex flex-col gap-2">
<div class="w-full flex items-end gap-2 pt-2">
<span class="text-lg font-serif text-muted-foreground/40 translate-y-1">X</span>
<div class="flex-1 border-b border-foreground h-6"></div>
</div>
<div class="flex justify-between items-start px-0.5">
<p class="text-[9px] text-muted-foreground/60 italic max-w-[280px] leading-tight">
A Work Order is then established and Cardoza Construction, LLC will proceed with work.
Valid for 30 days from original date.
</p>
<span class="text-[9px] font-bold text-muted-foreground uppercase tracking-wider">Authorized Signature</span>
</div>
</div>
<div class="flex justify-between items-start px-0.5">
<p class="text-[9px] text-muted-foreground/60 italic max-w-[280px] leading-tight">
A Work Order is then established and Cardoza Construction, LLC will proceed with work.
Valid for 30 days from original date.
</p>
<span class="text-[9px] font-bold text-muted-foreground uppercase tracking-wider">Authorized Signature</span>
</div>
<div class="col-span-5 text-right space-y-1">
<div class="flex justify-end items-center gap-6 p-4 bg-white text-foreground rounded-2xl mt-3 border-2 border-foreground shadow-sm">
<div class="text-[9px] font-black uppercase tracking-widest text-muted-foreground/60">Grand Total</div>
<div class="text-3xl font-black tracking-tighter text-foreground">${props.formatNumber(props.grandTotals().grandTotal)}</div>
</div>
</div>
</div>
<div class="col-span-5 text-right space-y-1">
<div class="flex justify-end items-center gap-6 p-4 bg-white text-foreground rounded-2xl mt-3 border-2 border-foreground shadow-sm">
<div class="text-[9px] font-black uppercase tracking-widest text-muted-foreground/60">Grand Total</div>
<div class="text-3xl font-black tracking-tighter text-foreground">${props.formatNumber(props.grandTotals().grandTotal)}</div>
{/* Terms & Legal Notices */}
<div class="mt-8 pt-6 border-t-2 border-foreground space-y-4">
<div class="break-inside-avoid">
<h5 class="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1">Terms & Conditions</h5>
<p class="text-[10px] font-bold text-foreground/70 leading-relaxed">
Unless otherwise agreed, credit approval and 50% down is required prior to scheduling/starting work.
(Credit card authorization may be substituted for jobs under $2,000)
</p>
</div>
<div class="break-inside-avoid">
<h5 class="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1">Notice to Owner</h5>
<div class="text-[10px] font-bold text-foreground/60 leading-relaxed whitespace-pre-wrap">
{NOTICE_TO_OWNERS.replace('NOTICE TO OWNER', '').trim()}
</div>
</div>
</div>
</div>
{/* Terms & Legal Notices */}
<div class="mt-8 pt-6 border-t-2 border-foreground space-y-4">
<div class="break-inside-avoid">
<h5 class="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1">Terms & Conditions</h5>
<p class="text-[10px] font-bold text-foreground/70 leading-relaxed">
Unless otherwise agreed, credit approval and 50% down is required prior to scheduling/starting work.
(Credit card authorization may be substituted for jobs under $2,000)
</p>
</div>
<div class="break-inside-avoid">
<h5 class="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1">Notice to Owner</h5>
<div class="text-[10px] font-bold text-foreground/60 leading-relaxed whitespace-pre-wrap">
{NOTICE_TO_OWNERS.replace('NOTICE TO OWNER', '').trim()}
</div>
</div>
</div>
{/* Print Page Counter */}
<div class="print-page-number"></div>
{/* Print Page Counter */}
<div class="print-page-number"></div>
</Show>
</div>
);
};
+245 -85
View File
@@ -1,12 +1,17 @@
import type { Component } from 'solid-js';
import { For, createMemo, Show, createSignal } from 'solid-js';
import { For, Show, createMemo, createSignal, type Component } from 'solid-js';
import { Trash2, Plus, Settings2, Percent, DollarSign, ChevronDown, FileText, ClipboardList, FileBox } from 'lucide-solid';
import NumericInput from './ui/NumericInput';
import type { EstimateItem, Scope, SubScope, PricingType } from '../types';
import type { EstimateItem, Scope, SubScope, PricingType, ScopeTextTemplateType } from '../types';
import ItemRow from './ItemRow';
import SubScopeCard from './SubScopeCard';
import NoteEditor from './NoteEditor';
import LazyItem from './LazyItem';
import ScopeScaleWrapper from './ui/ScopeScaleWrapper';
import { ModifierRegistry } from '../utils/modifier-registry';
import { appStore } from '../store/appStore';
import { SupplierDropdown } from './ui/SupplierDropdown';
import ScopeTextTemplateModal from './ScopeTextTemplateModal';
import { getItemsPricingTotals } from '../utils/pricing';
interface ScopeCardProps {
scope: Scope;
@@ -41,24 +46,34 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
const [bulkMarkup, setBulkMarkup] = createSignal(0);
const [bulkContingency, setBulkContingency] = createSignal(0);
const [isOver, setIsOver] = createSignal(false);
const [activeTemplateType, setActiveTemplateType] = createSignal<ScopeTextTemplateType>('estimatorNotes');
const [isTemplateModalOpen, setIsTemplateModalOpen] = createSignal(false);
const scopeItems = createMemo(() => props.items.filter(i => !i.subScopeId));
const totals = createMemo(() => {
let subtotal = 0;
let markup = 0;
let contingency = 0;
let totalQuantity = 0;
const scopeLevelTotals = getItemsPricingTotals(scopeItems(), []);
let subtotal = scopeLevelTotals.subtotal;
let markup = scopeLevelTotals.markup;
let contingency = scopeLevelTotals.contingency;
let totalQuantity = scopeLevelTotals.quantity;
props.items.forEach(item => {
const base = item.quantity * item.unitPrice;
subtotal += base;
totalQuantity += item.quantity;
markup += item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
contingency += item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
let modifiersTotal = 0;
props.subScopes.forEach(ss => {
const ssItems = props.items.filter(i => i.subScopeId === ss.id);
const ssTotals = getItemsPricingTotals(ssItems, ss.modifiers || []);
subtotal += ssTotals.subtotal;
markup += ssTotals.markup;
contingency += ssTotals.contingency;
totalQuantity += ssTotals.quantity;
(ss.modifiers || []).forEach(m => {
if (!m.includeInTotal) return;
modifiersTotal += ModifierRegistry.calculate(m, ssTotals.total, ssItems);
});
});
// Add Management and Delivery
// Add Management and Delivery after all scope and sub-scope item totals are accounted for.
const mgmtTotal = props.scope.useManagementLogic === 'percent'
? subtotal * (props.scope.managementFee / 100)
: props.scope.managementFee;
@@ -73,8 +88,9 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
contingency,
mgmtTotal,
deliveryTotal,
modifiersTotal,
totalQuantity,
total: subtotal + markup + contingency + mgmtTotal + deliveryTotal
total: subtotal + markup + contingency + mgmtTotal + deliveryTotal + modifiersTotal
};
});
@@ -96,6 +112,26 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
setIsOver(false);
};
const openTemplateModal = (type: ScopeTextTemplateType) => {
setActiveTemplateType(type);
setIsTemplateModalOpen(true);
};
const templateModalCurrentValue = createMemo(() =>
activeTemplateType() === 'estimatorNotes'
? props.scope.estimatorNotes || ''
: props.scope.bidDescription || ''
);
const applyTemplateValue = (value: string) => {
if (activeTemplateType() === 'estimatorNotes') {
props.onUpdateScope(props.scope.id, { estimatorNotes: value });
return;
}
props.onUpdateScope(props.scope.id, { bidDescription: value });
};
return (
<div
onDragOver={handleDragOver}
@@ -124,23 +160,37 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
onInput={(e) => props.onUpdateScope(props.scope.id, { name: e.currentTarget.value })}
class="text-xl font-black text-foreground bg-transparent border-none outline-none focus:ring-0 w-64"
/>
<div class="flex items-center gap-2 text-xs text-muted-foreground font-bold uppercase tracking-wider">
<div class="flex items-center gap-2 text-[10px] text-muted-foreground font-bold uppercase tracking-wider mt-1 h-6">
<span>{props.items.length} Items</span>
<span></span>
<span>{formatQuantity(totals().totalQuantity)} Qty</span>
<span></span>
<span class="text-primary">${formatNumber(totals().total)} Total</span>
<span></span>
<SupplierDropdown
value={props.scope.supplier || ''}
options={appStore.suppliers()}
onSelect={(val) => props.onUpdateScope(props.scope.id, { supplier: val })}
/>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
onClick={() => props.onAddSubScope(props.scope.id)}
class="flex items-center gap-2 px-4 py-2 bg-primary/10 text-primary rounded-xl text-sm font-bold hover:bg-primary/20 transition-colors border border-primary/20"
>
<Plus class="mr-2 h-3.5 w-3.5" /> Sub-scope
</button>
<div class="flex items-center gap-1.5">
<button
onClick={() => props.onAddSubScope(props.scope.id)}
class="flex items-center gap-2 px-3 py-2 bg-primary/10 text-primary rounded-xl text-sm font-bold hover:bg-primary/20 transition-colors border border-primary/20"
>
<Plus class="h-3.5 w-3.5" /> Sub-scope
</button>
<button
onClick={() => props.onOpenApplyTemplate(props.scope.id)}
class="flex items-center gap-2 px-3 py-2 bg-indigo-50 text-indigo-600 rounded-xl text-sm font-bold hover:bg-indigo-100 transition-colors border border-indigo-100"
>
<FileBox class="h-3.5 w-3.5" /> Template
</button>
</div>
<div class="h-8 w-px bg-border mx-2"></div>
<button
onClick={() => setShowBulkActions(!showBulkActions())}
@@ -189,70 +239,63 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
Apply
</button>
</div>
<div class="flex items-center ml-auto">
<button
onClick={() => props.onOpenApplyTemplate(props.scope.id)}
class="flex items-center gap-2 px-3 py-1.5 bg-background text-primary border border-primary/30 rounded-lg text-xs font-bold shadow-sm hover:bg-primary hover:text-primary-foreground hover:border-primary transition-all"
>
<FileBox class="w-3.5 h-3.5" /> Apply Template
</button>
</div>
</div>
</Show>
<div
onClick={(e) => { if (e.target === e.currentTarget) props.onClearSelection(); }}
class="p-6 space-y-4 relative"
>
<Show when={scopeItems().length > 0}>
<div class="relative pb-4">
{/* Column Headers - Sticky, Square Top */}
<div class="sticky top-header z-20 bg-background/95 backdrop-blur-sm border-b border-border py-2 mb-2 hidden md:flex items-center gap-4 text-[10px] font-bold text-muted-foreground uppercase tracking-wider px-3 rounded-none">
<div class="w-4 shrink-0"></div> {/* Grip space */}
<div class="flex-1 min-w-[200px]">Description</div>
<div class="w-20 shrink-0 text-right">Qty</div>
<div class="w-24 shrink-0 text-right">Price</div>
<div class="w-24 shrink-0 text-right">Subtotal</div>
<Show when={props.isSidebarCollapsed() || !props.hideColumns}>
<div class="w-32 shrink-0 text-center">Markup</div>
<div class="w-32 shrink-0 text-center">Contingency</div>
</Show>
<div class="w-28 shrink-0 text-right">Total</div>
<div class="w-16 shrink-0"></div> {/* Action space */}
</div>
<ScopeScaleWrapper>
{/* Sticky Column Headers */}
<div class="scope-sticky-header py-2 mb-2 hidden md:flex items-center gap-4 text-[10px] font-bold text-muted-foreground uppercase tracking-wider px-3">
<div class="w-4 shrink-0"></div> {/* Grip space */}
<div class="flex-1 min-w-[200px]">Description</div>
<div class="w-20 shrink-0 text-right">Qty</div>
<div class="w-24 shrink-0 text-right">Price</div>
<div class="w-24 shrink-0 text-right">Subtotal</div>
<Show when={props.isSidebarCollapsed() || !props.hideColumns}>
<div class="w-32 shrink-0 text-center">Markup</div>
<div class="w-32 shrink-0 text-center">Contingency</div>
</Show>
<div class="w-28 shrink-0 text-right">Total</div>
<div class="w-16 shrink-0"></div> {/* Action space */}
</div>
{/* Scope-level items */}
<div
onClick={(e) => { if (e.target === e.currentTarget) props.onClearSelection(); }}
class="px-3 pb-3 space-y-2"
>
<Show when={scopeItems().length > 0}>
<div class="grid gap-2">
{/* Scope Level Items */}
<For each={props.items.filter(i => !i.subScopeId)}>
{(item) => (
<LazyItem estimatedHeight={60}>
<ItemRow
item={item}
onUpdate={props.onUpdateItem}
onDelete={props.onDeleteItem}
onDragStart={props.onDragStart}
onDuplicateItem={props.onDuplicateItem}
isSidebarCollapsed={props.isSidebarCollapsed}
isSelected={props.selectedIds.includes(item.id)}
anySelected={props.selectedIds.length > 0}
onToggleSelection={props.onToggleSelection}
hideColumns={props.hideColumns}
/>
</LazyItem>
)}
{(item) => (
<LazyItem estimatedHeight={60}>
<ItemRow
item={item}
onUpdate={props.onUpdateItem}
onDelete={props.onDeleteItem}
onDragStart={props.onDragStart}
onDuplicateItem={props.onDuplicateItem}
isSidebarCollapsed={props.isSidebarCollapsed}
isSelected={props.selectedIds.includes(item.id)}
anySelected={props.selectedIds.length > 0}
onToggleSelection={props.onToggleSelection}
hideColumns={props.hideColumns}
activeSupplier={props.scope.supplier}
/>
</LazyItem>
)}
</For>
</div>
</div>
</Show>
</Show>
<Show when={scopeItems().length === 0 && props.subScopes.length === 0}>
<div class="text-center py-8 text-muted-foreground/50 italic text-sm">
Empty scope. Drag items here or add sub-scopes.
</div>
</Show>
<Show when={scopeItems().length === 0 && props.subScopes.length === 0}>
<div class="text-center py-8 text-muted-foreground/50 italic text-sm">
Empty scope. Drag items here or add sub-scopes.
</div>
</Show>
</div>
{/* Sub-scopes */}
<div class="space-y-6 pt-4">
{/* Sub-scopes — rendered inside the same scale wrapper so they zoom together */}
<div class="px-3 pb-3 space-y-4">
<For each={props.subScopes}>
{(subScope) => (
<SubScopeCard
@@ -273,13 +316,16 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
onToggleSelection={props.onToggleSelection}
onClearSelection={props.onClearSelection}
hideColumns={props.hideColumns}
scopeSupplier={props.scope.supplier}
/>
)}
</For>
</div>
</ScopeScaleWrapper>
<div class="p-6 pt-4 space-y-4">
{/* Scope Extras & Totals */}
<div class="mt-8 pt-6 border-t border-border space-y-3">
<div class="mt-4 pt-6 border-t border-border space-y-3">
{/* Management Line */}
<div class="flex items-center justify-between group">
<div class="flex items-center gap-3">
@@ -292,21 +338,63 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
<Percent class="w-3 h-3" />
</button>
<button
onClick={() => props.onUpdateScope(props.scope.id, { useManagementLogic: 'simple' })}
onClick={() => props.onUpdateScope(props.scope.id, {
useManagementLogic: 'simple',
managementFee: (props.scope.managementFeeQuantity || 0) * (props.scope.managementFeeRate || 0)
})}
class={`p-1 rounded-md transition-colors ${props.scope.useManagementLogic === 'simple' ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`}
>
<DollarSign class="w-3 h-3" />
</button>
</div>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-4">
{props.scope.useManagementLogic === 'percent' ? (
<NumericInput
value={props.scope.managementFee}
onUpdate={(val) => props.onUpdateScope(props.scope.id, { managementFee: val })}
class="w-20 text-right text-sm border-b border-dashed border-border focus:border-primary outline-none bg-transparent"
/>
<span class="w-28 text-right text-foreground font-medium">${formatNumber(totals().mgmtTotal)}</span>
</div>
) : (
<div class="flex items-center gap-3">
<div class="flex flex-col items-end gap-0.5 text-[9px] font-black tracking-[0.3em] text-muted-foreground uppercase">
<span>Qty</span>
<NumericInput
value={props.scope.managementFeeQuantity}
onUpdate={(val) => {
const qty = val;
const rate = props.scope.managementFeeRate || 0;
props.onUpdateScope(props.scope.id, {
managementFeeQuantity: qty,
managementFee: qty * rate
});
}}
class="w-16 text-right text-sm border-b border-dashed border-border focus:border-primary outline-none bg-transparent"
/>
</div>
<span class="text-xs font-black text-muted-foreground/70">×</span>
<div class="flex flex-col items-end gap-0.5 text-[9px] font-black tracking-[0.3em] text-muted-foreground uppercase">
<span>Rate</span>
<div class="flex items-center gap-1">
<span class="text-[10px] font-black text-muted-foreground">$</span>
<NumericInput
value={props.scope.managementFeeRate}
onUpdate={(val) => {
const rate = val;
const qty = props.scope.managementFeeQuantity || 0;
props.onUpdateScope(props.scope.id, {
managementFeeRate: rate,
managementFee: qty * rate
});
}}
class="w-20 text-right text-sm border-b border-dashed border-border focus:border-primary outline-none bg-transparent"
/>
</div>
</div>
</div>
)}
<span class="w-28 text-right text-foreground font-medium">${formatNumber(totals().mgmtTotal)}</span>
</div>
</div>
{/* Delivery Fee */}
@@ -321,21 +409,63 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
<Percent class="w-3 h-3" />
</button>
<button
onClick={() => props.onUpdateScope(props.scope.id, { useDeliveryLogic: 'simple' })}
onClick={() => props.onUpdateScope(props.scope.id, {
useDeliveryLogic: 'simple',
deliveryFee: (props.scope.deliveryFeeQuantity || 0) * (props.scope.deliveryFeeRate || 0)
})}
class={`p-1 rounded-md transition-colors ${props.scope.useDeliveryLogic === 'simple' ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`}
>
<DollarSign class="w-3 h-3" />
</button>
</div>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-4">
{props.scope.useDeliveryLogic === 'percent' ? (
<NumericInput
value={props.scope.deliveryFee}
onUpdate={(val) => props.onUpdateScope(props.scope.id, { deliveryFee: val })}
class="w-20 text-right text-sm border-b border-dashed border-border focus:border-primary outline-none bg-transparent"
/>
<span class="w-28 text-right text-foreground font-medium">${formatNumber(totals().deliveryTotal)}</span>
</div>
) : (
<div class="flex items-center gap-3">
<div class="flex flex-col items-end gap-0.5 text-[9px] font-black tracking-[0.3em] text-muted-foreground uppercase">
<span>Qty</span>
<NumericInput
value={props.scope.deliveryFeeQuantity}
onUpdate={(val) => {
const qty = val;
const rate = props.scope.deliveryFeeRate || 0;
props.onUpdateScope(props.scope.id, {
deliveryFeeQuantity: qty,
deliveryFee: qty * rate
});
}}
class="w-16 text-right text-sm border-b border-dashed border-border focus:border-primary outline-none bg-transparent"
/>
</div>
<span class="text-xs font-black text-muted-foreground/70">×</span>
<div class="flex flex-col items-end gap-0.5 text-[9px] font-black tracking-[0.3em] text-muted-foreground uppercase">
<span>Rate</span>
<div class="flex items-center gap-1">
<span class="text-[10px] font-black text-muted-foreground">$</span>
<NumericInput
value={props.scope.deliveryFeeRate}
onUpdate={(val) => {
const rate = val;
const qty = props.scope.deliveryFeeQuantity || 0;
props.onUpdateScope(props.scope.id, {
deliveryFeeRate: rate,
deliveryFee: qty * rate
});
}}
class="w-20 text-right text-sm border-b border-dashed border-border focus:border-primary outline-none bg-transparent"
/>
</div>
</div>
</div>
)}
<span class="w-28 text-right text-foreground font-medium">${formatNumber(totals().deliveryTotal)}</span>
</div>
</div>
{/* Scope Notes Section */}
@@ -346,6 +476,14 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
onUpdate={(val) => props.onUpdateScope(props.scope.id, { estimatorNotes: val })}
icon={ClipboardList}
highlight={props.scope.estimatorNotes?.includes('#')}
headerActions={
<button
onClick={() => openTemplateModal('estimatorNotes')}
class="px-3 py-1 rounded-lg text-[10px] font-bold transition-all border text-muted-foreground hover:bg-muted border-border/40"
>
Template
</button>
}
/>
<NoteEditor
label="Scope Description (Appears on Finale Estimate)"
@@ -353,6 +491,14 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
onUpdate={(val) => props.onUpdateScope(props.scope.id, { bidDescription: val })}
icon={FileText}
highlight={props.scope.bidDescription?.includes('#')}
headerActions={
<button
onClick={() => openTemplateModal('bidDescription')}
class="px-3 py-1 rounded-lg text-[10px] font-bold transition-all border text-muted-foreground hover:bg-muted border-border/40"
>
Template
</button>
}
/>
</div>
@@ -374,6 +520,12 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
<span>Scope Contingency:</span>
<span class="w-28 text-right">${formatNumber(totals().contingency)}</span>
</div>
<Show when={totals().modifiersTotal !== 0}>
<div class="flex justify-end gap-8 text-indigo-600">
<span>Scope Modifiers:</span>
<span class="w-28 text-right">${formatNumber(totals().modifiersTotal)}</span>
</div>
</Show>
<div class="flex justify-end gap-8 font-black text-foreground text-xl mt-4">
<span>Scope Grand Total:</span>
<span class="w-32 text-right border-t-2 border-foreground pt-1">
@@ -384,6 +536,14 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
</div>
</div>
</Show>
<ScopeTextTemplateModal
show={isTemplateModalOpen()}
templateType={activeTemplateType()}
currentValue={templateModalCurrentValue()}
onApply={applyTemplateValue}
onClose={() => setIsTemplateModalOpen(false)}
/>
</div>
);
};
+386
View File
@@ -0,0 +1,386 @@
import { createEffect, createMemo, createSignal, For, Show, type Component } from 'solid-js';
import { Portal } from 'solid-js/web';
import { CheckCircle2, CopyPlus, History, PencilLine, Search, Trash2, User, X } from 'lucide-solid';
import type { ScopeTextTemplate, ScopeTextTemplateHistoryEntry, ScopeTextTemplateType } from '../types';
import { pb, COLLECTIONS } from '../utils/db';
import { createScopeTextDiff } from '../utils/scope-text-template-history';
interface ScopeTextTemplateModalProps {
show: boolean;
templateType: ScopeTextTemplateType;
currentValue: string;
onApply: (value: string) => void;
onClose: () => void;
}
const TEMPLATE_LABELS: Record<ScopeTextTemplateType, string> = {
estimatorNotes: "Estimator's Notes",
bidDescription: 'Scope Description'
};
const getCurrentUserLabel = () =>
pb.authStore.model?.name || pb.authStore.model?.email || 'Unknown';
const ScopeTextTemplateModal: Component<ScopeTextTemplateModalProps> = (props) => {
const [templates, setTemplates] = createSignal<ScopeTextTemplate[]>([]);
const [isLoading, setIsLoading] = createSignal(false);
const [isSaving, setIsSaving] = createSignal(false);
const [updatingTemplateId, setUpdatingTemplateId] = createSignal<string | null>(null);
const [deletingTemplateId, setDeletingTemplateId] = createSignal<string | null>(null);
const [expandedHistoryId, setExpandedHistoryId] = createSignal<string | null>(null);
const [searchQuery, setSearchQuery] = createSignal('');
const [newTemplateName, setNewTemplateName] = createSignal('');
createEffect(() => {
if (props.show) {
void loadTemplates();
setSearchQuery('');
setNewTemplateName('');
}
});
const loadTemplates = async () => {
setIsLoading(true);
try {
const records = await pb.collection(COLLECTIONS.SCOPE_TEXT_TEMPLATES).getFullList<any>({
sort: '-updated'
});
setTemplates(records.map((record) => ({
id: record.id,
name: record.name,
type: record.type,
content: record.content,
lastUpdatedBy: record.lastUpdatedBy,
history: Array.isArray(record.history) ? record.history : [],
createdAt: record.created,
updatedAt: record.updated
})));
} catch (error) {
console.error('Failed to load scope text templates:', error);
alert('Failed to load scope text templates.');
} finally {
setIsLoading(false);
}
};
const filteredTemplates = createMemo(() => {
const query = searchQuery().trim().toLowerCase();
return templates().filter(template => {
if (template.type !== props.templateType) return false;
if (!query) return true;
return (
template.name.toLowerCase().includes(query) ||
template.content.toLowerCase().includes(query)
);
});
});
const saveCurrentAsTemplate = async () => {
const name = newTemplateName().trim();
const content = props.currentValue.trim();
if (!name) {
alert('Enter a template name first.');
return;
}
if (!content) {
alert(`Add ${TEMPLATE_LABELS[props.templateType]} content before saving a template.`);
return;
}
const now = new Date().toISOString();
const userLabel = getCurrentUserLabel();
const historyEntry: ScopeTextTemplateHistoryEntry = {
id: crypto.randomUUID(),
timestamp: now,
updatedBy: userLabel,
action: 'created',
previousContent: '',
nextContent: props.currentValue,
diff: createScopeTextDiff('', props.currentValue)
};
setIsSaving(true);
try {
const created = await pb.collection(COLLECTIONS.SCOPE_TEXT_TEMPLATES).create<any>({
name,
type: props.templateType,
content: props.currentValue,
lastUpdatedBy: userLabel,
history: [historyEntry]
});
setTemplates(prev => [{
id: created.id,
name: created.name,
type: created.type,
content: created.content,
lastUpdatedBy: created.lastUpdatedBy,
history: Array.isArray(created.history) ? created.history : [historyEntry],
createdAt: created.created,
updatedAt: created.updated
}, ...prev]);
setNewTemplateName('');
} catch (error) {
console.error('Failed to save scope text template:', error);
alert('Failed to save scope text template.');
} finally {
setIsSaving(false);
}
};
const updateTemplateFromCurrent = async (template: ScopeTextTemplate) => {
if (template.content === props.currentValue) {
alert('Current text matches this template already.');
return;
}
const userLabel = getCurrentUserLabel();
const historyEntry: ScopeTextTemplateHistoryEntry = {
id: crypto.randomUUID(),
timestamp: new Date().toISOString(),
updatedBy: userLabel,
action: 'updated',
previousContent: template.content,
nextContent: props.currentValue,
diff: createScopeTextDiff(template.content, props.currentValue)
};
setUpdatingTemplateId(template.id);
try {
const updated = await pb.collection(COLLECTIONS.SCOPE_TEXT_TEMPLATES).update<any>(template.id, {
content: props.currentValue,
lastUpdatedBy: userLabel,
history: [...(template.history || []), historyEntry]
});
setTemplates(prev => prev
.map((entry) => entry.id === template.id
? {
id: updated.id,
name: updated.name,
type: updated.type,
content: updated.content,
lastUpdatedBy: updated.lastUpdatedBy,
history: Array.isArray(updated.history) ? updated.history : [...(template.history || []), historyEntry],
createdAt: updated.created,
updatedAt: updated.updated
}
: entry
)
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
);
} catch (error) {
console.error('Failed to update scope text template:', error);
alert('Failed to update scope text template.');
} finally {
setUpdatingTemplateId(null);
}
};
const handleDelete = async (id: string) => {
const target = templates().find(template => template.id === id);
if (!target) return;
if (!window.confirm(`Delete template "${target.name}"?`)) return;
setDeletingTemplateId(id);
try {
await pb.collection(COLLECTIONS.SCOPE_TEXT_TEMPLATES).delete(id);
setTemplates(prev => prev.filter(template => template.id !== id));
} catch (error) {
console.error('Failed to delete scope text template:', error);
alert('Failed to delete scope text template.');
} finally {
setDeletingTemplateId(null);
}
};
return (
<Show when={props.show}>
<Portal>
<div class="fixed inset-0 z-[110] flex items-center justify-center bg-background/80 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div class="bg-card w-full max-w-3xl max-h-[85vh] flex flex-col rounded-3xl shadow-premium border border-border overflow-hidden animate-in zoom-in-95 duration-200">
<div class="flex items-center justify-between p-6 border-b border-border">
<div>
<h3 class="text-xl font-black text-foreground">{TEMPLATE_LABELS[props.templateType]} Templates</h3>
<p class="text-[10px] uppercase font-bold text-muted-foreground tracking-wider mt-1">Save reusable text and apply it to this scope</p>
</div>
<button onClick={props.onClose} class="p-2 hover:bg-muted rounded-xl transition-colors">
<X class="w-5 h-5 text-muted-foreground" />
</button>
</div>
<div class="p-6 space-y-6 overflow-y-auto">
<div class="grid grid-cols-1 lg:grid-cols-[1fr_auto] gap-3 items-end">
<div class="space-y-2">
<label class="text-xs font-black text-muted-foreground uppercase tracking-wider">Save Current Text as Template</label>
<input
type="text"
value={newTemplateName()}
onInput={(e) => setNewTemplateName(e.currentTarget.value)}
placeholder="Template name"
class="w-full bg-muted/20 border border-border/60 px-4 py-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium"
/>
</div>
<button
onClick={saveCurrentAsTemplate}
disabled={isSaving()}
class="flex items-center justify-center gap-2 px-4 py-3 bg-primary text-primary-foreground rounded-2xl text-sm font-bold hover:bg-primary/90 transition-colors"
>
<CopyPlus class="w-4 h-4" />
{isSaving() ? 'Saving...' : 'Save Template'}
</button>
</div>
<div class="space-y-3">
<label class="text-xs font-black text-muted-foreground uppercase tracking-wider">Saved Templates</label>
<div class="relative">
<Search class="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
value={searchQuery()}
onInput={(e) => setSearchQuery(e.currentTarget.value)}
placeholder="Search templates"
class="w-full bg-muted/20 border border-border/60 pl-11 pr-4 py-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium"
/>
</div>
</div>
<div class="space-y-3">
<Show
when={!isLoading()}
fallback={
<div class="py-12 text-center text-muted-foreground text-sm font-medium">
Loading templates...
</div>
}
>
<For each={filteredTemplates()}>
{(template) => (
<div class="rounded-2xl border border-border/60 bg-muted/10 p-4 space-y-4">
<div class="flex items-start justify-between gap-4">
<div>
<h4 class="font-bold text-foreground">{template.name}</h4>
<p class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mt-1">
Updated {new Date(template.updatedAt).toLocaleDateString()}
</p>
<Show when={template.lastUpdatedBy}>
<div class="flex items-center gap-1.5 mt-1.5">
<User class="w-3 h-3 text-muted-foreground" />
<span class="text-[10px] font-bold uppercase tracking-tight text-muted-foreground">
Last updated by {template.lastUpdatedBy}
</span>
</div>
</Show>
</div>
<div class="flex items-center gap-2">
<button
onClick={() => {
props.onApply(template.content);
props.onClose();
}}
class="flex items-center gap-2 px-4 py-2 bg-primary/10 text-primary border border-primary/20 rounded-xl text-xs font-bold hover:bg-primary hover:text-primary-foreground transition-all"
>
<CheckCircle2 class="w-4 h-4" />
Apply
</button>
<button
onClick={() => void updateTemplateFromCurrent(template)}
disabled={updatingTemplateId() === template.id}
class="flex items-center gap-2 px-4 py-2 bg-amber-50 text-amber-700 border border-amber-200 rounded-xl text-xs font-bold hover:bg-amber-100 transition-all disabled:opacity-50"
>
<PencilLine class="w-4 h-4" />
{updatingTemplateId() === template.id ? 'Updating...' : 'Update'}
</button>
<button
onClick={() => setExpandedHistoryId(expandedHistoryId() === template.id ? null : template.id)}
class="flex items-center gap-2 px-4 py-2 bg-muted/40 text-muted-foreground border border-border rounded-xl text-xs font-bold hover:bg-muted transition-all"
>
<History class="w-4 h-4" />
History
</button>
<button
onClick={() => void handleDelete(template.id)}
disabled={deletingTemplateId() === template.id}
class="p-2 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-xl transition-colors disabled:opacity-50"
aria-label={`Delete ${template.name}`}
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
<pre class="whitespace-pre-wrap break-words text-sm text-foreground/85 bg-background/80 rounded-xl border border-border/40 p-3 font-sans">{template.content}</pre>
<Show when={expandedHistoryId() === template.id}>
<div class="space-y-3 border-t border-border/50 pt-4">
<div class="text-[10px] font-black uppercase tracking-widest text-muted-foreground">
Edit History
</div>
<For each={[...(template.history || [])].slice().reverse()}>
{(entry) => (
<div class="rounded-xl border border-border/50 bg-background/70 overflow-hidden">
<div class="px-4 py-3 border-b border-border/40 flex items-center justify-between gap-4">
<div>
<div class="text-xs font-bold text-foreground">
{entry.action === 'created' ? 'Created' : 'Updated'}
</div>
<div class="text-[10px] uppercase tracking-wider text-muted-foreground font-bold mt-1">
{new Date(entry.timestamp).toLocaleString()}
</div>
</div>
<div class="flex items-center gap-1.5 text-[10px] uppercase tracking-tight font-bold text-muted-foreground">
<User class="w-3 h-3" />
{entry.updatedBy}
</div>
</div>
<div class="p-3 space-y-1 bg-background/80">
<For each={entry.diff}>
{(line) => (
<Show when={line.op !== 'equal'}>
<div class={`font-mono text-xs whitespace-pre-wrap break-words px-3 py-1.5 rounded-lg ${
line.op === 'add'
? 'bg-green-50 text-green-700'
: 'bg-red-50 text-red-700'
}`}>
<span class="mr-2 font-bold">{line.op === 'add' ? '+' : '-'}</span>
{line.text || ' '}
</div>
</Show>
)}
</For>
<Show when={!entry.diff.some(line => line.op !== 'equal')}>
<div class="text-xs text-muted-foreground italic">
No line changes captured.
</div>
</Show>
</div>
</div>
)}
</For>
<Show when={(template.history || []).length === 0}>
<div class="text-xs text-muted-foreground italic">
No history recorded yet.
</div>
</Show>
</div>
</Show>
</div>
)}
</For>
<Show when={filteredTemplates().length === 0}>
<div class="py-12 flex flex-col items-center justify-center text-center text-muted-foreground/60 border-2 border-dashed border-border/60 rounded-2xl bg-background/50">
<p class="text-sm font-medium">No {TEMPLATE_LABELS[props.templateType].toLowerCase()} templates found.</p>
<p class="text-xs mt-1">Save the current text to start reusing it across scopes.</p>
</div>
</Show>
</Show>
</div>
</div>
</div>
</div>
</Portal>
</Show>
);
};
export default ScopeTextTemplateModal;
+90 -36
View File
@@ -1,9 +1,12 @@
import type { Component } from 'solid-js';
import { For, createMemo, Show, createSignal } from 'solid-js';
import { Trash2, ChevronDown, Copy, ListTree } from 'lucide-solid';
import { For, createMemo, Show, createSignal, type Component } from 'solid-js';
import { Trash2, ChevronDown, Copy, ListTree, Plus, Calculator } from 'lucide-solid';
import type { EstimateItem, SubScope, Scope } from '../types';
import ItemRow from './ItemRow';
import LazyItem from './LazyItem';
import ModifierRow from './ModifierRow';
import { appStore } from '../store/appStore';
import { ModifierRegistry, MODIFIER_MODULES } from '../utils/modifier-registry';
import { getItemsPricingTotals } from '../utils/pricing';
interface SubScopeCardProps {
subScope: SubScope;
@@ -23,6 +26,7 @@ interface SubScopeCardProps {
onToggleSelection: (id: string, isMulti: boolean, isShift: boolean) => void;
onClearSelection: () => void;
hideColumns?: boolean;
scopeSupplier?: string;
}
const SubScopeCard: Component<SubScopeCardProps> = (props) => {
@@ -30,16 +34,24 @@ const SubScopeCard: Component<SubScopeCardProps> = (props) => {
const [isOver, setIsOver] = createSignal(false);
const [showCopyMenu, setShowCopyMenu] = createSignal(false);
const subScopeItems = () => props.items.filter(item => item.subScopeId === props.subScope.id);
const totals = createMemo(() => {
return props.items.reduce((acc, item) => {
const base = item.quantity * item.unitPrice;
const markup = item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
const contingency = item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
return {
amount: acc.amount + base + markup + contingency,
quantity: acc.quantity + item.quantity
};
}, { amount: 0, quantity: 0 });
const modifiers = props.subScope.modifiers || [];
const itemTotals = getItemsPricingTotals(props.items, modifiers);
let modifiedAmount = itemTotals.total;
modifiers.forEach(m => {
if (!m.includeInTotal) return;
modifiedAmount += ModifierRegistry.calculate(m, itemTotals.total, subScopeItems());
});
return {
amount: modifiedAmount,
quantity: itemTotals.quantity,
baseAmount: itemTotals.total
};
});
const formatNumber = (num: number) => {
@@ -166,11 +178,11 @@ const SubScopeCard: Component<SubScopeCardProps> = (props) => {
<Show when={!isCollapsed()}>
<div
onClick={(e) => { if (e.target === e.currentTarget) props.onClearSelection(); }}
class="p-2 space-y-1.5 relative"
class="relative"
>
{/* SubScope Column Headers */}
<Show when={props.items.length > 0}>
<div class="sticky top-header z-20 bg-background/95 backdrop-blur-sm hidden md:flex items-center gap-4 text-[9px] font-bold text-muted-foreground/60 uppercase tracking-wider px-3 pb-2 pt-1 mb-2 border-b border-border/40">
<div class="scope-sticky-header py-2 mb-2 hidden md:flex items-center gap-4 text-[9px] font-bold text-muted-foreground/60 uppercase tracking-wider px-3">
<div class="w-4 shrink-0"></div>
<div class="flex-1 min-w-[200px]">Description</div>
<div class="w-20 shrink-0 text-right">Qty</div>
@@ -185,29 +197,71 @@ const SubScopeCard: Component<SubScopeCardProps> = (props) => {
</div>
</Show>
<For each={props.items}>
{(item) => (
<LazyItem estimatedHeight={60}>
<ItemRow
item={item}
onUpdate={props.onUpdateItem}
onDelete={props.onDeleteItem}
onDragStart={props.onDragStart}
onDuplicateItem={props.onDuplicateItem}
isSidebarCollapsed={props.isSidebarCollapsed}
isSelected={props.selectedIds.includes(item.id)}
anySelected={props.selectedIds.length > 0}
onToggleSelection={props.onToggleSelection}
hideColumns={props.hideColumns}
/>
</LazyItem>
)}
</For>
<Show when={props.items.length === 0}>
<div class="text-center py-4 text-[10px] font-bold text-muted-foreground/40 uppercase tracking-widest italic">
Empty Sub-scope
<div class="px-3 pb-3 space-y-1.5">
<For each={props.items}>
{(item) => (
<LazyItem estimatedHeight={60}>
<ItemRow
item={item}
onUpdate={props.onUpdateItem}
onDelete={props.onDeleteItem}
onDragStart={props.onDragStart}
onDuplicateItem={props.onDuplicateItem}
isSidebarCollapsed={props.isSidebarCollapsed}
isSelected={props.selectedIds.includes(item.id)}
anySelected={props.selectedIds.length > 0}
onToggleSelection={props.onToggleSelection}
hideColumns={props.hideColumns}
activeSupplier={props.scopeSupplier}
subScopeModifiers={props.subScope.modifiers}
/>
</LazyItem>
)}
</For>
{/* Modifiers Section */}
<Show when={(props.subScope.modifiers?.length || 0) > 0}>
<div class="pt-4 pb-2 space-y-1.5">
<div class="px-1 text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] mb-3 flex items-center gap-2">
<Calculator class="w-3.5 h-3.5" />
<span>Sub-scope Fees & Modifiers</span>
</div>
<For each={props.subScope.modifiers}>
{(modifier) => (
<ModifierRow
modifier={modifier}
calculatedValue={ModifierRegistry.calculateDisplay(modifier, totals().baseAmount, subScopeItems(), props.subScope.modifiers || [])}
onUpdate={(id, updates) => appStore.updateModifier(props.subScope.id, id, updates)}
onDelete={(id) => appStore.removeModifier(props.subScope.id, id)}
/>
)}
</For>
</div>
</Show>
<Show when={props.items.length === 0 && (props.subScope.modifiers?.length || 0) === 0}>
<div class="text-center py-4 text-[10px] font-bold text-muted-foreground/40 uppercase tracking-widest italic">
Empty Sub-scope
</div>
</Show>
{/* Add Modifier Buttons */}
<div class="flex flex-wrap items-center gap-2 mt-4 pt-4 border-t border-border/40">
<span class="text-[9px] font-black uppercase tracking-[0.2em] text-muted-foreground/60 ml-1 mr-2">Add Modifier:</span>
<For each={MODIFIER_MODULES}>
{(module) => (
<button
onClick={() => appStore.addModifier(props.subScope.id, module.id)}
class="flex items-center gap-1.5 px-3 py-1.5 bg-background border border-border rounded-xl text-[10px] font-black uppercase tracking-wider text-muted-foreground hover:text-primary hover:border-primary/30 transition-all group shadow-sm bg-white"
title={module.description}
>
<Plus class="w-3.5 h-3.5 transition-transform group-hover:rotate-90" />
{module.name}
</button>
)}
</For>
</div>
</Show>
</div>
</div>
</Show>
</div>
@@ -1,32 +1,40 @@
import { createSignal, onCleanup, Show } from 'solid-js';
import type { Component } from 'solid-js';
import { PanelLeftOpen, PanelLeftClose, Calculator, Plus, FileUp, Upload, Table, Download } from 'lucide-solid';
import { Calculator, Plus, Upload, Table, Download, History, Save, ChevronDown, MoreHorizontal, FileSpreadsheet } from 'lucide-solid';
import { appStore } from '../../store/appStore';
interface BuilderHeaderProps {
isSidebarCollapsed: boolean;
onToggleSidebar: () => void;
onAddScope: () => void;
onReset: () => void;
onShowNewEstimateModal: () => void;
onImportCsvQuantities: (e: Event) => void;
onImportEstimate: (e: Event) => void;
onShowExportTableModal: () => void;
onExportEstimate: () => void;
onShowLoadModal: () => void;
onShowSaveModal: () => void;
onShowHistoryModal: () => void;
onSaveChanges: () => void;
}
export const BuilderHeader: Component<BuilderHeaderProps> = (props) => {
const { scopes, estimateItems: items } = appStore;
const [showActions, setShowActions] = createSignal(false);
let dropdownRef: HTMLDivElement | undefined;
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
setShowActions(false);
}
};
window.addEventListener('click', handleClickOutside);
onCleanup(() => window.removeEventListener('click', handleClickOutside));
return (
<div class="min-h-[4rem] py-3 px-6 border-b border-border bg-background/50 flex flex-wrap gap-y-4 items-center justify-between sticky top-0 z-50 backdrop-blur-md">
<div class="flex items-center gap-4">
<button
onClick={props.onToggleSidebar}
class="p-2 hover:bg-accent rounded-xl transition-colors text-muted-foreground shrink-0"
title={props.isSidebarCollapsed ? "Expand Sidebar" : "Collapse Sidebar"}
>
{props.isSidebarCollapsed ? <PanelLeftOpen class="w-5 h-5" /> : <PanelLeftClose class="w-5 h-5" />}
</button>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary rounded-xl text-primary-foreground shadow-md shadow-primary/20 shrink-0">
<Calculator class="w-5 h-5" />
@@ -43,46 +51,83 @@ export const BuilderHeader: Component<BuilderHeaderProps> = (props) => {
</div>
<div class="flex flex-wrap items-center gap-2">
<button
onClick={props.onAddScope}
class="flex items-center gap-2 px-4 py-2 bg-foreground text-background rounded-xl text-sm font-bold hover:bg-foreground/90 transition-all shadow-lg shadow-foreground/10 mr-2"
onClick={props.onShowNewEstimateModal}
class="flex items-center gap-2 px-3 py-2 bg-muted/50 text-blue-600 rounded-xl text-xs font-black hover:bg-blue-100/50 transition-all border border-blue-200"
>
<Plus class="w-4 h-4" /> New Scope
</button>
<button
onClick={props.onReset}
class="flex items-center gap-2 px-3 py-2 bg-muted/50 text-primary rounded-xl text-xs font-black hover:bg-primary/10 transition-all border border-border"
>
<FileUp class="w-4 h-4" /> New CSV
</button>
<div class="h-6 w-px bg-border mx-1"></div>
<label class="flex items-center gap-2 px-3 py-2 bg-muted text-muted-foreground rounded-xl text-xs font-bold hover:bg-accent hover:text-accent-foreground transition-all border border-border cursor-pointer">
<Upload class="w-4 h-4" /> Import JSON
<input type="file" accept=".json" class="hidden" onChange={props.onImportEstimate} />
</label>
<button
onClick={props.onShowExportTableModal}
class="flex items-center gap-2 px-3 py-2 bg-muted text-muted-foreground rounded-xl text-xs font-bold hover:bg-accent hover:text-accent-foreground transition-all border border-border mr-2"
>
<Table class="w-4 h-4" /> Export Table
</button>
<button
onClick={props.onExportEstimate}
class="flex items-center gap-2 px-3 py-2 bg-muted text-muted-foreground rounded-xl text-xs font-bold hover:bg-accent hover:text-accent-foreground transition-all border border-border mr-2"
>
<Download class="w-4 h-4" /> Export JSON
<Plus class="w-4 h-4" /> New Estimate
</button>
<div class="relative" ref={dropdownRef}>
<button
onClick={(e) => { e.stopPropagation(); setShowActions(!showActions()); }}
class="flex items-center gap-2 px-3 py-2 bg-muted text-muted-foreground rounded-xl text-xs font-bold hover:bg-accent hover:text-accent-foreground transition-all border border-border"
>
<MoreHorizontal class="w-4 h-4" /> Actions <ChevronDown class={`w-3 h-3 transition-transform ${showActions() ? 'rotate-180' : ''}`} />
</button>
<Show when={showActions()}>
<div class="absolute right-0 mt-2 w-56 bg-card border border-border rounded-2xl shadow-premium z-[60] overflow-hidden animate-in fade-in zoom-in-95 duration-150 origin-top-right">
<div class="p-2 space-y-1">
<p class="text-[9px] font-black uppercase tracking-widest text-muted-foreground/60 px-3 py-2">Data Exchange</p>
<label class="flex items-center gap-3 px-3 py-2.5 text-xs font-bold text-foreground hover:bg-muted rounded-xl cursor-pointer transition-colors w-full">
<FileSpreadsheet class="w-4 h-4 text-emerald-500" /> Import CSV Quantities
<input type="file" accept=".csv" class="hidden" onChange={(e) => { props.onImportCsvQuantities(e); setShowActions(false); }} />
</label>
<label class="flex items-center gap-3 px-3 py-2.5 text-xs font-bold text-foreground hover:bg-muted rounded-xl cursor-pointer transition-colors w-full">
<Upload class="w-4 h-4 text-blue-500" /> Import JSON
<input type="file" accept=".json" class="hidden" onChange={(e) => { props.onImportEstimate(e); setShowActions(false); }} />
</label>
<button
onClick={() => { props.onExportEstimate(); setShowActions(false); }}
class="flex items-center gap-3 px-3 py-2.5 text-xs font-bold text-foreground hover:bg-muted rounded-xl transition-colors w-full text-left"
>
<Download class="w-4 h-4 text-blue-500" /> Export JSON
</button>
</div>
<div class="border-t border-border/50 p-2 space-y-1 bg-muted/10">
<p class="text-[9px] font-black uppercase tracking-widest text-muted-foreground/60 px-3 py-2">Summaries</p>
<button
onClick={() => { props.onShowExportTableModal(); setShowActions(false); }}
class="flex items-center gap-3 px-3 py-2.5 text-xs font-bold text-foreground hover:bg-muted rounded-xl transition-colors w-full text-left"
>
<Table class="w-4 h-4 text-purple-500" /> Export Table
</button>
</div>
</div>
</Show>
</div>
<div class="h-6 w-px bg-border mx-1"></div>
<button
onClick={props.onShowLoadModal}
class="flex items-center gap-2 px-3 py-2 bg-blue-50 text-blue-600 rounded-xl text-xs font-bold hover:bg-blue-100 transition-all border border-blue-200"
class="flex items-center gap-2 px-4 py-2 bg-blue-50 text-blue-600 rounded-xl text-xs font-bold hover:bg-blue-100 transition-all border border-blue-200"
>
<Download class="w-4 h-4" /> Load
</button>
<Show when={appStore.estimateId()}>
<button
onClick={props.onShowHistoryModal}
class="flex items-center gap-2 px-3 py-2 bg-purple-50 text-purple-600 rounded-xl text-xs font-bold hover:bg-purple-100 transition-all border border-purple-200"
>
<History class="w-4 h-4" /> History
</button>
<button
onClick={props.onSaveChanges}
class="flex items-center gap-2 px-3 py-2 bg-green-50 text-green-600 rounded-xl text-xs font-bold hover:bg-green-100 transition-all border border-green-200"
>
<Save class="w-4 h-4" />
{appStore.isLatestVersion() ? 'Save Changes' : 'Save as Newest'}
</button>
</Show>
<button
onClick={props.onShowSaveModal}
class="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-xl text-sm font-bold hover:bg-primary/90 transition-all shadow-lg shadow-primary/10"
class="flex items-center gap-4 py-2 px-4 bg-primary text-primary-foreground rounded-xl text-sm font-bold hover:bg-primary/90 transition-all shadow-lg shadow-primary/10"
>
<Upload class="w-4 h-4" /> Save
<Upload class="w-4 h-4" /> Save New
</button>
</div>
</div>
+138 -124
View File
@@ -1,11 +1,12 @@
import type { Component } from 'solid-js';
import { For } from 'solid-js';
import { Plus, ListTree, ChevronRight } from 'lucide-solid';
import { Plus, ListTree, ChevronRight, PanelLeftOpen, PanelLeftClose } from 'lucide-solid';
import { appStore } from '../../store/appStore';
import type { EstimateItem } from '../../types';
interface BuilderSidebarProps {
isCollapsed: boolean;
onToggleSidebar: () => void;
unassignedItems: EstimateItem[];
grandTotals: { grandTotal: number };
hoveredScopeId: string | null;
@@ -22,138 +23,151 @@ export const BuilderSidebar: Component<BuilderSidebarProps> = (props) => {
};
return (
/*
* The sidebar sits inside a CSS grid column (in EstimateBuilder.tsx).
* We use overflow-visible on the aside so the toggle button can remain
* visible and clickable even when the grid column width is 0px.
*/
<aside
class={`h-[calc(100vh-var(--spacing-header))] sticky top-header bg-background border-r border-border flex flex-col transition-all duration-500 ease-in-out relative group/sidebar
${props.isCollapsed ? 'w-0 opacity-0 -translate-x-full overflow-hidden' : 'w-72 opacity-100'}`}
class="h-[calc(100vh-var(--spacing-header))] sticky top-[var(--spacing-header)] bg-background border-r border-border flex flex-col overflow-visible z-40 transition-colors"
style={{ "min-width": 0 }}
>
<div class="flex-1 overflow-y-auto px-4 py-8">
<div class="mb-4">
<div class="flex items-center justify-between mb-4 px-2">
<span class="text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em]">Estimate Navigator</span>
<button
onClick={props.onAddScope}
class="p-1.5 bg-muted hover:bg-primary/10 text-muted-foreground hover:text-primary rounded-lg transition-all"
>
<Plus class="w-3 h-3" />
</button>
</div>
{/*
* Sticky Toggle Button
* Anchored to the right edge (100% width) of the sidebar column.
* As the column width animates, the button follows perfectly.
* We use translateX to adjust horizontal offset from the edge.
*/}
<button
onClick={props.onToggleSidebar}
class="absolute top-4 z-50 w-10 h-10 bg-background hover:bg-accent rounded-xl transition-all duration-500 ease-in-out shadow-premium border border-border text-muted-foreground hover:text-primary active:scale-95 flex items-center justify-center cursor-pointer"
style={{
left: '100%',
transform: props.isCollapsed
? 'translateX(1.25rem)' // When closed (0px width), sit 20px off the left edge
: 'translateX(-0.5rem)' // When open, hang 80% out to the right (overlap only 8px)
}}
title={props.isCollapsed ? "Expand Sidebar" : "Collapse Sidebar"}
>
{props.isCollapsed ? <PanelLeftOpen class="w-5 h-5" /> : <PanelLeftClose class="w-5 h-5" />}
</button>
<nav class="space-y-1">
{/* Unassigned Sidebar Link */}
<div
onDragOver={(e) => {
e.preventDefault();
props.setHoveredScopeId('unassigned');
}}
onDragLeave={() => props.setHoveredScopeId(null)}
onDrop={(e) => props.onDrop(e, undefined, undefined)}
onClick={() => {
const el = document.getElementById('unassigned-section');
if (el) {
const headerOffset = 180;
const elementPosition = el.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
}
}}
class={`group flex items-center justify-between px-3 py-3 rounded-xl text-xs font-black uppercase tracking-wider transition-all cursor-pointer border-2 ${props.hoveredScopeId === 'unassigned' ? 'bg-primary/10 border-primary scale-[1.02] shadow-premium-lg shadow-primary/10 text-primary' : (props.unassignedItems.length > 0 ? 'bg-muted/30 text-foreground border-border/50 hover:bg-muted/50' : 'text-muted-foreground border-transparent hover:bg-accent')}`}
>
<span class="flex items-center gap-2">
<ListTree class="w-4 h-4" /> Unassigned
</span>
<span class={`px-2 py-0.5 rounded-lg text-[10px] font-black border transition-colors ${props.hoveredScopeId === 'unassigned' ? 'bg-primary text-primary-foreground border-primary' : 'bg-background text-primary border-border'}`}>
{props.unassignedItems.length}
</span>
{/*
* Sidebar Content Wrapper
* We apply the overflow-hidden here so the content doesn't leak out
* when the grid column is 0px.
*/}
<div class={`flex-1 overflow-y-auto overflow-x-hidden transition-all duration-500 flex flex-col ${props.isCollapsed ? 'w-0 opacity-0 pointer-events-none' : 'w-72 opacity-100'}`}>
<div class="flex-1 px-4 py-8">
<div class="mb-4">
<div class="flex items-center justify-between mb-4 px-2">
<span class="text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em] whitespace-nowrap">Estimate Navigator</span>
<button
onClick={props.onAddScope}
class="p-1.5 bg-muted hover:bg-primary/10 text-muted-foreground hover:text-primary rounded-lg transition-all"
>
<Plus class="w-3 h-3" />
</button>
</div>
<div class="h-4"></div>
<nav class="space-y-1">
{/* Unassigned Sidebar Link */}
<div
onDragOver={(e) => {
e.preventDefault();
props.setHoveredScopeId('unassigned');
}}
onDragLeave={() => props.setHoveredScopeId(null)}
onDrop={(e) => props.onDrop(e, undefined, undefined)}
onClick={() => {
const el = document.getElementById('unassigned-section');
if (el) {
const headerOffset = 180;
const elementPosition = el.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
}
}}
class={`group flex items-center justify-between px-3 py-3 rounded-xl text-xs font-black uppercase tracking-wider transition-all cursor-pointer border-2 ${props.hoveredScopeId === 'unassigned' ? 'bg-primary/10 border-primary scale-[1.02] shadow-premium text-primary' : (props.unassignedItems.length > 0 ? 'bg-muted/30 text-foreground border-border/50 hover:bg-muted/50' : 'text-muted-foreground border-transparent hover:bg-accent')}`}
>
<span class="flex items-center gap-2 whitespace-nowrap">
<ListTree class="w-4 h-4 shrink-0" /> Unassigned
</span>
<span class={`px-2 py-0.5 rounded-lg text-[10px] font-black border transition-colors shrink-0 ${props.hoveredScopeId === 'unassigned' ? 'bg-primary text-primary-foreground border-primary' : 'bg-background text-primary border-border'}`}>
{props.unassignedItems.length}
</span>
</div>
{/* Scopes Sidebar Links */}
<For each={scopes}>
{(scope) => (
<div class="space-y-1">
<div
onDragOver={(e) => {
e.preventDefault();
props.setHoveredScopeId(scope.id);
}}
onDragLeave={() => props.setHoveredScopeId(null)}
onDrop={(e) => props.onDrop(e, scope.id)}
class={`group flex items-center justify-between px-4 py-3 rounded-xl text-sm font-bold transition-all cursor-pointer border-2 ${props.hoveredScopeId === scope.id ? 'bg-primary/10 border-primary scale-[1.02] shadow-premium-lg shadow-primary/10 text-primary-foreground' : 'text-muted-foreground border-transparent hover:bg-card hover:shadow-premium hover:border-border'}`}
onClick={() => {
const el = document.getElementById(`scope-${scope.id}`);
if (el) {
const headerOffset = 180;
const elementPosition = el.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
}
}}
>
<span class="truncate pr-2">{scope.name}</span>
<div class="flex items-center gap-2">
<span class={`px-2 py-0.5 rounded-lg text-[10px] font-black border transition-colors ${props.hoveredScopeId === scope.id ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted text-muted-foreground border-border'}`}>
{items.filter(i => i.scopeId === scope.id).length}
</span>
<ChevronRight class={`w-4 h-4 text-muted-foreground/40 group-hover:text-primary transition-colors ${props.hoveredScopeId === scope.id ? 'text-primary' : ''}`} />
<div class="h-4"></div>
{/* Scopes Sidebar Links */}
<For each={scopes}>
{(scope) => (
<div class="space-y-1">
<div
onDragOver={(e) => { e.preventDefault(); props.setHoveredScopeId(scope.id); }}
onDragLeave={() => props.setHoveredScopeId(null)}
onDrop={(e) => props.onDrop(e, scope.id)}
class={`group flex items-center justify-between px-4 py-3 rounded-xl text-sm font-bold transition-all cursor-pointer border-2 ${props.hoveredScopeId === scope.id ? 'bg-primary/10 border-primary scale-[1.02] shadow-premium text-primary' : 'text-muted-foreground border-transparent hover:bg-card hover:shadow-premium hover:border-border'}`}
onClick={() => {
const el = document.getElementById(`scope-${scope.id}`);
if (el) {
const headerOffset = 180;
const elementPosition = el.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
}
}}
>
<span class="truncate pr-2">{scope.name}</span>
<div class="flex items-center gap-2 shrink-0">
<span class={`px-2 py-0.5 rounded-lg text-[10px] font-black border transition-colors ${props.hoveredScopeId === scope.id ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted text-muted-foreground border-border'}`}>
{items.filter(i => i.scopeId === scope.id).length}
</span>
<ChevronRight class={`w-4 h-4 text-muted-foreground/40 group-hover:text-primary transition-colors ${props.hoveredScopeId === scope.id ? 'text-primary' : ''}`} />
</div>
</div>
{/* Nested Sub-scopes in Sidebar */}
<div class="pl-4 space-y-0.5 border-l-2 border-gray-50 ml-6">
<For each={subScopes.filter(ss => ss.scopeId === scope.id)}>
{(subScope) => (
<div
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); props.setHoveredScopeId(subScope.id); }}
onDragLeave={() => props.setHoveredScopeId(null)}
onDrop={(e) => { e.stopPropagation(); props.onDrop(e, scope.id, subScope.id); }}
onClick={(e) => {
e.stopPropagation();
const el = document.getElementById(`subscope-${subScope.id}`);
if (el) {
const headerOffset = 180;
const elementPosition = el.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
}
}}
class={`flex items-center justify-between px-3 py-1.5 rounded-lg text-[11px] font-black uppercase tracking-wider transition-all cursor-pointer border ${props.hoveredScopeId === subScope.id ? 'bg-primary/5 border-primary/30 text-primary scale-[1.02]' : 'text-muted-foreground/60 border-transparent hover:bg-muted hover:text-foreground'}`}
>
<span class="truncate">{subScope.name}</span>
<span class="text-[10px] opacity-60 font-medium shrink-0">
{items.filter(i => i.subScopeId === subScope.id).length}
</span>
</div>
)}
</For>
</div>
</div>
)}
</For>
</nav>
</div>
{/* Nested Sub-scopes in Sidebar */}
<div class="pl-4 space-y-0.5 border-l-2 border-gray-50 ml-6">
<For each={subScopes.filter(ss => ss.scopeId === scope.id)}>
{(subScope) => (
<div
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
props.setHoveredScopeId(subScope.id);
}}
onDragLeave={() => props.setHoveredScopeId(null)}
onDrop={(e) => {
e.stopPropagation();
props.onDrop(e, scope.id, subScope.id);
}}
onClick={(e) => {
e.stopPropagation();
const el = document.getElementById(`subscope-${subScope.id}`);
if (el) {
const headerOffset = 180;
const elementPosition = el.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
}
}}
class={`flex items-center justify-between px-3 py-1.5 rounded-lg text-[11px] font-black uppercase tracking-wider transition-all cursor-pointer border ${props.hoveredScopeId === subScope.id ? 'bg-primary/5 border-primary/30 text-primary scale-[1.02]' : 'text-muted-foreground/60 border-transparent hover:bg-muted hover:text-foreground'}`}
>
<span class="truncate">{subScope.name}</span>
<span class="text-[10px] opacity-60 font-medium">
{items.filter(i => i.subScopeId === subScope.id).length}
</span>
</div>
)}
</For>
</div>
</div>
)}
</For>
</nav>
</div>
{/* Sidebar Footer */}
<div class="p-4 border-t border-border bg-muted/30">
<div class="bg-card rounded-2xl p-4 border border-border shadow-sm">
<div class="text-[10px] font-black text-muted-foreground uppercase tracking-widest mb-1">Current Total</div>
<div class="text-xl font-black text-foreground">${formatNumber(props.grandTotals.grandTotal)}</div>
{/* Sidebar Footer */}
<div class="p-4 border-t border-border bg-muted/30 rounded-2xl mt-4">
<div class="bg-card rounded-2xl p-4 border border-border shadow-sm">
<div class="text-[10px] font-black text-muted-foreground uppercase tracking-widest mb-1 whitespace-nowrap">Current Total</div>
<div class="text-xl font-black text-foreground">${formatNumber(props.grandTotals.grandTotal)}</div>
</div>
</div>
</div>
</div>
@@ -1,4 +1,4 @@
import type { Component } from 'solid-js';
import { Show, type Component } from 'solid-js';
import { Info, Plus } from 'lucide-solid';
interface ExecutiveSummaryProps {
@@ -7,9 +7,11 @@ interface ExecutiveSummaryProps {
markup: number;
contingency: number;
fees: number;
modifiersTotal: number;
grandTotal: number;
};
onFinalizeBid: () => void;
onExportManagerInfo: () => void;
}
export const ExecutiveSummary: Component<ExecutiveSummaryProps> = (props) => {
@@ -40,6 +42,12 @@ export const ExecutiveSummary: Component<ExecutiveSummaryProps> = (props) => {
<span class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest truncate">Contingency</span>
<div class="text-2xl xl:text-3xl font-black text-foreground tracking-tight truncate">${formatNumber(props.totals.contingency)}</div>
</div>
<Show when={props.totals.modifiersTotal !== 0}>
<div class="bg-card p-6 rounded-3xl border border-border shadow-premium space-y-2 flex flex-col justify-center min-w-0 transition-transform hover:-translate-y-1">
<span class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest truncate">Applied Modifiers</span>
<div class="text-2xl xl:text-3xl font-black text-indigo-600 tracking-tight truncate">${formatNumber(props.totals.modifiersTotal)}</div>
</div>
</Show>
<div class="bg-card p-6 rounded-3xl border border-border shadow-premium space-y-2 flex flex-col justify-center min-w-0 transition-transform hover:-translate-y-1">
<span class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest truncate">Fees & Logistics</span>
<div class="text-2xl xl:text-3xl font-black text-foreground tracking-tight truncate">${formatNumber(props.totals.fees)}</div>
@@ -61,12 +69,20 @@ export const ExecutiveSummary: Component<ExecutiveSummaryProps> = (props) => {
{formatNumber(props.totals.grandTotal)}
</div>
<div class="mt-8">
<button
onClick={props.onFinalizeBid}
class="w-full py-5 bg-primary hover:bg-primary/90 text-primary-foreground rounded-2xl font-black text-xs uppercase tracking-[0.2em] transition-all shadow-xl shadow-primary/20 active:scale-95 flex items-center justify-center gap-3"
>
<Plus class="w-5 h-5" /> Finalize Bid
</button>
<div class="space-y-3">
<button
onClick={props.onFinalizeBid}
class="w-full py-5 bg-primary hover:bg-primary/90 text-primary-foreground rounded-2xl font-black text-xs uppercase tracking-[0.2em] transition-all shadow-xl shadow-primary/20 active:scale-95 flex items-center justify-center gap-3"
>
<Plus class="w-5 h-5" /> Finalize Bid
</button>
<button
onClick={props.onExportManagerInfo}
class="w-full py-4 bg-muted hover:bg-muted/80 text-foreground rounded-2xl font-black text-xs uppercase tracking-[0.2em] transition-all border border-border active:scale-95 flex items-center justify-center gap-3"
>
<Info class="w-5 h-5" /> Manager Info
</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,218 @@
import { Show, For } from 'solid-js';
import { Portal } from 'solid-js/web';
import { X, History, CheckCircle2, Clock, User } from 'lucide-solid';
import { appStore } from '../../store/appStore';
interface HistoryModalProps {
show: boolean;
onClose: () => void;
onCheckout: (record: any) => void;
}
const HistoryModal = (props: HistoryModalProps) => {
// History is an array of snapshots from the 'history' field of the estimate record
const historyEntries = () => [...appStore.history()].reverse();
return (
<Show when={props.show}>
<Portal>
<div class="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-background/80 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div class="bg-card w-full max-w-2xl max-h-[85vh] flex flex-col rounded-3xl shadow-premium border border-border overflow-hidden animate-in zoom-in-95 duration-200">
<div class="flex items-center justify-between p-6 border-b border-border">
<div class="flex items-center gap-3">
<div class="p-2 bg-purple-100 text-purple-600 rounded-xl">
<History class="w-5 h-5" />
</div>
<div>
<h3 class="text-xl font-black text-foreground">Estimate History</h3>
<p class="text-[10px] uppercase font-bold text-muted-foreground tracking-wider mt-0.5">Version Control & Timeline</p>
</div>
</div>
<button onClick={props.onClose} class="p-2 hover:bg-muted rounded-xl transition-colors">
<X class="w-5 h-5 text-muted-foreground" />
</button>
</div>
<div class="flex-1 flex flex-col p-6 gap-4 overflow-hidden">
<div class="space-y-3 overflow-y-auto pr-2">
{/* Current Live Version */}
<div
class={`group flex flex-col p-4 rounded-2xl border transition-all ${
appStore.isLatestVersion()
? 'border-primary bg-primary/5 shadow-premium'
: 'border-border/60 bg-muted/10 hover:border-primary/30'
}`}
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class={`p-2 rounded-lg ${appStore.isLatestVersion() ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'}`}>
<CheckCircle2 class="w-4 h-4" />
</div>
<div>
<div class="flex items-center gap-2">
<h4 class="font-bold text-foreground">Latest Version</h4>
<span class="px-1.5 py-0.5 bg-green-100 text-green-700 text-[8px] font-black uppercase rounded tracking-widest">Live</span>
<Show when={appStore.isLatestVersion()}>
<span class="px-1.5 py-0.5 bg-primary/20 text-primary text-[8px] font-black uppercase rounded tracking-widest border border-primary/20">Active</span>
</Show>
</div>
<p class="text-[10px] text-muted-foreground font-medium uppercase tracking-wider mt-1">
The current tip of the project
</p>
<Show when={appStore.latestSnapshot()?.savedBy}>
<div class="flex items-center gap-1.5 mt-1.5 grayscale opacity-70">
<User class="w-3 h-3 text-muted-foreground" />
<span class="text-[10px] font-bold text-muted-foreground uppercase tracking-tight">Saved by {appStore.latestSnapshot().savedBy}</span>
</div>
</Show>
</div>
</div>
<Show when={!appStore.isLatestVersion()}>
<button
onClick={() => {
props.onCheckout(appStore.latestSnapshot());
props.onClose();
}}
class="flex items-center gap-2 px-4 py-2 bg-background border border-border rounded-xl text-xs font-bold shadow-sm hover:text-primary hover:border-primary/50 transition-all opacity-0 group-hover:opacity-100"
>
Return to Latest
</button>
</Show>
</div>
{/* Change Summary Breakdown for Latest */}
<Show when={appStore.latestSnapshot()?.changeSummary && Object.keys(appStore.latestSnapshot().changeSummary).length > 0}>
<div class="mt-4 pt-4 border-t border-border/40">
<p class="text-[9px] font-black uppercase tracking-widest text-muted-foreground/60 mb-2">Changes in this Version:</p>
<div class="space-y-3">
<For each={Object.values(appStore.latestSnapshot().changeSummary)}>
{(sum: any) => (
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between text-[11px]">
<span class="font-bold text-foreground/80">{sum.scopeName}</span>
<div class="flex gap-2 font-medium">
<Show when={sum.itemsAdded > 0}>
<span class="text-green-600">+{sum.itemsAdded} Add</span>
</Show>
<Show when={sum.itemsRemoved > 0}>
<span class="text-red-600">-{sum.itemsRemoved} Rem</span>
</Show>
<Show when={sum.itemsUpdated > 0}>
<span class="text-blue-600">{sum.itemsUpdated} Upd</span>
</Show>
</div>
</div>
<Show when={sum.fieldChanges > 0}>
<p class="text-[9px] text-muted-foreground ml-1">
{sum.fieldChanges} field adjustments
</p>
</Show>
<Show when={sum.scopeMetadataUpdated}>
<p class="text-[9px] text-blue-600 ml-1 font-bold">
Scope Description/Notes updated
</p>
</Show>
</div>
)}
</For>
</div>
</div>
</Show>
</div>
{/* Historical Snapshots */}
<For each={historyEntries()}>
{(snapshot) => (
<div class="group flex flex-col p-4 rounded-2xl border border-border/60 bg-muted/5 hover:border-primary/30 transition-all">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="p-2 bg-muted text-muted-foreground rounded-lg">
<Clock class="w-4 h-4" />
</div>
<div>
<div class="flex items-center gap-2">
<h4 class="font-bold text-foreground">
{snapshot.jobInfo?.name || snapshot.name || 'Version'}
</h4>
</div>
<p class="text-[10px] text-muted-foreground font-medium uppercase tracking-wider mt-1">
{new Date(snapshot.timestamp || Date.now()).toLocaleString()}
</p>
<Show when={snapshot.savedBy}>
<div class="flex items-center gap-1.5 mt-1.5 grayscale opacity-70">
<User class="w-3 h-3 text-muted-foreground" />
<span class="text-[10px] font-bold text-muted-foreground uppercase tracking-tight">Saved by {snapshot.savedBy}</span>
</div>
</Show>
</div>
</div>
<button
onClick={() => {
props.onCheckout(snapshot);
props.onClose();
}}
class="flex items-center gap-2 px-4 py-2 bg-background border border-border rounded-xl text-xs font-bold shadow-sm hover:text-primary hover:border-primary/50 transition-all opacity-0 group-hover:opacity-100"
>
Checkout
</button>
</div>
{/* Change Summary Breakdown */}
<Show when={snapshot.changeSummary && Object.keys(snapshot.changeSummary).length > 0}>
<div class="mt-4 pt-4 border-t border-border/40">
<p class="text-[9px] font-black uppercase tracking-widest text-muted-foreground/60 mb-2">Scope Changes in this Version:</p>
<div class="space-y-3">
<For each={Object.values(snapshot.changeSummary)}>
{(sum: any) => (
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between text-[11px]">
<span class="font-bold text-foreground/80">{sum.scopeName}</span>
<div class="flex gap-2 font-medium">
<Show when={sum.itemsAdded > 0}>
<span class="text-green-600">+{sum.itemsAdded} Add</span>
</Show>
<Show when={sum.itemsRemoved > 0}>
<span class="text-red-600">-{sum.itemsRemoved} Rem</span>
</Show>
<Show when={sum.itemsUpdated > 0}>
<span class="text-blue-600">{sum.itemsUpdated} Upd</span>
</Show>
</div>
</div>
<Show when={sum.fieldChanges > 0}>
<p class="text-[9px] text-muted-foreground ml-1">
{sum.fieldChanges} field adjustments
</p>
</Show>
<Show when={sum.scopeMetadataUpdated}>
<p class="text-[9px] text-blue-600 ml-1 font-bold">
Scope Description/Notes updated
</p>
</Show>
</div>
)}
</For>
</div>
</div>
</Show>
</div>
)}
</For>
<Show when={historyEntries().length === 0}>
<div class="py-12 text-center text-muted-foreground/60 border-2 border-dashed border-border/60 rounded-xl bg-muted/5 font-medium text-sm">
No previous versions found.
</div>
</Show>
</div>
</div>
</div>
</div>
</Portal>
</Show>
);
};
export default HistoryModal;
@@ -0,0 +1,90 @@
import { Portal } from 'solid-js/web';
import { Show } from 'solid-js';
import { X, FileSpreadsheet, PlusCircle, AlertCircle } from 'lucide-solid';
interface NewEstimateModalProps {
show: boolean;
onClose: () => void;
onResetCSV: () => void;
onManualEstimate: () => void;
}
export const NewEstimateModal = (props: NewEstimateModalProps) => {
return (
<Show when={props.show}>
<Portal>
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div class="bg-card w-full max-w-lg rounded-3xl shadow-premium border border-border overflow-hidden animate-in zoom-in-95 duration-200">
<div class="p-6 border-b border-border/50 flex items-center justify-between bg-muted/30">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-xl text-primary">
<PlusCircle class="w-5 h-5" />
</div>
<div>
<h3 class="text-xl font-black text-foreground">New Estimate</h3>
<p class="text-[10px] text-muted-foreground font-black uppercase tracking-widest mt-0.5">Start a fresh project</p>
</div>
</div>
<button
onClick={props.onClose}
class="p-2 hover:bg-background rounded-xl transition-all group"
>
<X class="w-5 h-5 text-muted-foreground group-hover:text-foreground transition-colors" />
</button>
</div>
<div class="p-8 space-y-6">
<div class="p-4 bg-amber-50 border border-amber-200 rounded-2xl flex gap-3">
<AlertCircle class="w-5 h-5 text-amber-600 shrink-0" />
<div class="text-xs text-amber-900 font-medium">
Starting a new estimate will clear all unsaved progress in the current project. Make sure you've saved any changes to the cloud first.
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<button
onClick={props.onResetCSV}
class="flex flex-col items-center text-center p-6 bg-muted/30 border border-border/60 rounded-3xl hover:border-primary/50 hover:bg-primary/5 transition-all group gap-4"
>
<div class="p-4 bg-background rounded-2xl shadow-sm group-hover:shadow-md transition-all group-hover:scale-110">
<FileSpreadsheet class="w-8 h-8 text-blue-600" />
</div>
<div>
<h4 class="font-black text-foreground mb-1 text-sm">New from CSV</h4>
<p class="text-[10px] text-muted-foreground font-medium leading-relaxed">
Clear current state and upload a new estimate CSV file to begin.
</p>
</div>
</button>
<button
onClick={props.onManualEstimate}
class="flex flex-col items-center text-center p-6 bg-muted/30 border border-border/60 rounded-3xl hover:border-primary/50 hover:bg-primary/5 transition-all group gap-4"
>
<div class="p-4 bg-background rounded-2xl shadow-sm group-hover:shadow-md transition-all group-hover:scale-110">
<PlusCircle class="w-8 h-8 text-purple-600" />
</div>
<div>
<h4 class="font-black text-foreground mb-1 text-sm">Manual Estimate</h4>
<p class="text-[10px] text-muted-foreground font-medium leading-relaxed">
Start with a blank estimate and a single default item.
</p>
</div>
</button>
</div>
</div>
<div class="p-6 bg-muted/30 border-t border-border/50 flex justify-end">
<button
onClick={props.onClose}
class="px-6 py-2.5 rounded-xl font-bold text-sm hover:bg-background border border-transparent hover:border-border transition-all"
>
Cancel
</button>
</div>
</div>
</div>
</Portal>
</Show>
);
};
@@ -1,6 +1,6 @@
import type { Component, Accessor } from 'solid-js';
import { Show, For } from 'solid-js';
import { ListTree } from 'lucide-solid';
import { Show, For, createSignal, createMemo } from 'solid-js';
import { ListTree, Search, X } from 'lucide-solid';
import ItemRow from '../ItemRow';
import LazyItem from '../LazyItem';
import type { EstimateItem } from '../../types';
@@ -20,6 +20,16 @@ interface UnassignedItemsSectionProps {
}
export const UnassignedItemsSection: Component<UnassignedItemsSectionProps> = (props) => {
const [filterText, setFilterText] = createSignal('');
const filteredItems = createMemo(() => {
const text = filterText().toLowerCase().trim();
if (!text) return props.unassignedItems;
return props.unassignedItems.filter(item =>
(item.description || '').toLowerCase().includes(text)
);
});
return (
<Show when={props.unassignedItems.length > 0}>
<div
@@ -39,13 +49,37 @@ export const UnassignedItemsSection: Component<UnassignedItemsSectionProps> = (p
<p class="text-muted-foreground text-sm">Drag these items into scopes to begin your estimate.</p>
</div>
</div>
<span class="px-4 py-1.5 bg-card border border-border text-primary rounded-full text-[10px] font-black uppercase tracking-widest shadow-sm">
{props.unassignedItems.length} Items Pending
</span>
<div class="flex items-center gap-4">
<div class="relative group/search">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground/40 group-focus-within/search:text-primary transition-colors" />
<input
type="text"
value={filterText()}
onInput={(e) => setFilterText(e.currentTarget.value)}
placeholder="Filter unassigned items..."
class="pl-10 pr-10 py-2 bg-card border border-border rounded-xl text-sm focus:border-primary focus:ring-4 focus:ring-primary/10 outline-none transition-all w-64 shadow-sm"
/>
<Show when={filterText()}>
<button
onClick={() => setFilterText('')}
class="absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-muted rounded-lg transition-all text-muted-foreground/40 hover:text-foreground"
>
<X class="w-3 h-3" />
</button>
</Show>
</div>
<span class="px-4 py-1.5 bg-card border border-border text-primary rounded-full text-[10px] font-black uppercase tracking-widest shadow-sm">
{filteredItems().length} of {props.unassignedItems.length} Items
</span>
</div>
</div>
<div class="relative pb-4">
{/* Sticky Column Headers for Unassigned */}
<div class="sticky top-header z-10 bg-background/95 backdrop-blur-sm border-b border-border py-3 mb-4 hidden md:flex items-center gap-4 text-[10px] font-black text-muted-foreground/60 uppercase tracking-[0.2em] px-3">
<div
onClick={(e) => { if (e.target === e.currentTarget) props.clearSelection(e); }}
class="relative overflow-x-auto min-w-0 px-6"
>
<div class="min-w-full md:min-w-max flex flex-col items-stretch pb-4">
{/* Sticky Column Headers for Unassigned */}
<div class="sticky top-0 z-10 bg-background/95 backdrop-blur-sm border-b border-border py-3 mb-4 hidden md:flex items-center gap-4 text-[10px] font-black text-muted-foreground/60 uppercase tracking-[0.2em] px-3">
<div class="w-4 shrink-0"></div>
<div class="flex-1 min-w-[200px]">Description</div>
<div class="w-20 shrink-0 text-right">Qty</div>
@@ -59,28 +93,29 @@ export const UnassignedItemsSection: Component<UnassignedItemsSectionProps> = (p
<div class="w-16 shrink-0"></div>
</div>
<div class="grid gap-2">
<For each={props.unassignedItems}>
{(item) => (
<LazyItem estimatedHeight={60}>
<ItemRow
item={item}
onUpdate={props.updateItem}
onDelete={props.deleteItem}
onDragStart={props.handleDragStart}
onDuplicateItem={props.duplicateItem}
isSidebarCollapsed={props.isSidebarCollapsed}
isSelected={props.selectedIds.includes(item.id)}
anySelected={props.selectedIds.length > 0}
onToggleSelection={(id, isMulti, isShift) => props.toggleSelection(id, isMulti, isShift)}
hideColumns={props.anyDescriptionIsLong()}
/>
</LazyItem>
)}
</For>
<div class="grid gap-2">
<For each={filteredItems()}>
{(item) => (
<LazyItem estimatedHeight={60}>
<ItemRow
item={item}
onUpdate={props.updateItem}
onDelete={props.deleteItem}
onDragStart={props.handleDragStart}
onDuplicateItem={props.duplicateItem}
isSidebarCollapsed={props.isSidebarCollapsed}
isSelected={props.selectedIds.includes(item.id)}
anySelected={props.selectedIds.length > 0}
onToggleSelection={(id, isMulti, isShift) => props.toggleSelection(id, isMulti, isShift)}
hideColumns={props.anyDescriptionIsLong()}
/>
</LazyItem>
)}
</For>
</div>
</div>
</div>
</div>
</div>
</Show>
);
};
@@ -1,6 +1,8 @@
import { createMemo } from 'solid-js';
import type { Accessor } from 'solid-js';
import { appStore } from '../../store/appStore';
import { ModifierRegistry } from '../../utils/modifier-registry';
import { getEffectiveItemPricing, getItemsPricingTotals } from '../../utils/pricing';
export function useEstimateCalculations(selectedIds: Accessor<string[]>) {
const items = appStore.estimateItems;
@@ -14,15 +16,17 @@ export function useEstimateCalculations(selectedIds: Accessor<string[]>) {
let contingency = 0;
let fees = 0;
const scopeSubtotals = new Map<string, number>();
const subScopes = appStore.subScopes;
const subScopeById = new Map(subScopes.map(subScope => [subScope.id, subScope]));
items.forEach(item => {
const base = item.quantity * item.unitPrice;
subtotal += base;
markup += item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
contingency += item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
const pricing = getEffectiveItemPricing(item, item.subScopeId ? (subScopeById.get(item.subScopeId)?.modifiers || []) : []);
subtotal += pricing.effectiveSubtotal;
markup += pricing.markupAmount;
contingency += pricing.contingencyAmount;
if (item.scopeId) {
scopeSubtotals.set(item.scopeId, (scopeSubtotals.get(item.scopeId) || 0) + base);
scopeSubtotals.set(item.scopeId, (scopeSubtotals.get(item.scopeId) || 0) + pricing.effectiveSubtotal);
}
});
@@ -38,12 +42,25 @@ export function useEstimateCalculations(selectedIds: Accessor<string[]>) {
: scope.deliveryFee;
});
let modifiersTotal = 0;
subScopes.forEach(subScope => {
const subScopeItems = items.filter(i => i.subScopeId === subScope.id);
const subScopeTotalWithMarkup = getItemsPricingTotals(subScopeItems, subScope.modifiers || []).total;
(subScope.modifiers || []).forEach(m => {
if (!m.includeInTotal) return;
modifiersTotal += ModifierRegistry.calculate(m, subScopeTotalWithMarkup, subScopeItems);
});
});
return {
subtotal,
markup,
contingency,
fees,
grandTotal: subtotal + markup + contingency + fees
modifiersTotal,
grandTotal: subtotal + markup + contingency + fees + modifiersTotal
};
});
@@ -65,19 +82,18 @@ export function useEstimateCalculations(selectedIds: Accessor<string[]>) {
const selectionTotals = createMemo(() => {
const selected = items.filter(i => selectedIds().includes(i.id));
const subScopeById = new Map(appStore.subScopes.map(subScope => [subScope.id, subScope]));
let qty = 0, price = 0, sub = 0, mark = 0, cont = 0, tot = 0;
selected.forEach(item => {
const base = item.quantity * item.unitPrice;
const markupAmt = item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
const contingencyAmt = item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
const pricing = getEffectiveItemPricing(item, item.subScopeId ? (subScopeById.get(item.subScopeId)?.modifiers || []) : []);
qty += item.quantity;
price += item.unitPrice;
sub += base;
mark += markupAmt;
cont += contingencyAmt;
tot += base + markupAmt + contingencyAmt;
qty += pricing.effectiveQuantity;
price += pricing.effectiveUnitPrice;
sub += pricing.effectiveSubtotal;
mark += pricing.markupAmount;
cont += pricing.contingencyAmount;
tot += pricing.total;
});
return {
@@ -1,13 +1,26 @@
import { createSignal } from 'solid-js';
import { appStore } from '../../store/appStore';
import { pb, COLLECTIONS } from '../../utils/db';
import { normalizeScopes } from '../../utils/scope-utils';
export function useEstimateSync(
setShowSaveModal: (show: boolean) => void
) {
const [isCloudLoading, setIsCloudLoading] = createSignal(false);
const { scopes, setScopes, subScopes, setSubScopes, estimateItems: items, setEstimateItems: setItems, clientInfo, setClientInfo, jobInfo, setJobInfo, generalEstimateNotes, setGeneralEstimateNotes, generalPreNotes, setGeneralPreNotes } = appStore;
const {
scopes, setScopes,
subScopes, setSubScopes,
estimateItems: items, setEstimateItems: setItems,
clientInfo, setClientInfo,
jobInfo, setJobInfo,
generalEstimateNotes, setGeneralEstimateNotes,
generalPreNotes, setGeneralPreNotes,
setHistory,
setIsLatestVersion,
setLatestSnapshot,
setEstimateId, setEstimateName
} = appStore;
const exportEstimate = () => {
const data = {
@@ -18,7 +31,7 @@ export function useEstimateSync(
jobInfo: { ...jobInfo },
generalEstimateNotes: generalEstimateNotes(),
generalPreNotes: generalPreNotes(),
version: '1.0'
version: '1.2'
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
@@ -37,7 +50,7 @@ export function useEstimateSync(
reader.onload = (event) => {
try {
const data = JSON.parse(event.target?.result as string);
if (data.scopes) setScopes(data.scopes);
if (data.scopes) setScopes(normalizeScopes(data.scopes));
if (data.subScopes) setSubScopes(data.subScopes);
if (data.items) setItems(data.items);
if (data.clientInfo) setClientInfo(data.clientInfo);
@@ -60,28 +73,179 @@ export function useEstimateSync(
clientInfo: { ...clientInfo },
jobInfo: { ...jobInfo },
generalEstimateNotes: generalEstimateNotes(),
generalPreNotes: generalPreNotes()
generalPreNotes: generalPreNotes(),
timestamp: new Date().toISOString(),
savedBy: pb.authStore.model?.name || pb.authStore.model?.email || 'Unknown'
};
};
const calculateChangeSummary = (prevData: any, newData: any) => {
const summary: Record<string, { scopeName: string, itemsAdded: number, itemsRemoved: number, itemsUpdated: number, fieldChanges: number, scopeMetadataUpdated: boolean }> = {};
const prevItems = prevData.items || [];
const newItems = newData.items || [];
const allScopeIds = new Set([
...prevItems.map((i: any) => i.scopeId),
...newItems.map((i: any) => i.scopeId)
]);
const scopesMap = new Map((newData.scopes || []).map((s: any) => [s.id, s.name]));
allScopeIds.forEach(rawId => {
const scopeId = String(rawId);
if (!scopeId || scopeId === 'undefined') return;
const scopeName = scopesMap.get(scopeId) || "Unknown Scope";
const scopePrevItems = prevItems.filter((i: any) => i.scopeId === scopeId);
const scopeNewItems = newItems.filter((i: any) => i.scopeId === scopeId);
let added = 0;
let removed = 0;
let updated = 0;
let fields = 0;
let scopeMetadataUpdated = false;
// Check Scope Metadata Changes
const prevScope = (prevData.scopes || []).find((s: any) => s.id === scopeId);
const currentScope = (newData.scopes || []).find((s: any) => s.id === scopeId);
if (prevScope && currentScope) {
['bidDescription', 'estimatorNotes', 'name', 'managementFee', 'deliveryFee', 'useManagementLogic', 'useDeliveryLogic'].forEach(k => {
if (String(currentScope[k]) !== String(prevScope[k])) {
fields++;
scopeMetadataUpdated = true;
}
});
}
const scopePrevMap = new Map(scopePrevItems.map((i: any) => [i.id, i]));
const scopeNewMap = new Map(scopeNewItems.map((i: any) => [i.id, i]));
scopeNewItems.forEach((item: any) => {
const prev = scopePrevMap.get(item.id);
if (!prev) {
added++;
} else {
let changed = false;
['description', 'quantity', 'unitPrice', 'markup', 'markupType', 'contingency', 'contingencyType'].forEach(k => {
const v1 = (item as any)[k];
const v2 = (prev as any)[k];
if (String(v1) !== String(v2)) {
fields++;
changed = true;
}
});
if (changed) updated++;
}
});
scopePrevItems.forEach((item: any) => {
if (!scopeNewMap.has(item.id)) {
removed++;
}
});
if (added > 0 || removed > 0 || updated > 0 || fields > 0 || scopeMetadataUpdated) {
summary[scopeId] = {
scopeName: String(scopeName),
itemsAdded: added,
itemsRemoved: removed,
itemsUpdated: updated,
fieldChanges: fields,
scopeMetadataUpdated
};
}
});
return summary;
};
const saveToCloud = async (name: string) => {
setIsCloudLoading(true);
try {
const data = getFullEstimateData();
await pb.collection(COLLECTIONS.ESTIMATES).create({
const record = await pb.collection(COLLECTIONS.ESTIMATES).create({
name,
items: data
items: { ...data, changeSummary: null },
history: [],
userId: pb.authStore.model?.id
});
setShowSaveModal(false);
appStore.setEstimateName(name);
alert('Estimate saved to PocketBase successfully!');
setEstimateName(name);
setEstimateId(record.id);
setHistory([]);
setLatestSnapshot({ ...data, changeSummary: null });
setIsLatestVersion(true);
alert('New estimate saved successfully!');
} catch (err) {
console.error(err);
alert('Failed to save. Ensure EstiMaker_Estimates collection permissions are public.');
alert('Failed to save. Ensure collection permissions are public.');
} finally {
setIsCloudLoading(false);
}
};
return { exportEstimate, importEstimate, saveToCloud, isCloudLoading };
const saveChangesToCloud = async () => {
const id = appStore.estimateId();
if (!id) return;
setIsCloudLoading(true);
try {
const currentData = getFullEstimateData();
const record = await pb.collection(COLLECTIONS.ESTIMATES).getOne(id);
const updatedHistory = [...(record.history || [])];
const changeSummary = calculateChangeSummary(record.items, currentData);
const newTip = {
...currentData,
changeSummary
};
await pb.collection(COLLECTIONS.ESTIMATES).update(id, {
items: newTip,
history: [...updatedHistory, record.items],
userId: pb.authStore.model?.id
});
setHistory([...updatedHistory, record.items]);
setLatestSnapshot(newTip);
setIsLatestVersion(true);
alert('Changes saved successfully!');
} catch (err) {
console.error(err);
alert('Failed to save changes.');
} finally {
setIsCloudLoading(false);
}
};
const saveAsNewestToCloud = async () => {
await saveChangesToCloud();
};
const checkoutVersion = async (snapshot: any) => {
// snapshot is an entry from the history array
if (!snapshot) return;
if (snapshot.scopes) setScopes(normalizeScopes(snapshot.scopes));
if (snapshot.subScopes) setSubScopes(snapshot.subScopes);
if (snapshot.items) setItems(snapshot.items);
if (snapshot.clientInfo) setClientInfo(snapshot.clientInfo);
if (snapshot.jobInfo) setJobInfo(snapshot.jobInfo);
if (snapshot.generalEstimateNotes !== undefined) setGeneralEstimateNotes(snapshot.generalEstimateNotes);
if (snapshot.generalPreNotes !== undefined) setGeneralPreNotes(snapshot.generalPreNotes);
// This checkout handles both historical items and the 'latestSnapshot'
// If we are checking out the latest snapshot, then isLatestVersion is true
const isTip = JSON.stringify(snapshot) === JSON.stringify(appStore.latestSnapshot());
setIsLatestVersion(isTip);
// Clear CSV items to avoid confusion
appStore.setItems([]);
};
return { exportEstimate, importEstimate, saveToCloud, saveChangesToCloud, saveAsNewestToCloud, checkoutVersion, isCloudLoading };
}
+111 -81
View File
@@ -30,94 +30,124 @@ const TemplateItemRow: Component<TemplateItemRowProps> = (props) => {
};
return (
<div class={`group flex items-start gap-4 p-3 border transition-all animate-in fade-in slide-in-from-left-2 rounded-2xl bg-card border-border hover:shadow-premium-hover hover:border-border/80 ${isEditing() ? 'ring-1 ring-primary/20 shadow-premium border-primary/30' : ''}`}>
<div class={`group flex flex-col md:flex-row items-stretch md:items-start gap-3 md:gap-4 p-3 border transition-all animate-in fade-in slide-in-from-left-2 rounded-2xl bg-card border-border hover:shadow-premium-hover hover:border-border/80 ${isEditing() ? 'ring-1 ring-primary/20 shadow-premium border-primary/30' : ''}`}>
<div class="cursor-grab transition-colors shrink-0 w-4 mt-2 p-1 -m-1 rounded hover:bg-muted text-muted-foreground/30 hover:text-muted-foreground/60" title="Drag to reorder (Coming soon)">
<GripVertical class="w-4 h-4" />
</div>
<div class="flex items-start gap-3">
<div class="cursor-grab transition-colors shrink-0 w-4 mt-2 p-1 rounded hover:bg-muted text-muted-foreground/30 hover:text-muted-foreground/60" title="Drag to reorder (Coming soon)">
<GripVertical class="w-4 h-4" />
</div>
<div class="flex-1 relative">
<textarea
ref={descriptionRef}
value={localDescription()}
onFocus={() => setIsEditing(true)}
onBlur={handleBlur}
onInput={(e) => {
setLocalDescription(e.currentTarget.value);
if (!('fieldSizing' in document.documentElement.style)) {
e.currentTarget.style.height = 'auto';
e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
}
}}
rows="1"
class="w-full bg-transparent border-b border-transparent hover:border-border/40 focus:border-primary outline-none py-1 transition-all resize-none block overflow-hidden align-top leading-normal text-base font-medium text-foreground/90"
style={{ "field-sizing": "content", "min-height": "1.5em" }}
placeholder="Item description..."
/>
</div>
<div class="w-24 shrink-0 mt-1">
<NumericInput
value={props.item.quantity}
onUpdate={(val) => props.onUpdate(props.item.id, { quantity: val })}
class="w-full bg-muted/30 border border-border/60 rounded-xl px-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-bold text-foreground/80 transition-all"
placeholder="Qty"
/>
</div>
<div class="w-32 shrink-0 mt-1">
<div class="relative">
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 text-[10px]">$</span>
<NumericInput
step="0.1"
value={props.item.unitPrice}
onUpdate={(val) => props.onUpdate(props.item.id, { unitPrice: val })}
class="w-full bg-muted/30 border border-border/60 rounded-xl pl-5 pr-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-bold text-foreground/80 transition-all"
placeholder="Price"
<div class="flex-1 relative min-w-[200px]">
<textarea
ref={descriptionRef}
value={localDescription()}
onFocus={() => setIsEditing(true)}
onBlur={handleBlur}
onInput={(e) => {
setLocalDescription(e.currentTarget.value);
if (!('fieldSizing' in document.documentElement.style)) {
e.currentTarget.style.height = 'auto';
e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
}
}}
rows="1"
class="w-full bg-transparent border-b border-transparent hover:border-border/40 focus:border-primary outline-none py-1 transition-all resize-none block overflow-hidden align-top leading-normal text-base font-medium text-foreground/90"
style={{ "field-sizing": "content", "min-height": "1.5em" }}
placeholder="Item description..."
/>
</div>
{/* Mobile Actions Container */}
<div class="flex md:hidden items-center gap-1.5 mt-1.5 px-1">
<button
onClick={() => props.onDelete(props.item.id)}
class="text-muted-foreground/40 hover:text-destructive transition-colors"
title="Delete Item"
>
<div class="p-1 px-1.5 bg-muted rounded-lg border border-border/40 hover:bg-destructive/10">
<Trash2 class="w-3 h-3" />
</div>
</button>
</div>
</div>
<div class="w-28 shrink-0 flex items-center gap-1.5 mt-1">
<NumericInput
value={props.item.markup}
onUpdate={(val) => props.onUpdate(props.item.id, { markup: val })}
class="w-full bg-muted/30 border border-border/60 rounded-xl px-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-bold text-primary transition-all"
placeholder="Markup"
/>
<button
onClick={() => props.onUpdate(props.item.id, { markupType: props.item.markupType === 'percent' ? 'amount' : 'percent' })}
class="text-[9px] font-black px-1.5 py-1.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary/10 hover:text-primary min-w-[24px] transition-colors border border-border/40"
>
{props.item.markupType === 'percent' ? '%' : '$'}
</button>
</div>
<div class="w-28 shrink-0 flex items-center gap-1.5 mt-1">
<NumericInput
value={props.item.contingency}
onUpdate={(val) => props.onUpdate(props.item.id, { contingency: val })}
class="w-full bg-muted/30 border border-border/60 rounded-xl px-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-bold text-foreground/80 transition-all"
placeholder="Cont"
/>
<button
onClick={() => props.onUpdate(props.item.id, { contingencyType: props.item.contingencyType === 'percent' ? 'amount' : 'percent' })}
class="text-[9px] font-black px-1.5 py-1.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary/10 hover:text-primary min-w-[24px] transition-colors border border-border/40"
>
{props.item.contingencyType === 'percent' ? '%' : '$'}
</button>
</div>
<div class="w-10 shrink-0 flex items-center justify-end gap-1.5 mt-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => props.onDelete(props.item.id)}
class="text-muted-foreground/40 hover:text-destructive transition-colors"
title="Delete Item"
>
<div class="p-1 px-1.5 bg-muted rounded-lg border border-border/40 hover:bg-destructive/10">
<Trash2 class="w-3 h-3" />
{/* Numeric Fields Group */}
<div class="flex flex-wrap md:flex-nowrap items-center gap-3 md:gap-4 pl-7 md:pl-0">
<div class="flex items-center gap-2">
<div class="w-24 shrink-0">
<div class="md:hidden text-[9px] font-bold text-muted-foreground/60 uppercase mb-1 ml-1">Qty</div>
<NumericInput
value={props.item.quantity}
onUpdate={(val) => props.onUpdate(props.item.id, { quantity: val })}
class="w-full bg-muted/30 border border-border/60 rounded-xl px-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-bold text-foreground/80 transition-all"
placeholder="Qty"
/>
</div>
</button>
<div class="w-32 shrink-0">
<div class="md:hidden text-[9px] font-bold text-muted-foreground/60 uppercase mb-1 ml-1">Price</div>
<div class="relative">
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 text-[10px]">$</span>
<NumericInput
step="0.1"
value={props.item.unitPrice}
onUpdate={(val) => props.onUpdate(props.item.id, { unitPrice: val })}
class="w-full bg-muted/30 border border-border/60 rounded-xl pl-5 pr-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-bold text-foreground/80 transition-all"
placeholder="Price"
/>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<div class="w-28 shrink-0">
<div class="md:hidden text-[9px] font-bold text-muted-foreground/60 uppercase mb-1 ml-1">Markup</div>
<div class="flex items-center gap-1.5">
<NumericInput
value={props.item.markup}
onUpdate={(val) => props.onUpdate(props.item.id, { markup: val })}
class="w-full bg-muted/30 border border-border/60 rounded-xl px-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-bold text-primary transition-all"
placeholder="Markup"
/>
<button
onClick={() => props.onUpdate(props.item.id, { markupType: props.item.markupType === 'percent' ? 'amount' : 'percent' })}
class="text-[9px] font-black px-1.5 py-1.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary/10 hover:text-primary min-w-[24px] transition-colors border border-border/40"
>
{props.item.markupType === 'percent' ? '%' : '$'}
</button>
</div>
</div>
<div class="w-28 shrink-0">
<div class="md:hidden text-[9px] font-bold text-muted-foreground/60 uppercase mb-1 ml-1">Cont</div>
<div class="flex items-center gap-1.5">
<NumericInput
value={props.item.contingency}
onUpdate={(val) => props.onUpdate(props.item.id, { contingency: val })}
class="w-full bg-muted/30 border border-border/60 rounded-xl px-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-bold text-foreground/80 transition-all"
placeholder="Cont"
/>
<button
onClick={() => props.onUpdate(props.item.id, { contingencyType: props.item.contingencyType === 'percent' ? 'amount' : 'percent' })}
class="text-[9px] font-black px-1.5 py-1.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary/10 hover:text-primary min-w-[24px] transition-colors border border-border/40"
>
{props.item.contingencyType === 'percent' ? '%' : '$'}
</button>
</div>
</div>
</div>
<div class="hidden md:flex w-10 shrink-0 items-center justify-end gap-1.5 mt-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => props.onDelete(props.item.id)}
class="text-muted-foreground/40 hover:text-destructive transition-colors"
title="Delete Item"
>
<div class="p-1 px-1.5 bg-muted rounded-lg border border-border/40 hover:bg-destructive/10">
<Trash2 class="w-3 h-3" />
</div>
</button>
</div>
</div>
</div>
);
+25 -3
View File
@@ -6,6 +6,10 @@ interface NumericInputProps extends Omit<JSX.InputHTMLAttributes<HTMLInputElemen
onUpdate: (val: number) => void;
dataFieldName?: string;
class?: string;
allowTextSearch?: boolean;
onTextSearch?: (searchString: string) => void;
onTextSearchExit?: () => void;
inputRef?: any;
}
const NumericInput: Component<NumericInputProps> = (props) => {
@@ -13,7 +17,7 @@ const NumericInput: Component<NumericInputProps> = (props) => {
const [localValue, setLocalValue] = createSignal(props.value.toString());
createEffect(() => {
if (!isFocused()) {
if (!isFocused() || (props.allowTextSearch && !localValue().startsWith('@') && props.value !== parseFloat(localValue()))) {
setLocalValue(props.value.toString());
}
});
@@ -21,6 +25,15 @@ const NumericInput: Component<NumericInputProps> = (props) => {
const handleInput = (e: InputEvent & { currentTarget: HTMLInputElement }) => {
let val = e.currentTarget.value;
// Check if we allow text search and it starts with '@'
if (props.allowTextSearch && val.startsWith('@')) {
setLocalValue(val);
if (props.onTextSearch) {
props.onTextSearch(val.substring(1));
}
return;
}
// Allow only numeric characters, one dot, and one leading minus sign
// This regex allows: "", "-", ".", "-.", and valid numbers
if (val !== '' && !/^-?(\d*\.?\d*)?$/.test(val)) {
@@ -29,6 +42,11 @@ const NumericInput: Component<NumericInputProps> = (props) => {
return;
}
// If we were in text search but cleared it back to a number
if (props.allowTextSearch && localValue().startsWith('@') && !val.startsWith('@')) {
if (props.onTextSearchExit) props.onTextSearchExit();
}
setLocalValue(val);
// Only update the store if it's a valid numeric value for calculations
@@ -59,14 +77,18 @@ const NumericInput: Component<NumericInputProps> = (props) => {
const handleBlur = (e: FocusEvent) => {
setIsFocused(false);
// On blur, normalize the local value to the props value
// But if the user left it empty or as a partial, props.value will be 0
// On blur, always normalize the local value to the props value
setLocalValue(props.value.toString());
if (props.allowTextSearch && localValue().startsWith('@') && props.onTextSearchExit) {
props.onTextSearchExit();
}
callHandler(props.onBlur, e);
};
return (
<input
ref={props.inputRef}
{...props}
type="text"
inputmode="decimal"
+104
View File
@@ -0,0 +1,104 @@
/**
* ScopeScaleWrapper
*
* Wraps a scope's items/header block so that on narrow screens the
* whole thing scales down as a unit rather than overflowing.
*
* Critical CSS behaviour used here:
*
* overflow: clip
* Clips the inner element at the outer's edges — identical to
* `overflow: hidden` visually, BUT does NOT create a scroll
* container. This matters because `position: sticky` elements
* inside reference the nearest scroll container ancestor. With
* `overflow: hidden` that would be this element (which doesn't
* scroll → sticky breaks). With `overflow: clip` there is no
* new scroll container → sticky still references the page.
*
* transform: scale() + transform-origin: top left
* Visually shrinks the inner block. Layout size is unaffected
* (transform never changes layout). We compensate by setting
* the outer div's explicit height = naturalHeight × scale, and
* clipping with overflow: clip.
*/
import type { Component, JSX } from 'solid-js';
import { onMount, onCleanup } from 'solid-js';
interface ScopeScaleWrapperProps {
children: JSX.Element;
class?: string;
/** Minimum scale factor — won't zoom below this. Default: 0.65 */
minScale?: number;
}
const ScopeScaleWrapper: Component<ScopeScaleWrapperProps> = (props) => {
let outerEl: HTMLDivElement | undefined;
let innerEl: HTMLDivElement | undefined;
const recalculate = () => {
if (!outerEl || !innerEl) return;
const minScale = props.minScale ?? 0.65;
// ── Step 1: measure the inner's TRUE natural width ───────────────
// Must set max-content BEFORE measuring — if width is unset the
// element stretches to fill the parent, making scrollWidth == outerWidth
// and we'd never think scaling is needed.
innerEl.style.width = 'max-content';
innerEl.style.transform = 'none';
outerEl.style.height = '';
outerEl.style.overflow = '';
void outerEl.offsetWidth; // force reflow
const naturalWidth = innerEl.scrollWidth;
const naturalHeight = innerEl.scrollHeight;
const outerWidth = outerEl.clientWidth;
if (naturalWidth <= outerWidth) {
// ── Fits naturally — fill available width, no clip/scale ──────
innerEl.style.width = '100%';
innerEl.style.transform = '';
outerEl.style.height = '';
outerEl.style.overflow = '';
} else {
// ── Too wide — scale down ─────────────────────────────────────
const rawScale = outerWidth / naturalWidth;
const newScale = Math.max(minScale, rawScale);
// Fix inner to its natural pixel width so transform-origin math
// is reliable, then scale from the top-left corner
innerEl.style.width = `${naturalWidth}px`;
innerEl.style.transformOrigin = 'top left';
innerEl.style.transform = `scale(${newScale})`;
// Set outer height to visual (post-scale) height so no dead space
// appears below the scaled block
outerEl.style.height = `${naturalHeight * newScale}px`;
// overflow: clip — clips layout overflow WITHOUT creating a scroll
// container, so position:sticky inside still works against the page
outerEl.style.overflow = 'clip';
}
};
let ro: ResizeObserver;
onMount(() => {
ro = new ResizeObserver(recalculate);
if (outerEl) ro.observe(outerEl);
if (innerEl) ro.observe(innerEl);
recalculate();
});
onCleanup(() => ro?.disconnect());
return (
<div ref={outerEl} class={`relative ${props.class ?? ''}`}>
<div ref={innerEl}>
{props.children}
</div>
</div>
);
};
export default ScopeScaleWrapper;
+90
View File
@@ -0,0 +1,90 @@
import { createSignal, Show, For, onMount, onCleanup } from 'solid-js';
import { Portal } from 'solid-js/web';
import { ChevronDown, Check } from 'lucide-solid';
interface SupplierDropdownProps {
value: string;
options: string[];
onSelect: (value: string) => void;
placeholder?: string;
}
export const SupplierDropdown = (props: SupplierDropdownProps) => {
const [isOpen, setIsOpen] = createSignal(false);
let containerRef: HTMLDivElement | undefined;
let buttonRef: HTMLButtonElement | undefined;
const handleClickOutside = (e: MouseEvent) => {
if (containerRef && !containerRef.contains(e.target as Node) &&
buttonRef && !buttonRef.contains(e.target as Node)) {
setIsOpen(false);
}
};
onMount(() => {
document.addEventListener('click', handleClickOutside);
});
onCleanup(() => {
document.removeEventListener('click', handleClickOutside);
});
const getRect = () => buttonRef?.getBoundingClientRect();
return (
<div class="relative inline-block">
<button
ref={buttonRef}
onClick={() => setIsOpen(!isOpen())}
class="flex items-center gap-1.5 group/supplier bg-zinc-100/50 hover:bg-zinc-100 border border-zinc-200/50 rounded-full px-3 py-1 transition-all duration-200 focus:outline-none focus:ring-4 focus:ring-primary/10 focus:border-primary/30 active:scale-95"
>
<span class="text-[9px] font-black uppercase tracking-widest text-muted-foreground/60 select-none">Supplier</span>
<span class="text-[9px] font-black uppercase tracking-widest text-foreground min-w-[30px] text-left">
{props.value || props.placeholder || 'Any'}
</span>
<ChevronDown
class={`w-2.5 h-2.5 text-muted-foreground transition-transform duration-300 ${isOpen() ? 'rotate-180' : 'group-hover/supplier:translate-y-0.5'}`}
/>
</button>
<Show when={isOpen()}>
<Portal>
<div
ref={containerRef}
class="fixed z-[1000] min-w-[160px] bg-white/90 backdrop-blur-xl border border-white/20 rounded-2xl shadow-2xl shadow-zinc-900/10 overflow-hidden animate-in fade-in zoom-in-95 duration-200 origin-top"
style={{
top: `${(getRect()?.bottom || 0) + 8}px`,
left: `${getRect()?.left || 0}px`
}}
>
<div class="p-1.5 flex flex-col gap-0.5">
<button
onClick={() => { props.onSelect(''); setIsOpen(false); }}
class={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all ${!props.value ? 'bg-primary text-primary-foreground shadow-lg shadow-primary/20' : 'hover:bg-zinc-100 text-muted-foreground/60'}`}
>
<span>Any</span>
<Show when={!props.value}>
<Check class="w-3 h-3" />
</Show>
</button>
<div class="h-px bg-zinc-100 my-0.5 mx-1"></div>
<For each={props.options}>
{(option) => (
<button
onClick={() => { props.onSelect(option); setIsOpen(false); }}
class={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all ${props.value === option ? 'bg-primary text-primary-foreground shadow-lg shadow-primary/20' : 'hover:bg-zinc-100 text-foreground'}`}
>
<span>{option}</span>
<Show when={props.value === option}>
<Check class="w-3 h-3" />
</Show>
</button>
)}
</For>
</div>
</div>
</Portal>
</Show>
</div>
);
};
+27 -3
View File
@@ -2,8 +2,6 @@
@plugin "@tailwindcss/typography";
/* Import Standard Shadcn Font (Inter) */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap');
@theme {
/* Clean, single-font family for a modern SaaS look */
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
@@ -104,4 +102,30 @@
.print-only {
@apply hidden;
}
}
}
/* =========================================================
Scope Sticky Header
A shared class for scope and sub-scope column headers.
position:sticky works inside the ScopeScaleWrapper because
the outer container has overflow-y:visible.
========================================================= */
.scope-sticky-header {
position: sticky;
top: 0;
z-index: 20;
background: hsl(0 0% 100% / 0.97);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
border-bottom: 1px solid hsl(240 5.9% 90%);
}
/* shadow-premium custom utility */
.shadow-premium {
box-shadow: var(--shadow-premium);
}
.shadow-premium-hover:hover {
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.07), 0 8px 10px -6px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(0, 0, 0, 0.04);
}
+27 -2
View File
@@ -1,9 +1,13 @@
import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import { Router, Route, useNavigate, useLocation } from '@solidjs/router';
import { createEffect, Show } from 'solid-js';
import './index.css';
import AppContainer from './AppContainer';
import ItemExtractor from './ItemExtractor';
import TemplateCreator from './views/TemplateCreator';
import StandardPricing from './views/StandardPricing';
import Login from './views/Login';
import { authStore } from './store/authStore';
const root = document.getElementById('root');
@@ -13,9 +17,30 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
);
}
function AuthGuard(props: { children: any }) {
const navigate = useNavigate();
const location = useLocation();
createEffect(() => {
if (!authStore.isAuthenticated() && location.pathname !== '/login') {
navigate('/login', { replace: true });
} else if (authStore.isAuthenticated() && location.pathname === '/login') {
navigate('/', { replace: true });
}
});
return (
<Show when={authStore.isAuthenticated() || location.pathname === '/login'}>
{props.children}
</Show>
);
}
render(() => (
<Router root={AppContainer}>
<Router root={(props) => <AuthGuard><AppContainer>{props.children}</AppContainer></AuthGuard>}>
<Route path="/login" component={Login} />
<Route path="/" component={ItemExtractor as any} />
<Route path="/templates" component={TemplateCreator} />
<Route path="/standard-pricing" component={StandardPricing} />
</Router>
), root!);
+125 -3
View File
@@ -1,15 +1,20 @@
import { createSignal } from 'solid-js';
import { createStore } from 'solid-js/store';
import { createStore, produce } from 'solid-js/store';
import type { ExtractedItem } from '../utils/csv-parser';
import type { TemplateItem, Scope, SubScope, EstimateItem, JobInfo } from '../types';
import type { TemplateItem, Scope, SubScope, EstimateItem, JobInfo, Modifier } from '../types';
import { ModifierRegistry } from '../utils/modifier-registry';
// Raw CSV Data
const [activeItems, setActiveItems] = createSignal<ExtractedItem[]>([]);
const [activeIgnoredLines, setActiveIgnoredLines] = createSignal<string[][]>([]);
const [suppliers, setSuppliers] = createSignal<string[]>([]);
// Estimate Metadata
const [activeEstimateId, setActiveEstimateId] = createSignal<string | null>(null);
const [activeEstimateName, setActiveEstimateName] = createSignal('');
const [activeHistory, setActiveHistory] = createSignal<any[]>([]);
const [activeLatestSnapshot, setActiveLatestSnapshot] = createSignal<any | null>(null);
const [isLatestVersion, setIsLatestVersion] = createSignal(true);
// Estimate Builder State
const [scopes, setScopes] = createStore<Scope[]>([]);
@@ -36,8 +41,12 @@ const [generalPreNotes, setGeneralPreNotes] = createSignal('');
// Template Creator State
const [activeTemplateName, setActiveTemplateName] = createSignal('');
const [activeTemplateItems, setActiveTemplateItems] = createStore<TemplateItem[]>([]);
const [activeTemplateModifiers, setActiveTemplateModifiers] = createStore<Modifier[]>([]);
const [activeTemplateSubScopeName, setActiveTemplateSubScopeName] = createSignal<string | undefined>(undefined);
const [activeTemplateId, setActiveTemplateId] = createSignal<string | null>(null);
import { pb, COLLECTIONS } from '../utils/db';
export const appStore = {
// Raw CSV Extraction
items: activeItems,
@@ -50,6 +59,12 @@ export const appStore = {
setEstimateId: setActiveEstimateId,
estimateName: activeEstimateName,
setEstimateName: setActiveEstimateName,
history: activeHistory,
setHistory: setActiveHistory,
latestSnapshot: activeLatestSnapshot,
setLatestSnapshot: setActiveLatestSnapshot,
isLatestVersion: isLatestVersion,
setIsLatestVersion: setIsLatestVersion,
// Estimate Builder
scopes, setScopes,
@@ -59,6 +74,44 @@ export const appStore = {
jobInfo, setJobInfo,
generalEstimateNotes, setGeneralEstimateNotes,
generalPreNotes, setGeneralPreNotes,
// Scopes
addModifier(subScopeId: string, moduleId: string) {
const module = ModifierRegistry.getModule(moduleId);
const newModifier: Modifier = {
id: Math.random().toString(36).substr(2, 9),
moduleId: module.id,
name: module.defaultLabel,
value: module.defaultValue,
valueType: module.defaultValueType,
unitLabel: module.defaultUnitLabel,
type: module.id as Modifier['type'],
includeInTotal: module.id !== 'man-hours',
presetId: module.defaultPresetId,
targetField: module.defaultTargetField,
expression: module.defaultExpression,
parameters: module.defaultParameters ? { ...module.defaultParameters } : undefined
};
setSubScopes(s => s.id === subScopeId, produce((ss) => {
if (!ss.modifiers) ss.modifiers = [];
ss.modifiers.push(newModifier);
}));
},
updateModifier: (subScopeId: string, modifierId: string, updates: Partial<Modifier>) => {
setSubScopes(
s => s.id === subScopeId,
'modifiers',
(m: Modifier) => m.id === modifierId,
updates
);
},
removeModifier: (subScopeId: string, modifierId: string) => {
setSubScopes(
s => s.id === subScopeId,
'modifiers',
(m = []) => m.filter(mod => mod.id !== modifierId)
);
},
// Templates
templateId: activeTemplateId,
@@ -66,5 +119,74 @@ export const appStore = {
templateName: activeTemplateName,
setTemplateName: setActiveTemplateName,
templateItems: activeTemplateItems,
setTemplateItems: setActiveTemplateItems
setTemplateItems: setActiveTemplateItems,
templateModifiers: activeTemplateModifiers,
setTemplateModifiers: setActiveTemplateModifiers,
templateSubScopeName: activeTemplateSubScopeName,
setTemplateSubScopeName: setActiveTemplateSubScopeName,
addTemplateModifier(moduleId: string) {
const module = ModifierRegistry.getModule(moduleId);
const newModifier: Modifier = {
id: Math.random().toString(36).substr(2, 9),
moduleId: module.id,
name: module.defaultLabel,
value: module.defaultValue,
valueType: module.defaultValueType,
unitLabel: module.defaultUnitLabel,
type: module.id as Modifier['type'],
includeInTotal: module.id !== 'man-hours',
presetId: module.defaultPresetId,
targetField: module.defaultTargetField,
expression: module.defaultExpression,
parameters: module.defaultParameters ? { ...module.defaultParameters } : undefined
};
setActiveTemplateModifiers(prev => [...prev, newModifier]);
},
updateTemplateModifier: (modifierId: string, updates: Partial<Modifier>) => {
setActiveTemplateModifiers(
(m: Modifier) => m.id === modifierId,
updates
);
},
removeTemplateModifier: (modifierId: string) => {
setActiveTemplateModifiers(prev => prev.filter(mod => mod.id !== modifierId));
},
resetEstimate: () => {
setActiveItems([]);
setActiveIgnoredLines([]);
setActiveEstimateId(null);
setActiveEstimateName('');
setActiveHistory([]);
setActiveLatestSnapshot(null);
setIsLatestVersion(true);
setScopes([]);
setSubScopes([]);
setEstimateItems([]);
setClientInfo({ name: '', address: '', phone: '', fax: '' });
setJobInfo({ number: '', name: '', address: '', workOrder: '' });
setGeneralEstimateNotes('');
setGeneralPreNotes('');
setActiveTemplateName('');
setActiveTemplateItems([]);
setActiveTemplateModifiers([]);
setActiveTemplateSubScopeName(undefined);
setActiveTemplateId(null);
},
// Suppliers
suppliers,
async loadSuppliers() {
try {
const records = await pb.collection(COLLECTIONS.STANDARD_PRICES).getFullList({
fields: 'supplier',
requestKey: null
});
const unique = [...new Set(records.map(r => r.supplier).filter(Boolean))].sort() as string[];
setSuppliers(unique);
} catch (error) {
console.error('Error loading suppliers:', error);
}
}
};
+22
View File
@@ -0,0 +1,22 @@
import { createSignal } from 'solid-js';
import { pb } from '../utils/db';
const [isAuthenticated, setIsAuthenticated] = createSignal(pb.authStore.isValid);
const [currentUser, setCurrentUser] = createSignal(pb.authStore.model);
// Listen for auth changes from PocketBase
pb.authStore.onChange((_, model) => {
setIsAuthenticated(pb.authStore.isValid);
setCurrentUser(model);
});
export const authStore = {
isAuthenticated,
currentUser,
login: async (email: string, pass: string) => {
return await pb.collection('users').authWithPassword(email, pass);
},
logout: () => {
pb.authStore.clear();
}
};
+65
View File
@@ -3,18 +3,43 @@ export interface Scope {
name: string;
managementFee: number;
managementFeeType: 'percent' | 'amount';
managementFeeQuantity: number;
managementFeeRate: number;
deliveryFee: number;
deliveryFeeType: 'percent' | 'amount';
deliveryFeeQuantity: number;
deliveryFeeRate: number;
useManagementLogic: 'percent' | 'simple';
useDeliveryLogic: 'percent' | 'simple';
estimatorNotes?: string;
bidDescription?: string;
supplier?: string;
}
export type ModifierType = 'tax' | 'man-hours' | 'adjustment' | 'computed-field' | 'prevailing-wage' | 'custom-calculation' | 'custom';
export type ModifierPresetId = 'prevailing-wage' | 'custom';
export type ModifierTargetField = 'unitPrice' | 'quantity' | 'subtotal';
export interface Modifier {
id: string;
moduleId: string;
name: string;
value: number;
valueType: 'percent' | 'amount' | 'unit';
type: ModifierType;
unitLabel?: string; // e.g. "hrs" for man-hours
includeInTotal: boolean;
presetId?: ModifierPresetId;
targetField?: ModifierTargetField;
expression?: string;
parameters?: Record<string, number>;
}
export interface SubScope {
id: string;
name: string;
scopeId: string;
modifiers?: Modifier[];
}
export interface JobInfo {
@@ -54,4 +79,44 @@ export interface Template {
id: string;
name: string;
items: TemplateItem[];
modifiers?: Modifier[];
defaultSubScopeName?: string;
}
export type ScopeTextTemplateType = 'estimatorNotes' | 'bidDescription';
export type ScopeTextTemplateDiffOp = 'equal' | 'add' | 'remove';
export interface ScopeTextTemplateDiffLine {
op: ScopeTextTemplateDiffOp;
text: string;
}
export interface ScopeTextTemplateHistoryEntry {
id: string;
timestamp: string;
updatedBy: string;
action: 'created' | 'updated';
previousContent: string;
nextContent: string;
diff: ScopeTextTemplateDiffLine[];
}
export interface ScopeTextTemplate {
id: string;
name: string;
type: ScopeTextTemplateType;
content: string;
lastUpdatedBy?: string;
history?: ScopeTextTemplateHistoryEntry[];
createdAt: string;
updatedAt: string;
}
export interface StandardPrice {
id: string;
category: string;
name: string;
price: number;
supplier?: string;
}
+3 -1
View File
@@ -5,5 +5,7 @@ export const pb = new PocketBase('https://pocketbase.ccllc.pro');
export const COLLECTIONS = {
TEMPLATES: 'EstiMaker_Templates',
ESTIMATES: 'EstiMaker_Estimates'
SCOPE_TEXT_TEMPLATES: 'EstiMaker_ScopeTextTemplates',
ESTIMATES: 'EstiMaker_Estimates',
STANDARD_PRICES: 'EstiMaker_StandardPrices'
} as const;
+132
View File
@@ -0,0 +1,132 @@
import type { Modifier, EstimateItem, ModifierTargetField } from '../types';
import {
CUSTOM_CALC_DEFAULT_EXPRESSION,
getComputedModifierStatus,
getEffectiveItemPricing,
PREVAILING_WAGE_DEFAULT_EXPRESSION,
PREVAILING_WAGE_DEFAULT_PARAMETERS,
resolveModifierModuleId
} from './pricing';
export interface ModifierModule {
id: string;
name: string;
description: string;
defaultLabel: string;
defaultValue: number;
defaultValueType: 'percent' | 'amount' | 'unit';
defaultUnitLabel?: string;
defaultPresetId?: 'prevailing-wage' | 'custom';
defaultTargetField?: ModifierTargetField;
defaultExpression?: string;
defaultParameters?: Record<string, number>;
calculate: (modifier: Modifier, subScopeTotalWithMarkup: number, subScopeItems: EstimateItem[]) => number;
calculateDisplay?: (modifier: Modifier, subScopeTotalWithMarkup: number, subScopeItems: EstimateItem[], subScopeModifiers?: Modifier[]) => number;
}
const getPreMarkupSubtotal = (items: EstimateItem[], modifiers: Modifier[] = []) => {
return items.reduce((sum, item) => sum + getEffectiveItemPricing(item, modifiers).effectiveSubtotal, 0);
};
const TaxModule: ModifierModule = {
id: 'tax',
name: 'Tax',
description: 'Calculates percentage tax on the sub-scope total.',
defaultLabel: 'Sales Tax',
defaultValue: 0,
defaultValueType: 'percent',
calculate: (m, base) => {
if (m.valueType === 'percent') return base * (m.value / 100);
return m.value;
}
};
const ManHoursModule: ModifierModule = {
id: 'man-hours',
name: 'Man-Hours',
description: 'Calculates labor hours by dividing the sub-scope subtotal by a production factor.',
defaultLabel: 'Estimated Hours',
defaultValue: 30,
defaultValueType: 'unit',
defaultUnitLabel: 'factor',
calculate: () => 0,
calculateDisplay: (m, _, items, subScopeModifiers = []) => {
const factor = m.value || 1;
if (factor === 0) return 0;
const subtotal = getPreMarkupSubtotal(items, subScopeModifiers);
return subtotal / factor;
}
};
const SimpleAdjustmentModule: ModifierModule = {
id: 'adjustment',
name: 'Fixed adjustment',
description: 'Adds or subtracts a fixed amount or percentage.',
defaultLabel: 'Adjustment',
defaultValue: 0,
defaultValueType: 'amount',
calculate: (m, base) => {
if (m.valueType === 'percent') return base * (m.value / 100);
return m.value;
}
};
const PrevailingWageModule: ModifierModule = {
id: 'prevailing-wage',
name: 'Prevailing Wage',
description: 'Scales each item price by the prevailing wage multiplier.',
defaultLabel: 'Prevailing Wage',
defaultValue: 0,
defaultValueType: 'unit',
defaultPresetId: 'prevailing-wage',
defaultTargetField: 'unitPrice',
defaultExpression: PREVAILING_WAGE_DEFAULT_EXPRESSION,
defaultParameters: { ...PREVAILING_WAGE_DEFAULT_PARAMETERS },
calculate: () => 0,
calculateDisplay: (modifier) => getComputedModifierStatus(modifier).multiplier || 0
};
const CustomCalculationModule: ModifierModule = {
id: 'custom-calculation',
name: 'Custom Math',
description: 'Applies a custom expression using price, quantity, and subtotal variables.',
defaultLabel: 'Custom Math',
defaultValue: 0,
defaultValueType: 'unit',
defaultPresetId: 'custom',
defaultTargetField: 'unitPrice',
defaultExpression: CUSTOM_CALC_DEFAULT_EXPRESSION,
calculate: () => 0,
calculateDisplay: () => 0
};
export const MODIFIER_MODULES: ModifierModule[] = [
TaxModule,
ManHoursModule,
SimpleAdjustmentModule,
PrevailingWageModule,
CustomCalculationModule
];
const getModuleByResolvedId = (id: string) => MODIFIER_MODULES.find(m => m.id === id) || SimpleAdjustmentModule;
export const ModifierRegistry = {
getModule: (id: string, modifier?: Modifier) => {
if (id === 'computed-field' && modifier) {
return getModuleByResolvedId(resolveModifierModuleId(modifier));
}
return getModuleByResolvedId(id);
},
calculate: (modifier: Modifier, baseTotal: number, items: EstimateItem[]) => {
const module = ModifierRegistry.getModule(modifier.moduleId, modifier);
return module.calculate(modifier, baseTotal, items);
},
calculateDisplay: (modifier: Modifier, baseTotal: number, items: EstimateItem[], subScopeModifiers: Modifier[] = []) => {
const module = ModifierRegistry.getModule(modifier.moduleId, modifier);
if (module.calculateDisplay) return module.calculateDisplay(modifier, baseTotal, items, subScopeModifiers);
return module.calculate(modifier, baseTotal, items);
}
};
+412
View File
@@ -0,0 +1,412 @@
import type { EstimateItem, Modifier, ModifierTargetField } from '../types';
export const PREVAILING_WAGE_DEFAULT_PARAMETERS = {
baseRate: 30,
prevailingRate: 50
} as const;
export const PREVAILING_WAGE_DEFAULT_EXPRESSION = 'price * multiplier';
export const CUSTOM_CALC_DEFAULT_EXPRESSION = 'price';
export const TARGET_FIELD_LABELS: Record<ModifierTargetField, string> = {
unitPrice: 'Price',
quantity: 'Quantity',
subtotal: 'Subtotal'
};
const PREVIEW_ITEM: EstimateItem = {
id: '__preview__',
description: 'Preview',
quantity: 1,
unitPrice: 1,
markup: 0,
markupType: 'percent',
contingency: 0,
contingencyType: 'percent'
};
type Token =
| { type: 'number'; value: number }
| { type: 'identifier'; value: string }
| { type: 'operator'; value: '+' | '-' | '*' | '/' }
| { type: 'paren'; value: '(' | ')' };
interface ComputedItemState {
quantity: number;
unitPrice: number;
}
export interface ComputedModifierStatus {
parameters: Record<string, number>;
expression: string;
targetField: ModifierTargetField;
multiplier?: number;
error?: string;
}
export interface EffectiveItemPricing {
baseQuantity: number;
effectiveQuantity: number;
baseUnitPrice: number;
effectiveUnitPrice: number;
baseSubtotal: number;
effectiveSubtotal: number;
markupAmount: number;
contingencyAmount: number;
total: number;
hasAdjustedQuantity: boolean;
hasAdjustedUnitPrice: boolean;
hasAdjustedSubtotal: boolean;
hasDirectQuantityAdjustment: boolean;
hasDirectUnitPriceAdjustment: boolean;
hasDirectSubtotalAdjustment: boolean;
}
export interface ItemTotals {
subtotal: number;
markup: number;
contingency: number;
total: number;
quantity: number;
}
export const resolveModifierModuleId = (modifier: Modifier) => {
if (modifier.moduleId === 'computed-field') {
return modifier.presetId === 'custom' ? 'custom-calculation' : 'prevailing-wage';
}
return modifier.moduleId;
};
export const isPrevailingWageModifier = (modifier: Modifier) => resolveModifierModuleId(modifier) === 'prevailing-wage';
export const isCustomCalculationModifier = (modifier: Modifier) => resolveModifierModuleId(modifier) === 'custom-calculation';
export const isComputedFieldModifier = (modifier: Modifier) => isPrevailingWageModifier(modifier) || isCustomCalculationModifier(modifier);
export const getResolvedTargetField = (modifier: Modifier): ModifierTargetField => {
if (isPrevailingWageModifier(modifier)) return 'unitPrice';
return modifier.targetField || 'unitPrice';
};
export const getComputedModifierParameters = (modifier: Modifier) => {
const defaults: Record<string, number> = isPrevailingWageModifier(modifier)
? PREVAILING_WAGE_DEFAULT_PARAMETERS
: {};
return {
...defaults,
...(modifier.parameters || {})
};
};
export const getModifierExpression = (modifier: Modifier) => {
if (isPrevailingWageModifier(modifier)) {
return PREVAILING_WAGE_DEFAULT_EXPRESSION;
}
return modifier.expression?.trim() || CUSTOM_CALC_DEFAULT_EXPRESSION;
};
const tokenizeExpression = (expression: string): Token[] => {
const tokens: Token[] = [];
let index = 0;
while (index < expression.length) {
const char = expression[index];
if (/\s/.test(char)) {
index += 1;
continue;
}
if (/[+\-*/]/.test(char)) {
tokens.push({ type: 'operator', value: char as '+' | '-' | '*' | '/' });
index += 1;
continue;
}
if (char === '(' || char === ')') {
tokens.push({ type: 'paren', value: char });
index += 1;
continue;
}
if (/\d|\./.test(char)) {
let end = index + 1;
while (end < expression.length && /[\d.]/.test(expression[end])) end += 1;
const raw = expression.slice(index, end);
const value = Number.parseFloat(raw);
if (!Number.isFinite(value)) {
throw new Error(`Invalid number "${raw}".`);
}
tokens.push({ type: 'number', value });
index = end;
continue;
}
if (/[A-Za-z_]/.test(char)) {
let end = index + 1;
while (end < expression.length && /[A-Za-z0-9_]/.test(expression[end])) end += 1;
tokens.push({ type: 'identifier', value: expression.slice(index, end) });
index = end;
continue;
}
throw new Error(`Unsupported token "${char}".`);
}
return tokens;
};
const evaluateArithmeticExpression = (expression: string, context: Record<string, number>) => {
const trimmedExpression = expression.trim();
if (!trimmedExpression) {
throw new Error('Expression is required.');
}
const tokens = tokenizeExpression(trimmedExpression);
let index = 0;
const peek = () => tokens[index];
const consume = () => tokens[index++];
const parseExpression = (): number => {
let value = parseTerm();
while (peek()?.type === 'operator' && (peek()?.value === '+' || peek()?.value === '-')) {
const operator = consume().value;
const nextValue = parseTerm();
value = operator === '+' ? value + nextValue : value - nextValue;
}
return value;
};
const parseTerm = (): number => {
let value = parseFactor();
while (peek()?.type === 'operator' && (peek()?.value === '*' || peek()?.value === '/')) {
const operator = consume().value;
const nextValue = parseFactor();
if (operator === '/') {
if (nextValue === 0) throw new Error('Division by zero is not allowed.');
value /= nextValue;
} else {
value *= nextValue;
}
}
return value;
};
const parseFactor = (): number => {
const token = peek();
if (!token) throw new Error('Unexpected end of expression.');
if (token.type === 'operator' && token.value === '-') {
consume();
return -parseFactor();
}
if (token.type === 'number') {
consume();
return token.value;
}
if (token.type === 'identifier') {
consume();
const value = context[token.value];
if (!Number.isFinite(value)) {
throw new Error(`Unknown variable "${token.value}".`);
}
return value;
}
if (token.type === 'paren' && token.value === '(') {
consume();
const value = parseExpression();
const closing = consume();
if (!closing || closing.type !== 'paren' || closing.value !== ')') {
throw new Error('Missing closing parenthesis.');
}
return value;
}
throw new Error('Unexpected token in expression.');
};
const value = parseExpression();
if (index < tokens.length) {
throw new Error('Unexpected trailing token.');
}
if (!Number.isFinite(value)) {
throw new Error('Expression did not produce a finite number.');
}
return value;
};
const buildModifierContext = (modifier: Modifier, item: EstimateItem, state: ComputedItemState) => {
const parameters = getComputedModifierParameters(modifier);
const baseRate = parameters.baseRate;
const prevailingRate = parameters.prevailingRate;
if (isPrevailingWageModifier(modifier)) {
if (!Number.isFinite(baseRate) || baseRate <= 0) {
throw new Error('Base rate must be greater than 0.');
}
if (!Number.isFinite(prevailingRate)) {
throw new Error('Prevailing wage rate must be a valid number.');
}
}
const targetField = getResolvedTargetField(modifier);
const subtotal = state.quantity * state.unitPrice;
const context: Record<string, number> = {
value: targetField === 'unitPrice'
? state.unitPrice
: targetField === 'quantity'
? state.quantity
: subtotal,
price: state.unitPrice,
quantity: state.quantity,
subtotal,
basePrice: item.unitPrice,
baseQuantity: item.quantity,
baseSubtotal: item.quantity * item.unitPrice,
markup: item.markup,
contingency: item.contingency,
...parameters
};
if (Number.isFinite(baseRate) && baseRate > 0 && Number.isFinite(prevailingRate)) {
context.multiplier = prevailingRate / baseRate;
}
return {
parameters,
targetField,
context
};
};
const applyComputedModifierToState = (modifier: Modifier, item: EstimateItem, state: ComputedItemState) => {
const expression = getModifierExpression(modifier);
const { targetField, context } = buildModifierContext(modifier, item, state);
const nextValue = evaluateArithmeticExpression(expression, context);
if (targetField === 'unitPrice') {
return {
quantity: state.quantity,
unitPrice: nextValue
};
}
if (targetField === 'quantity') {
return {
quantity: nextValue,
unitPrice: state.unitPrice
};
}
if (state.quantity === 0) {
if (nextValue === 0) {
return {
quantity: state.quantity,
unitPrice: 0
};
}
throw new Error('Subtotal output requires a non-zero quantity.');
}
return {
quantity: state.quantity,
unitPrice: nextValue / state.quantity
};
};
export const getComputedModifierStatus = (
modifier: Modifier,
item: EstimateItem = PREVIEW_ITEM,
state: ComputedItemState = { quantity: item.quantity, unitPrice: item.unitPrice }
): ComputedModifierStatus => {
const expression = getModifierExpression(modifier);
try {
const { parameters, targetField, context } = buildModifierContext(modifier, item, state);
evaluateArithmeticExpression(expression, context);
return {
parameters,
expression,
targetField,
multiplier: context.multiplier
};
} catch (error) {
return {
parameters: getComputedModifierParameters(modifier),
expression,
targetField: getResolvedTargetField(modifier),
error: error instanceof Error ? error.message : 'Invalid expression.'
};
}
};
export const getEffectiveItemPricing = (item: EstimateItem, modifiers: Modifier[] = []): EffectiveItemPricing => {
let state: ComputedItemState = {
quantity: item.quantity,
unitPrice: item.unitPrice
};
const adjustedTargets = new Set<ModifierTargetField>();
modifiers.forEach((modifier) => {
if (!modifier.includeInTotal || !isComputedFieldModifier(modifier)) return;
try {
state = applyComputedModifierToState(modifier, item, state);
adjustedTargets.add(getResolvedTargetField(modifier));
} catch {
// Invalid computed modifiers are treated as inactive for pricing.
}
});
const effectiveSubtotal = state.quantity * state.unitPrice;
const markupAmount = item.markupType === 'percent'
? effectiveSubtotal * (item.markup / 100)
: item.markup;
const contingencyAmount = item.contingencyType === 'percent'
? effectiveSubtotal * (item.contingency / 100)
: item.contingency;
return {
baseQuantity: item.quantity,
effectiveQuantity: state.quantity,
baseUnitPrice: item.unitPrice,
effectiveUnitPrice: state.unitPrice,
baseSubtotal: item.quantity * item.unitPrice,
effectiveSubtotal,
markupAmount,
contingencyAmount,
total: effectiveSubtotal + markupAmount + contingencyAmount,
hasAdjustedQuantity: state.quantity !== item.quantity,
hasAdjustedUnitPrice: state.unitPrice !== item.unitPrice,
hasAdjustedSubtotal: effectiveSubtotal !== item.quantity * item.unitPrice,
hasDirectQuantityAdjustment: adjustedTargets.has('quantity'),
hasDirectUnitPriceAdjustment: adjustedTargets.has('unitPrice'),
hasDirectSubtotalAdjustment: adjustedTargets.has('subtotal')
};
};
export const getItemsPricingTotals = (items: EstimateItem[], modifiers: Modifier[] = []): ItemTotals => {
return items.reduce<ItemTotals>((acc, item) => {
const pricing = getEffectiveItemPricing(item, modifiers);
return {
subtotal: acc.subtotal + pricing.effectiveSubtotal,
markup: acc.markup + pricing.markupAmount,
contingency: acc.contingency + pricing.contingencyAmount,
total: acc.total + pricing.total,
quantity: acc.quantity + pricing.effectiveQuantity
};
}, {
subtotal: 0,
markup: 0,
contingency: 0,
total: 0,
quantity: 0
});
};
+56
View File
@@ -0,0 +1,56 @@
import type { ScopeTextTemplateDiffLine } from '../types';
const buildLcsTable = (a: string[], b: string[]) => {
const table = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
for (let i = a.length - 1; i >= 0; i--) {
for (let j = b.length - 1; j >= 0; j--) {
if (a[i] === b[j]) {
table[i][j] = table[i + 1][j + 1] + 1;
} else {
table[i][j] = Math.max(table[i + 1][j], table[i][j + 1]);
}
}
}
return table;
};
export const createScopeTextDiff = (previousContent: string, nextContent: string): ScopeTextTemplateDiffLine[] => {
const previousLines = previousContent.split('\n');
const nextLines = nextContent.split('\n');
const table = buildLcsTable(previousLines, nextLines);
const diff: ScopeTextTemplateDiffLine[] = [];
let i = 0;
let j = 0;
while (i < previousLines.length && j < nextLines.length) {
if (previousLines[i] === nextLines[j]) {
diff.push({ op: 'equal', text: previousLines[i] });
i++;
j++;
continue;
}
if (table[i + 1][j] >= table[i][j + 1]) {
diff.push({ op: 'remove', text: previousLines[i] });
i++;
} else {
diff.push({ op: 'add', text: nextLines[j] });
j++;
}
}
while (i < previousLines.length) {
diff.push({ op: 'remove', text: previousLines[i] });
i++;
}
while (j < nextLines.length) {
diff.push({ op: 'add', text: nextLines[j] });
j++;
}
return diff;
};
+13
View File
@@ -0,0 +1,13 @@
import type { Scope } from '../types';
export const normalizeScopes = (scopes: any[]): Scope[] => {
if (!Array.isArray(scopes)) return [];
return scopes.map(scope => ({
...scope,
managementFeeQuantity: scope.managementFeeQuantity ?? 1,
managementFeeRate: scope.managementFeeRate ?? 0,
deliveryFeeQuantity: scope.deliveryFeeQuantity ?? 1,
deliveryFeeRate: scope.deliveryFeeRate ?? 0
}));
};
+84
View File
@@ -0,0 +1,84 @@
import { parseCSV } from './csv-parser';
export interface ParsedStandardPriceRow {
category: string;
name: string;
price: number;
supplier?: string;
}
export interface StandardPriceImportResult {
rows: ParsedStandardPriceRow[];
skipped: number;
}
const HEADER_ALIASES = {
category: new Set(['category', 'group', 'type', 'materialcategory']),
name: new Set(['name', 'item', 'itemname', 'material', 'materialname', 'description', 'product']),
price: new Set(['price', 'unitprice', 'cost', 'amount', 'materialprice']),
supplier: new Set(['supplier', 'vendor'])
};
const normalizeHeader = (value: string) => value.toLowerCase().replace(/[^a-z0-9]/g, '');
const parsePrice = (value: string) => {
const cleaned = value.replace(/[$,\s]/g, '');
const parsed = Number.parseFloat(cleaned);
return Number.isFinite(parsed) ? parsed : null;
};
const getColumnIndex = (headers: string[], aliases: Set<string>) => {
return headers.findIndex(header => aliases.has(normalizeHeader(header)));
};
export function parseStandardPricesCSV(csvText: string, defaultSupplier?: string): StandardPriceImportResult {
const rows = parseCSV(csvText)
.map(row => row.map(cell => cell.trim()))
.filter(row => row.some(cell => cell !== ''));
if (rows.length === 0) {
throw new Error('The CSV file is empty.');
}
const headerRow = rows[0];
const dataRows = rows.slice(1);
const categoryIndex = getColumnIndex(headerRow, HEADER_ALIASES.category);
const nameIndex = getColumnIndex(headerRow, HEADER_ALIASES.name);
const priceIndex = getColumnIndex(headerRow, HEADER_ALIASES.price);
const supplierIndex = getColumnIndex(headerRow, HEADER_ALIASES.supplier);
if (nameIndex === -1 || priceIndex === -1) {
throw new Error('CSV must include item name and price columns.');
}
const parsedRows: ParsedStandardPriceRow[] = [];
let skipped = 0;
for (const row of dataRows) {
const name = row[nameIndex]?.trim() || '';
const price = parsePrice(row[priceIndex] || '');
if (!name || price === null || price < 0) {
skipped += 1;
continue;
}
const category = categoryIndex >= 0 ? row[categoryIndex]?.trim() || 'Uncategorized' : 'Uncategorized';
const supplierFromRow = supplierIndex >= 0 ? row[supplierIndex]?.trim() || '' : '';
const supplier = supplierFromRow || defaultSupplier?.trim() || '';
parsedRows.push({
category,
name,
price,
supplier: supplier || undefined
});
}
return { rows: parsedRows, skipped };
}
export function normalizeStandardPriceKey(name: string, supplier?: string) {
return `${name.trim().toLowerCase()}::${(supplier || '').trim().toLowerCase()}`;
}
+87
View File
@@ -0,0 +1,87 @@
import { createSignal } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { authStore } from '../store/authStore';
export default function Login() {
const [email, setEmail] = createSignal('');
const [password, setPassword] = createSignal('');
const [error, setError] = createSignal('');
const [isLoading, setIsLoading] = createSignal(false);
const navigate = useNavigate();
const handleLogin = async (e: Event) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await authStore.login(email(), password());
// Successful login, navigate to home
navigate('/', { replace: true });
} catch (err: any) {
console.error('Login error:', err);
setError(err.message || 'Failed to login. Please check your credentials.');
} finally {
setIsLoading(false);
}
};
return (
<div class="min-h-[80vh] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 bg-white p-8 rounded-xl shadow-lg border border-gray-100">
<div>
<h2 class="mt-2 text-center text-3xl font-extrabold text-gray-900 tracking-tight">
Sign in to EstiMaker
</h2>
</div>
<form class="mt-8 space-y-6" onSubmit={handleLogin}>
{error() && (
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md relative text-sm" role="alert">
<span class="block sm:inline">{error()}</span>
</div>
)}
<div class="rounded-md shadow-sm -space-y-px">
<div>
<label for="email-address" class="sr-only">Email address</label>
<input
id="email-address"
name="email"
type="email"
autocomplete="email"
required
class="appearance-none rounded-none relative block w-full px-3 py-2.5 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Email address"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
/>
</div>
<div>
<label for="password" class="sr-only">Password</label>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
required
class="appearance-none rounded-none relative block w-full px-3 py-2.5 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Password"
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
/>
</div>
</div>
<div>
<button
type="submit"
disabled={isLoading()}
class="group relative w-full flex justify-center py-2.5 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm transition-colors"
>
{isLoading() ? 'Signing in...' : 'Sign in'}
</button>
</div>
</form>
</div>
</div>
);
}
+486
View File
@@ -0,0 +1,486 @@
import { createSignal, onMount, For, createMemo, createResource, Show } from 'solid-js';
import type { Component } from 'solid-js';
import { Trash2, Plus, RefreshCw, Edit2, Save, X, ChevronDown, Upload } from 'lucide-solid';
import { pb, COLLECTIONS } from '../utils/db';
import type { StandardPrice } from '../types';
import { appStore } from '../store/appStore';
import { normalizeStandardPriceKey, parseStandardPricesCSV } from '../utils/standard-price-import';
const StandardPricing: Component = () => {
const fetchPrices = async () => {
const records = await pb.collection(COLLECTIONS.STANDARD_PRICES).getFullList({
sort: 'category,name',
requestKey: null
});
return records as unknown as StandardPrice[];
};
const [prices, { refetch }] = createResource(fetchPrices);
const [newCategory, setNewCategory] = createSignal('');
const [newName, setNewName] = createSignal('');
const [newPrice, setNewPrice] = createSignal<number>(0);
const [newSupplier, setNewSupplier] = createSignal('');
const [importSupplier, setImportSupplier] = createSignal('');
const [isImporting, setIsImporting] = createSignal(false);
const [isImportOpen, setIsImportOpen] = createSignal(false);
const [importSummary, setImportSummary] = createSignal<string | null>(null);
const [editingId, setEditingId] = createSignal<string | null>(null);
const [editCategory, setEditCategory] = createSignal('');
const [editName, setEditName] = createSignal('');
const [editPrice, setEditPrice] = createSignal<number>(0);
const [editSupplier, setEditSupplier] = createSignal('');
const categories = createMemo(() => {
return [...new Set((prices() || []).map(p => p.category))].sort();
});
const itemNames = createMemo(() => {
return [...new Set((prices() || []).map(p => p.name))].sort();
});
onMount(() => {
appStore.loadSuppliers();
});
const handleAddPrice = async () => {
if (!newCategory() || !newName() || newPrice() < 0) return;
try {
const record = {
category: newCategory(),
name: newName(),
price: newPrice(),
supplier: newSupplier()
};
await pb.collection(COLLECTIONS.STANDARD_PRICES).create(record);
refetch();
appStore.loadSuppliers(); // Refresh global list
// Reset fields
setNewName('');
setNewPrice(0);
setNewSupplier('');
// Keep category the same to easily add multiple items to same category
} catch (error) {
console.error('Error adding price:', error);
alert('Failed to add standard price.');
}
};
const readFileAsText = (file: File) => new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error('Failed to read the selected file.'));
reader.readAsText(file);
});
const handleImportFile = async (event: Event) => {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
setImportSummary(null);
setIsImporting(true);
try {
const csvText = await readFileAsText(file);
const { rows, skipped } = parseStandardPricesCSV(csvText, importSupplier());
if (rows.length === 0) {
throw new Error('No valid pricing rows were found in the CSV.');
}
const existingRecords = await pb.collection(COLLECTIONS.STANDARD_PRICES).getFullList({
fields: 'id,name,supplier',
requestKey: null
});
const existingByKey = new Map(
(existingRecords as unknown as Pick<StandardPrice, 'id' | 'name' | 'supplier'>[]).map(record => [
normalizeStandardPriceKey(record.name, record.supplier),
record.id
])
);
let created = 0;
let updated = 0;
for (const row of rows) {
const record = {
category: row.category,
name: row.name,
price: row.price,
supplier: row.supplier || ''
};
const existingId = existingByKey.get(normalizeStandardPriceKey(row.name, row.supplier));
if (existingId) {
await pb.collection(COLLECTIONS.STANDARD_PRICES).update(existingId, record);
updated += 1;
continue;
}
const createdRecord = await pb.collection(COLLECTIONS.STANDARD_PRICES).create(record);
existingByKey.set(normalizeStandardPriceKey(row.name, row.supplier), createdRecord.id);
created += 1;
}
await refetch();
await appStore.loadSuppliers();
setImportSummary(`Imported ${rows.length} rows: ${created} added, ${updated} replaced${skipped ? `, ${skipped} skipped` : ''}.`);
} catch (error) {
console.error('Error importing standard prices:', error);
const message = error instanceof Error ? error.message : 'Failed to import standard prices.';
setImportSummary(message);
alert(message);
} finally {
setIsImporting(false);
input.value = '';
}
};
const handleEditStart = (price: StandardPrice) => {
setEditingId(price.id);
setEditCategory(price.category);
setEditName(price.name);
setEditPrice(price.price);
setEditSupplier(price.supplier || '');
};
const handleEditCancel = () => {
setEditingId(null);
};
const handleEditSave = async () => {
const id = editingId();
if (!id || !editCategory() || !editName()) return;
try {
const updates = {
category: editCategory(),
name: editName(),
price: editPrice(),
supplier: editSupplier()
};
await pb.collection(COLLECTIONS.STANDARD_PRICES).update(id, updates);
refetch();
setEditingId(null);
appStore.loadSuppliers(); // Refresh global list
} catch (error) {
console.error('Error updating price:', error);
alert('Failed to update standard price.');
}
};
const handleDeletePrice = async (id: string) => {
if (!confirm('Are you sure you want to delete this standard price?')) return;
try {
await pb.collection(COLLECTIONS.STANDARD_PRICES).delete(id);
refetch();
appStore.loadSuppliers(); // Refresh global list
} catch (error) {
console.error('Error deleting price:', error);
alert('Failed to delete standard price.');
}
};
return (
<div class="flex flex-col items-center py-6 px-4 font-sans">
<div class="max-w-5xl w-full bg-white rounded-xl shadow-premium p-8 mb-8 border border-gray-100">
<div class="flex items-center justify-between mb-8 pb-4 border-b border-gray-100">
<div>
<h1 class="text-3xl font-bold text-gray-800 mb-2 tracking-tight">Standard Pricing</h1>
<p class="text-gray-500 font-medium">Add Standard Pricing via "@" in item price field.</p>
</div>
<button
onClick={refetch}
class="flex items-center gap-2 px-4 py-2 text-sm font-bold text-gray-700 bg-gray-50 border border-gray-200 rounded-xl hover:bg-gray-100 transition-all"
>
<RefreshCw class={`w-4 h-4 ${prices.loading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
{/* Add New Row */}
<div class="bg-blue-50/50 rounded-xl p-4 mb-8 border border-blue-100">
<h3 class="text-sm font-bold text-blue-800 mb-4 uppercase tracking-wider">Add New Standard Price</h3>
<div class="flex items-end gap-4 overflow-x-auto pb-2">
<div class="flex-1 min-w-[200px]">
<label class="block text-[10px] font-black tracking-widest text-zinc-400 mb-1.5 uppercase pl-1">Category</label>
<input
type="text"
value={newCategory()}
onInput={(e) => setNewCategory(e.currentTarget.value)}
placeholder="e.g. Drywall"
list="category-suggestions"
class="w-full bg-zinc-50/50 border border-zinc-200/60 rounded-xl px-4 py-2.5 text-sm focus:bg-white focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 outline-none transition-all duration-200 shadow-sm shadow-zinc-200/50 placeholder:text-zinc-300"
/>
</div>
<div class="flex-1 min-w-[200px]">
<label class="block text-[10px] font-black tracking-widest text-zinc-400 mb-1.5 uppercase pl-1">Item Name</label>
<input
type="text"
value={newName()}
onInput={(e) => setNewName(e.currentTarget.value)}
placeholder="e.g. Gyp Type X"
list="name-suggestions"
class="w-full bg-zinc-50/50 border border-zinc-200/60 rounded-xl px-4 py-2.5 text-sm focus:bg-white focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 outline-none transition-all duration-200 shadow-sm shadow-zinc-200/50 placeholder:text-zinc-300"
/>
</div>
<div class="w-32 shrink-0">
<label class="block text-[10px] font-black tracking-widest text-zinc-400 mb-1.5 uppercase pl-1">Price ($)</label>
<input
type="number"
step="0.01"
value={newPrice() || ''}
onInput={(e) => setNewPrice(parseFloat(e.currentTarget.value) || 0)}
placeholder="0.00"
class="w-full bg-zinc-50/50 border border-zinc-200/60 rounded-xl px-4 py-2.5 text-sm focus:bg-white focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 outline-none text-right font-black transition-all duration-200 shadow-sm shadow-zinc-200/50"
/>
</div>
<div class="w-40 shrink-0">
<label class="block text-[10px] font-black tracking-widest text-zinc-400 mb-1.5 uppercase pl-1">Supplier (Opt)</label>
<div class="relative group/sel">
<input
type="text"
value={newSupplier()}
onInput={(e) => setNewSupplier(e.currentTarget.value)}
placeholder="Any..."
list="supplier-suggestions"
class="w-full bg-zinc-50/50 border border-zinc-200/60 rounded-xl px-4 py-2.5 text-sm focus:bg-white focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 outline-none pr-10 cursor-pointer transition-all duration-200 shadow-sm shadow-zinc-200/50 placeholder:text-zinc-300 hover:border-zinc-300"
/>
<div class="absolute right-3.5 top-1/2 -translate-y-1/2 pointer-events-none text-zinc-300 group-hover/sel:text-blue-500 transition-colors">
<ChevronDown class="w-4 h-4" />
</div>
</div>
</div>
<div class="flex items-end h-[74px]">
<button
onClick={handleAddPrice}
disabled={!newCategory() || !newName() || newPrice() < 0}
class="flex items-center gap-2 px-6 py-2.5 bg-blue-600 text-white rounded-xl font-black text-sm hover:bg-blue-700 hover:shadow-xl hover:shadow-blue-500/25 active:scale-95 active:shadow-inner disabled:opacity-30 disabled:pointer-events-none transition-all duration-200 h-[46px] shadow-lg shadow-blue-500/20"
>
<Plus class="w-4 h-4 transition-transform group-hover:rotate-90" />
<span>Add Item</span>
</button>
</div>
</div>
</div>
<div class="bg-gray-50 rounded-xl mb-8 border border-gray-200 overflow-hidden">
<button
onClick={() => setIsImportOpen(!isImportOpen())}
class="w-full flex items-center justify-between gap-4 px-4 py-3 text-left hover:bg-gray-100/80 transition-colors"
>
<div>
<h3 class="text-sm font-bold text-gray-800 uppercase tracking-wider">Import Supplier Pricing CSV</h3>
<p class="text-sm text-gray-500 mt-1">Upload an Excel-style CSV and replace matching item names within the same supplier list.</p>
</div>
<ChevronDown class={`w-5 h-5 text-gray-400 transition-transform ${isImportOpen() ? 'rotate-180' : ''}`} />
</button>
<Show when={isImportOpen()}>
<div class="border-t border-gray-200 bg-white p-4">
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div class="flex-1">
<p class="text-sm text-gray-600">
Required columns: <span class="font-semibold text-gray-800">Item Name</span>, <span class="font-semibold text-gray-800">Price</span>.
Optional columns: <span class="font-semibold text-gray-800">Category</span>, <span class="font-semibold text-gray-800">Supplier</span>.
Expected row order for your guide sheet: <span class="font-semibold text-gray-800">Category, Item Name, Price, Supplier</span>.
If the file has no supplier column, the supplier entered below will be applied to every imported row.
</p>
</div>
<div class="flex flex-col sm:flex-row gap-3 lg:items-end">
<div class="min-w-[220px]">
<label class="block text-[10px] font-black tracking-widest text-zinc-400 mb-1.5 uppercase pl-1">Supplier Override</label>
<div class="relative group/sel">
<input
type="text"
value={importSupplier()}
onInput={(e) => setImportSupplier(e.currentTarget.value)}
placeholder="Used if CSV has no supplier"
list="supplier-suggestions"
class="w-full bg-zinc-50/50 border border-zinc-200/60 rounded-xl px-4 py-2.5 text-sm focus:bg-white focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 outline-none pr-10 transition-all duration-200 shadow-sm shadow-zinc-200/50 placeholder:text-zinc-300"
/>
<div class="absolute right-3.5 top-1/2 -translate-y-1/2 pointer-events-none text-zinc-300 group-hover/sel:text-blue-500 transition-colors">
<ChevronDown class="w-4 h-4" />
</div>
</div>
</div>
<label class={`flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-black text-sm transition-all duration-200 h-[46px] ${isImporting() ? 'bg-gray-100 text-gray-500 border border-gray-200 cursor-wait' : 'bg-blue-600 text-white hover:bg-blue-700 hover:shadow-xl hover:shadow-blue-500/25 active:scale-95 active:shadow-inner cursor-pointer shadow-lg shadow-blue-500/20'}`}>
<Upload class={`w-4 h-4 ${isImporting() ? 'animate-pulse' : ''}`} />
<span>{isImporting() ? 'Importing...' : 'Choose CSV'}</span>
<input
type="file"
accept=".csv,text/csv"
onChange={handleImportFile}
disabled={isImporting()}
class="hidden"
/>
</label>
</div>
</div>
<Show when={importSummary()}>
<div class="mt-4 text-sm font-medium text-gray-700 bg-gray-50 border border-gray-200 rounded-xl px-4 py-3">
{importSummary()}
</div>
</Show>
</div>
</Show>
</div>
{/* List of Prices */}
<div>
<h3 class="text-sm font-bold text-gray-800 mb-4 uppercase tracking-wider">Existing Prices</h3>
{prices.loading && !prices() ? (
<div class="py-12 text-center text-gray-500">Loading...</div>
) : (prices()?.length || 0) === 0 ? (
<div class="py-12 text-center text-gray-500 border-2 border-dashed border-gray-200 rounded-xl">
No standard prices added yet.
</div>
) : (
<div class="border border-gray-200 rounded-xl overflow-hidden relative">
{prices.loading && (
<div class="absolute inset-0 bg-white/50 backdrop-blur-[1px] flex items-center justify-center z-10 transition-opacity">
<RefreshCw class="w-8 h-8 text-blue-500 animate-spin" />
</div>
)}
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-gray-50 border-b border-gray-200 text-xs text-gray-500 uppercase tracking-wider font-bold">
<th class="py-3 px-4">Category</th>
<th class="py-3 px-4">Item Name</th>
<th class="py-3 px-4 text-right">Price</th>
<th class="py-3 px-4">Supplier</th>
<th class="py-3 px-4 w-24"></th>
</tr>
</thead>
<tbody>
<For each={prices()}>
{(price) => (
<tr class={`border-b border-gray-100 last:border-none transition-colors group ${editingId() === price.id ? 'bg-blue-50/30' : 'hover:bg-gray-50/50'}`}>
<td class="py-3 px-4 text-sm font-medium text-gray-700">
{editingId() === price.id ? (
<input
type="text"
value={editCategory()}
onInput={(e) => setEditCategory(e.currentTarget.value)}
list="category-suggestions"
class="w-full bg-white border border-zinc-200/60 rounded-xl px-3 py-1.5 text-xs focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 outline-none transition-all duration-200 shadow-sm"
/>
) : price.category}
</td>
<td class="py-3 px-4 text-sm font-bold text-gray-900">
{editingId() === price.id ? (
<input
type="text"
value={editName()}
onInput={(e) => setEditName(e.currentTarget.value)}
list="name-suggestions"
class="w-full bg-white border border-zinc-200/60 rounded-xl px-3 py-1.5 text-xs focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 outline-none transition-all duration-200 shadow-sm"
/>
) : price.name}
</td>
<td class="py-3 px-4 text-sm font-bold text-blue-600 text-right">
{editingId() === price.id ? (
<input
type="number"
step="0.01"
value={editPrice()}
onInput={(e) => setEditPrice(parseFloat(e.currentTarget.value) || 0)}
class="w-24 bg-white border border-zinc-200/60 rounded-xl px-3 py-1.5 text-xs focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 outline-none text-right font-black transition-all duration-200 shadow-sm"
/>
) : `$${price.price.toFixed(2)}`}
</td>
<td class="py-3 px-4 text-sm text-gray-500">
{editingId() === price.id ? (
<div class="relative group/sel">
<input
type="text"
value={editSupplier()}
onInput={(e) => setEditSupplier(e.currentTarget.value)}
list="supplier-suggestions"
class="w-full bg-white border border-zinc-200/60 rounded-xl px-3 py-1.5 text-xs focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 outline-none pr-8 cursor-pointer transition-all duration-200 shadow-sm"
/>
<div class="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-zinc-300 group-hover/sel:text-blue-500 transition-colors">
<ChevronDown class="w-3 h-3" />
</div>
</div>
) : (price.supplier || '-')}
</td>
<td class="py-3 px-4 text-right">
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{editingId() === price.id ? (
<>
<button
onClick={handleEditSave}
class="p-1.5 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Save"
>
<Save class="w-4 h-4" />
</button>
<button
onClick={handleEditCancel}
class="p-1.5 text-gray-400 hover:bg-gray-100 rounded-lg transition-colors"
title="Cancel"
>
<X class="w-4 h-4" />
</button>
</>
) : (
<>
<button
onClick={() => handleEditStart(price)}
class="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Edit"
>
<Edit2 class="w-4 h-4" />
</button>
<button
onClick={() => handleDeletePrice(price.id)}
class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Delete"
>
<Trash2 class="w-4 h-4" />
</button>
</>
)}
</div>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
)}
</div>
</div>
{/* Suggestions Memos */}
<datalist id="category-suggestions">
<For each={categories()}>
{(cat) => <option value={cat} />}
</For>
</datalist>
<datalist id="name-suggestions">
<For each={itemNames()}>
{(name) => <option value={name} />}
</For>
</datalist>
<datalist id="supplier-suggestions">
<For each={appStore.suppliers()}>
{(sup) => <option value={sup} />}
</For>
</datalist>
</div>
);
};
export default StandardPricing;
+102 -18
View File
@@ -1,12 +1,14 @@
import type { Component } from 'solid-js';
import { FolderOpen, Save } from 'lucide-solid';
import type { TemplateItem } from '../types';
import { For, createSignal, createMemo, type Component } from 'solid-js';
import { FolderOpen, Save, Calculator, Plus } from 'lucide-solid';
import type { EstimateItem, TemplateItem } from '../types';
import TemplateItemList from '../components/template/TemplateItemList';
import ModifierRow from '../components/ModifierRow';
import { ModifierRegistry, MODIFIER_MODULES } from '../utils/modifier-registry';
import TemplateLoadModal from '../components/template/TemplateLoadModal';
import { pb, COLLECTIONS } from '../utils/db';
import { createSignal } from 'solid-js';
import { appStore } from '../store/appStore';
import { getItemsPricingTotals } from '../utils/pricing';
const TemplateCreator: Component = () => {
const templateId = appStore.templateId;
@@ -15,6 +17,17 @@ const TemplateCreator: Component = () => {
const setTemplateName = appStore.setTemplateName;
const items = appStore.templateItems;
const setItems = appStore.setTemplateItems;
const modifiers = appStore.templateModifiers;
const setModifiers = appStore.setTemplateModifiers;
const defaultSubScopeName = appStore.templateSubScopeName;
const setDefaultSubScopeName = appStore.setTemplateSubScopeName;
const templateEstimateItems = createMemo<EstimateItem[]>(() => items.map(item => ({
...item,
scopeId: undefined,
subScopeId: undefined
})));
const templateSubtotal = createMemo(() => getItemsPricingTotals(templateEstimateItems(), modifiers).total);
const [showLoadModal, setShowLoadModal] = createSignal(false);
@@ -44,21 +57,27 @@ const TemplateCreator: Component = () => {
setTemplateId(record.id);
setTemplateName(record.name);
setItems(JSON.parse(JSON.stringify(record.items || [])));
setModifiers(JSON.parse(JSON.stringify(record.modifiers || [])));
setDefaultSubScopeName(record.defaultSubScopeName);
};
const handleNewTemplate = () => {
setTemplateId(null);
setTemplateName('');
setItems([]);
setModifiers([]);
setDefaultSubScopeName(undefined);
};
const handleSaveTemplate = async () => {
if (!templateName() || items.length === 0) return;
if (!templateName() || (items.length === 0 && modifiers.length === 0)) return;
try {
const record = {
name: templateName(),
items: JSON.parse(JSON.stringify(items)) // Ensure plain objects for PocketBase
items: JSON.parse(JSON.stringify(items)), // Ensure plain objects for PocketBase
modifiers: JSON.parse(JSON.stringify(modifiers)),
defaultSubScopeName: defaultSubScopeName()
};
if (templateId()) {
@@ -100,7 +119,7 @@ const TemplateCreator: Component = () => {
</button>
<button
onClick={handleSaveTemplate}
disabled={!templateName() || items.length === 0}
disabled={!templateName() || (items.length === 0 && modifiers.length === 0)}
class="flex items-center gap-2 px-6 py-2.5 text-sm font-bold text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition-all shadow-premium hover:shadow-premium-hover disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save class="w-4 h-4" />
@@ -109,17 +128,34 @@ const TemplateCreator: Component = () => {
</div>
</div>
<div class="mb-8">
<label class="block text-sm font-semibold text-gray-700 mb-2">
Sub-Scope Name
</label>
<input
type="text"
value={templateName()}
onInput={(e) => setTemplateName(e.currentTarget.value)}
placeholder="e.g. Master Bathroom Rough-in"
class="w-full bg-gray-50/50 border border-gray-200 rounded-xl px-4 py-3 text-gray-800 focus:bg-white focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none font-medium transition-all shadow-sm"
/>
<div class="flex gap-6 mb-8">
<div class="flex-1">
<label class="block text-sm font-semibold text-gray-700 mb-2">
Sub-Scope Name
</label>
<input
type="text"
value={templateName()}
onInput={(e) => setTemplateName(e.currentTarget.value)}
placeholder="e.g. Master Bathroom Rough-in"
class="w-full bg-gray-50/50 border border-gray-200 rounded-xl px-4 py-3 text-gray-800 focus:bg-white focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none font-medium transition-all shadow-sm"
/>
</div>
<div class="w-64">
<label class="block text-sm font-semibold text-gray-700 mb-2">
Default Target
</label>
<select
value={defaultSubScopeName() || ''}
onChange={(e) => setDefaultSubScopeName(e.currentTarget.value || undefined)}
class="w-full bg-gray-50/50 border border-gray-200 rounded-xl px-4 py-3 text-gray-800 focus:bg-white focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none font-medium transition-all shadow-sm appearance-none"
>
<option value="">None (Scope Level)</option>
<option value="Materials">Materials</option>
<option value="Labor">Labor</option>
</select>
</div>
</div>
<TemplateItemList
@@ -128,6 +164,54 @@ const TemplateCreator: Component = () => {
onUpdateItem={handleUpdateItem}
onDeleteItem={handleDeleteItem}
/>
{/* Modifiers Section */}
<div class="mt-12 pt-8 border-t border-gray-100">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-50 text-blue-600 rounded-xl">
<Calculator class="w-5 h-5" />
</div>
<div>
<h3 class="font-bold text-lg text-gray-800">Template Modifiers</h3>
<p class="text-xs text-gray-500 font-medium">Standard fees or adjustments for this sub-scope</p>
</div>
</div>
<div class="flex items-center gap-2">
<For each={MODIFIER_MODULES}>
{(module) => (
<button
onClick={() => appStore.addTemplateModifier(module.id)}
class="flex items-center gap-1.5 px-3 py-1.5 bg-white border border-gray-200 rounded-xl text-[10px] font-black uppercase tracking-wider text-gray-500 hover:text-blue-600 hover:border-blue-300 transition-all group shadow-sm"
>
<Plus class="w-3.5 h-3.5 transition-transform group-hover:rotate-90" />
{module.name}
</button>
)}
</For>
</div>
</div>
<div class="space-y-3 bg-gray-50/50 rounded-2xl p-6 border border-gray-100">
<For each={modifiers}>
{(modifier) => (
<ModifierRow
modifier={modifier}
calculatedValue={ModifierRegistry.calculateDisplay(modifier, templateSubtotal(), templateEstimateItems(), modifiers)}
onUpdate={appStore.updateTemplateModifier}
onDelete={appStore.removeTemplateModifier}
/>
)}
</For>
{modifiers.length === 0 && (
<div class="text-center py-8 text-gray-400 text-sm font-medium italic">
No modifiers added to this template.
</div>
)}
</div>
</div>
</div>
<TemplateLoadModal