Compare commits
32 Commits
c090c18734
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a3ac0feddf | |||
| 59c6211bb9 | |||
| cc0a1ad15d | |||
| 72135a11d2 | |||
| c2f384a105 | |||
| f2dcff7b71 | |||
| f9a19eb4c4 | |||
| 74e859ef71 | |||
| 6780d80d10 | |||
| 41803f4efa | |||
| c5b750bd08 | |||
| aac836501e | |||
| a1ca82e539 | |||
| fb7b138371 | |||
| 716a06ee28 | |||
| a9e0bafef0 | |||
| 7a93b75910 | |||
| 6fe897a91f | |||
| 971016013e | |||
| 5857844af0 | |||
| 8bd91ab002 | |||
| bd65217ddc | |||
| 229b4b1486 | |||
| 865c4a954c | |||
| f4ad67e097 | |||
| 6a1af4b723 | |||
| b52564d44d | |||
| c72da2fad5 | |||
| dfc4cccb56 | |||
| 989baa9be7 | |||
| 4b2585a59f | |||
| e637e0903d |
@@ -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)"'
|
||||
@@ -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...
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
}));
|
||||
};
|
||||
@@ -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()}`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user